diff --git a/src/modules/estimateWizard/components/steps/RecommendationStep.jsx b/src/modules/estimateWizard/components/steps/RecommendationStep.jsx index 0778b93..28f12ef 100644 --- a/src/modules/estimateWizard/components/steps/RecommendationStep.jsx +++ b/src/modules/estimateWizard/components/steps/RecommendationStep.jsx @@ -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 (
{ patchSession({ selectedPackage: t }); logEvent('package_selected', { package: t, after: 'recommendation' }); }} /> - {/* AI explanation */} -
-
- - - Why we highlighted {PKG_LABEL[highlight]} - - {explanation && ( - - {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 ? ( +
+
+ + + Why we highlighted {PKG_LABEL[highlight]} + {explanation && ( + + {explanation.aiUsed ? 'AI-assisted' : 'Reviewed'} + + )} +
+ + {loadingAI && !explanation ? ( +
+ Preparing your explanation… +
+ ) : ( + <> +

+ {explanation?.customerExplanation} +

+
    + {recommendation.reasons.map((r) => ( +
  • + {r} +
  • + ))} +
+ )}
- - {loadingAI && !explanation ? ( -
- Preparing your explanation… + ) : ( +
+
+ + + About the {PKG_LABEL[selectedType]} option + + + {PKG_LABEL[highlight]} recommended +
- ) : ( - <> -

- {explanation?.customerExplanation} -

-
    - {recommendation.reasons.map((r) => ( -
  • - {r} -
  • - ))} -
- - )} -
+ +

+ {selectedDesc.paragraph} +

+
    + {selectedDesc.bullets.map((b) => ( +
  • + {b} +
  • + ))} +
+

+ We still suggest {PKG_LABEL[highlight]} based on your answers — but every option stays available. +

+
+ )} diff --git a/src/modules/estimateWizard/components/steps/ReportStep.jsx b/src/modules/estimateWizard/components/steps/ReportStep.jsx index bcfda68..87abe9d 100644 --- a/src/modules/estimateWizard/components/steps/ReportStep.jsx +++ b/src/modules/estimateWizard/components/steps/ReportStep.jsx @@ -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 (
{/* Report summary card */} @@ -132,18 +125,6 @@ const ReportStep = () => {
- - {/* Next step CTA */} - {booked ? ( -
- -
- Inspection requested — your rep will reach out via {session.inputs.contactMethod}. Thank you! -
-
- ) : ( - - )}
); }; diff --git a/src/modules/estimateWizard/engine/hailProbability.js b/src/modules/estimateWizard/engine/hailProbability.js index 16977ae..67117db 100644 --- a/src/modules/estimateWizard/engine/hailProbability.js +++ b/src/modules/estimateWizard/engine/hailProbability.js @@ -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()}.`, },