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).
*/
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>
);
};