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).
|
||||
*/
|
||||
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 { score } from '../../engine/scoringEngine';
|
||||
import { computeTenYearValue } from '../../engine/valueModel';
|
||||
import { generateExplanation } from '../../ai/explain';
|
||||
import { formatUSD } from '../../engine/pricing';
|
||||
import GoodBetterBestCards from '../GoodBetterBestCards';
|
||||
import TenYearValueChart from '../TenYearValueChart';
|
||||
|
||||
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 { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
|
||||
|
||||
@@ -69,50 +100,84 @@ const RecommendationStep = () => {
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
<GoodBetterBestCards
|
||||
packages={tenantConfig.packages}
|
||||
pricing={tenantConfig.pricing}
|
||||
selectedType={session.selectedPackage || highlight}
|
||||
selectedType={selectedType}
|
||||
highlightedType={highlight}
|
||||
onSelect={(t) => { patchSession({ selectedPackage: t }); logEvent('package_selected', { package: t, after: 'recommendation' }); }}
|
||||
/>
|
||||
|
||||
{/* AI explanation */}
|
||||
<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">
|
||||
<Sparkles size={16} className="text-amber-500" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">
|
||||
Why we highlighted {PKG_LABEL[highlight]}
|
||||
</span>
|
||||
{explanation && (
|
||||
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 inline-flex items-center gap-1">
|
||||
<ShieldCheck size={11} /> {explanation.aiUsed ? 'AI-assisted' : 'Reviewed'}
|
||||
{/* 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="flex items-center gap-2 mb-2">
|
||||
<Sparkles size={16} className="text-amber-500" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">
|
||||
Why we highlighted {PKG_LABEL[highlight]}
|
||||
</span>
|
||||
{explanation && (
|
||||
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 inline-flex items-center gap-1">
|
||||
<ShieldCheck size={11} /> {explanation.aiUsed ? 'AI-assisted' : 'Reviewed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingAI && !explanation ? (
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400 py-2">
|
||||
<Loader2 size={15} className="animate-spin" /> Preparing your explanation…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-700 dark:text-zinc-200 leading-relaxed">
|
||||
{explanation?.customerExplanation}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{recommendation.reasons.map((r) => (
|
||||
<li key={r} className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||
<Check size={14} className="text-amber-500 mt-0.5 shrink-0" /> {r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingAI && !explanation ? (
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400 py-2">
|
||||
<Loader2 size={15} className="animate-spin" /> Preparing your explanation…
|
||||
) : (
|
||||
<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">
|
||||
{explanation?.customerExplanation}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{recommendation.reasons.map((r) => (
|
||||
<li key={r} className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||
<Check size={14} className="text-amber-500 mt-0.5 shrink-0" /> {r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</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} />
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* compliance record (spec §18 G1).
|
||||
*/
|
||||
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 { useMockStore } from '../../../../data/mockStore';
|
||||
import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport';
|
||||
@@ -33,7 +33,6 @@ const ReportStep = () => {
|
||||
const { addWizardLead } = useMockStore();
|
||||
const model = buildReportModel(session, tenantConfig);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [booked, setBooked] = useState(!!session.report?.appointmentBooked);
|
||||
const firedRef = useRef(false);
|
||||
|
||||
// Fire CRM + nurture handoff once (spec §13 / §17 E2). The ref guard prevents a
|
||||
@@ -99,12 +98,6 @@ const ReportStep = () => {
|
||||
} catch { /* user cancelled */ }
|
||||
};
|
||||
|
||||
const handleBook = () => {
|
||||
setBooked(true);
|
||||
patchSession({ report: { ...session.report, appointmentBooked: true, bookedAt: new Date().toISOString() } });
|
||||
logEvent('appointment_booked', { reportId: model.reportId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Report summary card */}
|
||||
@@ -132,18 +125,6 @@ const ReportStep = () => {
|
||||
<ActionButton icon={Mail} label="Email" onClick={handleEmail} />
|
||||
<ActionButton icon={Share2} label="Share" onClick={handleShare} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,6 +55,15 @@ export function computeHailRisk(input) {
|
||||
const p10Low = prob(lambdaLow, 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.
|
||||
const sampleScore = clamp(count / 8, 0, 1); // ~8+ events → strong
|
||||
const windowScore = clamp(years / 10, 0, 1); // 10y window → full
|
||||
@@ -74,7 +83,7 @@ export function computeHailRisk(input) {
|
||||
annualRate: +lambda.toFixed(3),
|
||||
maxHailInches,
|
||||
lastEventDate,
|
||||
probability10yr: { low: pct(p10Low), base: pct(p10Base), high: pct(p10High) },
|
||||
probability10yr: { low: pct(dispLow), base: pct(dispBase), high: pct(dispHigh) },
|
||||
confidence: confidenceLabel,
|
||||
confidenceValue,
|
||||
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.`,
|
||||
probability: count === 0
|
||||
? '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.`,
|
||||
lastUpdated: `Storm data reflects reports through ${new Date(generatedAt).toLocaleDateString()}.`,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user