estimate wizard display dec for ggod better best packages

This commit is contained in:
Mayur Shinde
2026-06-12 17:19:29 +05:30
parent 46c8933ec7
commit eff6695f54
3 changed files with 108 additions and 53 deletions
@@ -9,16 +9,47 @@
* The recommendation record is frozen into the session (spec §12 recommendations). * The recommendation record is frozen into the session (spec §12 recommendations).
*/ */
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Sparkles, Loader2, Check, ShieldCheck } from 'lucide-react'; import { Sparkles, Loader2, Check, ShieldCheck, Info } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext'; import { useEstimateWizard } from '../../EstimateWizardContext';
import { score } from '../../engine/scoringEngine'; import { score } from '../../engine/scoringEngine';
import { computeTenYearValue } from '../../engine/valueModel'; import { computeTenYearValue } from '../../engine/valueModel';
import { generateExplanation } from '../../ai/explain'; import { generateExplanation } from '../../ai/explain';
import { formatUSD } from '../../engine/pricing';
import GoodBetterBestCards from '../GoodBetterBestCards'; import GoodBetterBestCards from '../GoodBetterBestCards';
import TenYearValueChart from '../TenYearValueChart'; import TenYearValueChart from '../TenYearValueChart';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
/**
* Deterministic, manufacturer-neutral description for a package the customer
* clicked that is NOT the recommended highlight. Built purely from the tenant
* config package fields (+ the value model for the savings line) — no AI call,
* so it's instant and always on-message. Mirrors the guardrailed tone of the
* recommendation copy ("illustrative", "confirm with your carrier").
*/
function describePackage(pkg, valueModel) {
const label = PKG_LABEL[pkg.type];
const row = valueModel?.rows?.find((r) => r.type === pkg.type);
const paragraph =
`The ${label} option — ${pkg.name} — is our "${(pkg.position || '').toLowerCase()}" choice` +
`${pkg.fit ? `. It tends to fit best when: ${pkg.fit}` : '.'}`;
const bullets = [];
if (pkg.warrantyLabel) bullets.push(pkg.warrantyLabel);
if (pkg.serviceLifeYears) bullets.push(`~${pkg.serviceLifeYears}-year expected service life`);
if (pkg.hailResistance) bullets.push(`${pkg.hailResistance} hail resistance`);
if (valueModel?.premiumKnown && row?.savings) {
bullets.push(
`Illustrative 10-yr insurance savings ${formatUSD(row.savings.low)}${formatUSD(row.savings.high)} — confirm with your carrier`
);
} else if (pkg.insuranceDiscountPct > 0) {
bullets.push('May qualify for an insurance discount — confirm with your carrier');
}
return { paragraph, bullets };
}
const RecommendationStep = () => { const RecommendationStep = () => {
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard(); const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
@@ -69,18 +100,25 @@ const RecommendationStep = () => {
}, []); }, []);
const highlight = recommendation.selectedPackage; const highlight = recommendation.selectedPackage;
const selectedType = session.selectedPackage || highlight;
const isRecommendedSelected = selectedType === highlight;
const selectedPkg = tenantConfig.packages.find((p) => p.type === selectedType);
const selectedDesc = !isRecommendedSelected && selectedPkg ? describePackage(selectedPkg, value) : null;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<GoodBetterBestCards <GoodBetterBestCards
packages={tenantConfig.packages} packages={tenantConfig.packages}
pricing={tenantConfig.pricing} pricing={tenantConfig.pricing}
selectedType={session.selectedPackage || highlight} selectedType={selectedType}
highlightedType={highlight} highlightedType={highlight}
onSelect={(t) => { patchSession({ selectedPackage: t }); logEvent('package_selected', { package: t, after: 'recommendation' }); }} onSelect={(t) => { patchSession({ selectedPackage: t }); logEvent('package_selected', { package: t, after: 'recommendation' }); }}
/> />
{/* AI explanation */} {/* Explanation — describes the RECOMMENDED package (AI-assisted) while it's
selected, and switches to a deterministic per-package description when the
customer clicks one of the other options. */}
{isRecommendedSelected ? (
<div className="rounded-2xl border border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/5 p-4"> <div className="rounded-2xl border border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/5 p-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Sparkles size={16} className="text-amber-500" /> <Sparkles size={16} className="text-amber-500" />
@@ -113,6 +151,33 @@ const RecommendationStep = () => {
</> </>
)} )}
</div> </div>
) : (
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
<div className="flex items-center gap-2 mb-2">
<Info size={16} className="text-blue-500" />
<span className="font-semibold text-zinc-900 dark:text-white">
About the {PKG_LABEL[selectedType]} option
</span>
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
{PKG_LABEL[highlight]} recommended
</span>
</div>
<p className="text-sm text-zinc-700 dark:text-zinc-200 leading-relaxed">
{selectedDesc.paragraph}
</p>
<ul className="mt-3 space-y-1.5">
{selectedDesc.bullets.map((b) => (
<li key={b} className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300">
<Check size={14} className="text-blue-500 mt-0.5 shrink-0" /> {b}
</li>
))}
</ul>
<p className="mt-3 text-xs text-zinc-400">
We still suggest {PKG_LABEL[highlight]} based on your answers but every option stays available.
</p>
</div>
)}
<TenYearValueChart valueModel={value} /> <TenYearValueChart valueModel={value} />
@@ -5,7 +5,7 @@
* compliance record (spec §18 G1). * compliance record (spec §18 G1).
*/ */
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Download, Printer, Mail, Share2, CalendarCheck, FileText, Loader2, Check } from 'lucide-react'; import { Download, Printer, Mail, Share2, FileText, Loader2 } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext'; import { useEstimateWizard } from '../../EstimateWizardContext';
import { useMockStore } from '../../../../data/mockStore'; import { useMockStore } from '../../../../data/mockStore';
import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport'; import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport';
@@ -33,7 +33,6 @@ const ReportStep = () => {
const { addWizardLead } = useMockStore(); const { addWizardLead } = useMockStore();
const model = buildReportModel(session, tenantConfig); const model = buildReportModel(session, tenantConfig);
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [booked, setBooked] = useState(!!session.report?.appointmentBooked);
const firedRef = useRef(false); const firedRef = useRef(false);
// Fire CRM + nurture handoff once (spec §13 / §17 E2). The ref guard prevents a // Fire CRM + nurture handoff once (spec §13 / §17 E2). The ref guard prevents a
@@ -99,12 +98,6 @@ const ReportStep = () => {
} catch { /* user cancelled */ } } catch { /* user cancelled */ }
}; };
const handleBook = () => {
setBooked(true);
patchSession({ report: { ...session.report, appointmentBooked: true, bookedAt: new Date().toISOString() } });
logEvent('appointment_booked', { reportId: model.reportId });
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Report summary card */} {/* Report summary card */}
@@ -132,18 +125,6 @@ const ReportStep = () => {
<ActionButton icon={Mail} label="Email" onClick={handleEmail} /> <ActionButton icon={Mail} label="Email" onClick={handleEmail} />
<ActionButton icon={Share2} label="Share" onClick={handleShare} /> <ActionButton icon={Share2} label="Share" onClick={handleShare} />
</div> </div>
{/* Next step CTA */}
{booked ? (
<div className="rounded-2xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50 dark:bg-emerald-500/10 p-4 flex items-center gap-3">
<Check size={20} className="text-emerald-500" />
<div className="text-sm text-emerald-700 dark:text-emerald-300">
Inspection requested your rep will reach out via {session.inputs.contactMethod}. Thank you!
</div>
</div>
) : (
<ActionButton icon={CalendarCheck} label="Book my free inspection" onClick={handleBook} primary />
)}
</div> </div>
); );
}; };
@@ -55,6 +55,15 @@ export function computeHailRisk(input) {
const p10Low = prob(lambdaLow, 10); const p10Low = prob(lambdaLow, 10);
const p10High = prob(lambdaHigh, 10); const p10High = prob(lambdaHigh, 10);
// Never present a 100% historical probability — that reads as a certainty/guarantee,
// which this model explicitly is not (spec §6). High-activity areas push p10High to
// ~1.0, so cap the DISPLAYED band just under certainty. Underlying p10* values still
// drive the exposure tier and scoring unchanged.
const MAX_DISPLAY_PROB = 0.95;
const dispLow = Math.min(p10Low, MAX_DISPLAY_PROB);
const dispBase = Math.min(p10Base, MAX_DISPLAY_PROB);
const dispHigh = Math.min(p10High, MAX_DISPLAY_PROB);
// Confidence score (0..1): sample size + window length + geocode precision. // Confidence score (0..1): sample size + window length + geocode precision.
const sampleScore = clamp(count / 8, 0, 1); // ~8+ events → strong const sampleScore = clamp(count / 8, 0, 1); // ~8+ events → strong
const windowScore = clamp(years / 10, 0, 1); // 10y window → full const windowScore = clamp(years / 10, 0, 1); // 10y window → full
@@ -74,7 +83,7 @@ export function computeHailRisk(input) {
annualRate: +lambda.toFixed(3), annualRate: +lambda.toFixed(3),
maxHailInches, maxHailInches,
lastEventDate, lastEventDate,
probability10yr: { low: pct(p10Low), base: pct(p10Base), high: pct(p10High) }, probability10yr: { low: pct(dispLow), base: pct(dispBase), high: pct(dispHigh) },
confidence: confidenceLabel, confidence: confidenceLabel,
confidenceValue, confidenceValue,
exposureTier, exposureTier,
@@ -87,7 +96,7 @@ export function computeHailRisk(input) {
: `This area has averaged about ${lambda.toFixed(1)} observed hail event(s) per year in the available dataset.`, : `This area has averaged about ${lambda.toFixed(1)} observed hail event(s) per year in the available dataset.`,
probability: count === 0 probability: count === 0
? 'There is not enough historical hail activity nearby to estimate a meaningful probability.' ? 'There is not enough historical hail activity nearby to estimate a meaningful probability.'
: `Based on historical reports, the chance of at least one hail event in the next 10 years is estimated at ${pct(p10Low)}${pct(p10High)}%, assuming future patterns resemble the past.`, : `Based on historical reports, the chance of at least one hail event in the next 10 years is estimated at ${pct(dispLow)}${pct(dispHigh)}%, assuming future patterns resemble the past.`,
confidence: `Confidence: ${confidenceLabel}. This is based on county-level and nearby reports within ~${radiusMiles} miles, not a roof-specific inspection.`, confidence: `Confidence: ${confidenceLabel}. This is based on county-level and nearby reports within ~${radiusMiles} miles, not a roof-specific inspection.`,
lastUpdated: `Storm data reflects reports through ${new Date(generatedAt).toLocaleDateString()}.`, lastUpdated: `Storm data reflects reports through ${new Date(generatedAt).toLocaleDateString()}.`,
}, },