estimate wizard display dec for ggod better best packages
This commit is contained in:
@@ -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()}.`,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user