From 716d096fc855dfac25321b7b1f29518758d51c0d Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Tue, 9 Jun 2026 14:47:25 +0530 Subject: [PATCH 01/32] estimate module setup and wizard ui --- .gitignore | 2 +- src/App.jsx | 3 + src/components/Layout.jsx | 5 +- .../estimateWizard/CustomerEstimateWizard.jsx | 47 +++++ .../estimateWizard/EstimateWizardContext.jsx | 157 +++++++++++++++ .../estimateWizard/components/ChoiceCards.jsx | 51 +++++ .../components/WizardProgress.jsx | 26 +++ .../estimateWizard/components/WizardShell.jsx | 184 +++++++++++++++++ .../components/steps/AddressStep.jsx | 44 ++++ .../components/steps/PlaceholderStep.jsx | 31 +++ .../components/steps/PremiumStep.jsx | 57 ++++++ .../components/steps/StartStep.jsx | 106 ++++++++++ src/modules/estimateWizard/data/questions.js | 115 +++++++++++ .../estimateWizard/data/tenantConfig.js | 188 ++++++++++++++++++ 14 files changed, 1013 insertions(+), 3 deletions(-) create mode 100644 src/modules/estimateWizard/CustomerEstimateWizard.jsx create mode 100644 src/modules/estimateWizard/EstimateWizardContext.jsx create mode 100644 src/modules/estimateWizard/components/ChoiceCards.jsx create mode 100644 src/modules/estimateWizard/components/WizardProgress.jsx create mode 100644 src/modules/estimateWizard/components/WizardShell.jsx create mode 100644 src/modules/estimateWizard/components/steps/AddressStep.jsx create mode 100644 src/modules/estimateWizard/components/steps/PlaceholderStep.jsx create mode 100644 src/modules/estimateWizard/components/steps/PremiumStep.jsx create mode 100644 src/modules/estimateWizard/components/steps/StartStep.jsx create mode 100644 src/modules/estimateWizard/data/questions.js create mode 100644 src/modules/estimateWizard/data/tenantConfig.js diff --git a/.gitignore b/.gitignore index 375ea5e..a315b80 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,7 @@ dist-ssr # Reference images / design workflows (local only, not for repo) Estimate_Section_Screens_and_Workflows/ - +LynkedUpPro_Customer_Estimate_Presentation_DIY_Wizard_Build_Spec_v1_0.pdf # Claude Code local config (session memory, not for repo) .claude/ .vercel diff --git a/src/App.jsx b/src/App.jsx index 4a48c8b..a9d4661 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -45,6 +45,7 @@ import StormIntelPage from './pages/StormIntelPage'; import FieldStormZonePage from './pages/FieldStormZonePage'; import UserDetailsPage from './pages/UserDetailsPage'; import LeadVerificationPage from './pages/LeadVerification/LeadVerificationPage'; +import CustomerEstimateWizard from './modules/estimateWizard/CustomerEstimateWizard'; // ... (existing imports) const ProtectedRoute = ({ children, allowedRoles }) => { @@ -74,6 +75,8 @@ function App() { {/* Public Routes */} } /> } /> + {/* Public DIY Roofing Estimate Wizard — homeowner-facing, no auth, chrome-less */} + } /> { }, []); // Determine standard layout vs full screen for Public pages - const isPublic = ['/', '/login'].includes(location.pathname); + const isPublic = ['/', '/login', '/estimate'].includes(location.pathname); if (isPublic) { return ( @@ -128,6 +128,7 @@ const Layout = () => { const commonItems = [ { to: "/chat-assistant", icon: MessageSquare, label: "AI Assistant" }, + { to: "/estimate", icon: Sparkles, label: "Estimate Wizard" }, ]; const homeItem = { to: "/", icon: Home, label: "Home" }; diff --git a/src/modules/estimateWizard/CustomerEstimateWizard.jsx b/src/modules/estimateWizard/CustomerEstimateWizard.jsx new file mode 100644 index 0000000..2475729 --- /dev/null +++ b/src/modules/estimateWizard/CustomerEstimateWizard.jsx @@ -0,0 +1,47 @@ +/** + * CustomerEstimateWizard — public entry point for the DIY Roofing Estimate Wizard. + * Rendered chrome-less (no CRM sidebar) at the public /estimate route. The homeowner + * is the primary user (spec §1). Wrapped in its own provider so the module stays + * fully self-contained. + */ +import React from 'react'; +import { CheckCircle2 } from 'lucide-react'; +import { EstimateWizardProvider, useEstimateWizard } from './EstimateWizardContext'; +import WizardShell from './components/WizardShell'; + +const CompletionScreen = () => { + const { inputs, reset } = useEstimateWizard(); + return ( +
+
+ +

+ Thanks{inputs.name ? `, ${inputs.name.split(' ')[0]}` : ''}! +

+

+ Your roof options report is being prepared. The branded report, AI recommendation, and + booking step are delivered in Phase 5. +

+ +
+
+ ); +}; + +const WizardRouter = () => { + const { session } = useEstimateWizard(); + return session.status === 'completed' ? : ; +}; + +const CustomerEstimateWizard = ({ source = 'public' }) => ( + + + +); + +export default CustomerEstimateWizard; diff --git a/src/modules/estimateWizard/EstimateWizardContext.jsx b/src/modules/estimateWizard/EstimateWizardContext.jsx new file mode 100644 index 0000000..2ac6000 --- /dev/null +++ b/src/modules/estimateWizard/EstimateWizardContext.jsx @@ -0,0 +1,157 @@ +/** + * EstimateWizardContext — self-contained session state for the DIY Estimate Wizard. + * + * This is the client-side stand-in for the spec's backend entities (§12): + * estimate_sessions, estimate_inputs, hail_risk_snapshots, recommendations, + * customer_reports, estimate_events. + * For the demo it persists to localStorage so an abandoned session can be resumed + * (spec §13: "auto-save every screen to recover abandoned sessions"). + * + * Kept separate from the global MockStoreProvider on purpose — this is a new, + * isolated module. A Phase 5 adapter can mirror sessions into the CRM mock store. + */ + +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { STEP_FLOW } from './data/questions'; +import { getTenantConfig } from './data/tenantConfig'; + +const STORAGE_KEY = 'lup_estimate_wizard_session_v1'; + +const EstimateWizardContext = createContext(null); + +// A stable-ish id without relying on Date.now/Math.random being available everywhere. +function makeSessionId() { + const t = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14); + const r = Math.floor(performance.now() % 100000).toString().padStart(5, '0'); + return `es_${t}_${r}`; +} + +function freshSession(source = 'public') { + return { + id: makeSessionId(), + tenantId: 'lynkeduppro', + status: 'started', // started → completed | abandoned + source, // website | ad | qr | crm | public + startedAt: new Date().toISOString(), + completedAt: null, + // estimate_inputs (§12) + inputs: { + name: '', address: '', phone: '', email: '', consent: false, contactMethod: 'email', + roofAgeYears: 10, + issueType: null, + ownershipHorizon: null, + premiumAmount: null, premiumUnknown: false, + budgetPreference: null, + hoaConstraints: null, + atticConcern: null, + pestConcern: null, + financingInterest: null, + }, + property: null, // property_profiles (Phase 2) + hailSnapshot: null, // hail_risk_snapshots (Phase 2) + selectedPackage: null, // customer's chosen option (Phase 3) + selectedAddons: [], // add-on ids (Phase 3) + recommendation: null, // recommendations (Phase 4) + report: null, // customer_reports (Phase 5) + events: [], // estimate_events (audit/analytics) + stepIndex: 0, + }; +} + +function loadSession() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + // Basic shape guard so a stale/partial blob can't crash the wizard. + if (parsed && parsed.id && parsed.inputs) return parsed; + } catch { /* ignore corrupt storage */ } + return null; +} + +export function EstimateWizardProvider({ children, source = 'public' }) { + const [tenantConfig] = useState(getTenantConfig); + const [session, setSession] = useState(() => loadSession() || freshSession(source)); + const [resumed] = useState(() => !!loadSession()); + + // Autosave on every change (debounced via microtask coalescing not needed at this scale). + useEffect(() => { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); } catch { /* quota */ } + }, [session]); + + const logEvent = useCallback((eventType, payload = {}) => { + setSession(s => ({ + ...s, + events: [...s.events, { eventType, payload, occurredAt: new Date().toISOString() }], + })); + }, []); + + const setInput = useCallback((key, value) => { + setSession(s => ({ ...s, inputs: { ...s.inputs, [key]: value } })); + }, []); + + const patchSession = useCallback((patch) => { + setSession(s => ({ ...s, ...patch })); + }, []); + + const goToStep = useCallback((index) => { + const clamped = Math.max(0, Math.min(STEP_FLOW.length - 1, index)); + setSession(s => ({ ...s, stepIndex: clamped })); + }, []); + + const next = useCallback(() => { + setSession(s => { + const target = Math.min(STEP_FLOW.length - 1, s.stepIndex + 1); + const stepId = STEP_FLOW[s.stepIndex]?.id; + return { + ...s, + stepIndex: target, + events: [...s.events, { eventType: 'step_complete', payload: { stepId }, occurredAt: new Date().toISOString() }], + }; + }); + }, []); + + const back = useCallback(() => { + setSession(s => ({ ...s, stepIndex: Math.max(0, s.stepIndex - 1) })); + }, []); + + const complete = useCallback(() => { + setSession(s => ({ ...s, status: 'completed', completedAt: new Date().toISOString() })); + }, []); + + const reset = useCallback(() => { + const fresh = freshSession(source); + setSession(fresh); + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)); } catch { /* quota */ } + }, [source]); + + const value = { + session, + inputs: session.inputs, + tenantConfig, + resumed, + currentStep: STEP_FLOW[session.stepIndex], + stepIndex: session.stepIndex, + totalSteps: STEP_FLOW.length, + setInput, + patchSession, + logEvent, + goToStep, + next, + back, + complete, + reset, + }; + + return ( + + {children} + + ); +} + +export function useEstimateWizard() { + const ctx = useContext(EstimateWizardContext); + if (!ctx) throw new Error('useEstimateWizard must be used within EstimateWizardProvider'); + return ctx; +} diff --git a/src/modules/estimateWizard/components/ChoiceCards.jsx b/src/modules/estimateWizard/components/ChoiceCards.jsx new file mode 100644 index 0000000..b6bb6a9 --- /dev/null +++ b/src/modules/estimateWizard/components/ChoiceCards.jsx @@ -0,0 +1,51 @@ +/** + * ChoiceCards — single-select card group (spec §5: cards with 2–3 answer choices). + * Used by the generic 'cards' step type. Manufacturer-neutral, plain language. + */ +import React from 'react'; +import * as Icons from 'lucide-react'; + +const ChoiceCards = ({ choices = [], value, onChange }) => { + return ( +
+ {choices.map((choice) => { + const selected = value === choice.value; + const Icon = choice.icon ? Icons[choice.icon] : null; + return ( + + ); + })} +
+ ); +}; + +export default ChoiceCards; diff --git a/src/modules/estimateWizard/components/WizardProgress.jsx b/src/modules/estimateWizard/components/WizardProgress.jsx new file mode 100644 index 0000000..0ca444b --- /dev/null +++ b/src/modules/estimateWizard/components/WizardProgress.jsx @@ -0,0 +1,26 @@ +/** + * WizardProgress — slim progress bar + step counter (spec §4: "keep the customer moving"). + */ +import React from 'react'; + +const WizardProgress = ({ stepIndex, totalSteps }) => { + const pct = Math.round(((stepIndex + 1) / totalSteps) * 100); + return ( +
+
+ + Step {stepIndex + 1} of {totalSteps} + + {pct}% +
+
+
+
+
+ ); +}; + +export default WizardProgress; diff --git a/src/modules/estimateWizard/components/WizardShell.jsx b/src/modules/estimateWizard/components/WizardShell.jsx new file mode 100644 index 0000000..fe6f3cf --- /dev/null +++ b/src/modules/estimateWizard/components/WizardShell.jsx @@ -0,0 +1,184 @@ +/** + * WizardShell — the one-question-per-screen engine (spec §4, §5, §17 EPIC A2). + * + * Responsibilities: + * - render the current step (custom component | cards | slider) + * - gate Continue on required answers; allow explicit Skip where permitted + * - Back/Continue navigation, completion on the final step + * - mobile-first single-CTA layout (spec §4: "one primary CTA per screen") + */ +import React from 'react'; +import { ArrowLeft, ArrowRight, RotateCcw } from 'lucide-react'; +import { useEstimateWizard } from '../EstimateWizardContext'; +import WizardProgress from './WizardProgress'; +import ChoiceCards from './ChoiceCards'; +import StartStep from './steps/StartStep'; +import AddressStep from './steps/AddressStep'; +import PremiumStep from './steps/PremiumStep'; +import PlaceholderStep from './steps/PlaceholderStep'; + +// Step registry — later phases swap PlaceholderStep entries for real components. +const STEP_COMPONENTS = { + start: StartStep, + address: AddressStep, + premium: PremiumStep, + hailRisk: (props) => , + options: (props) => , + addons: (props) => , + recommendation: (props) => , + report: (props) => , +}; + +// Validation per step: returns true when the user may advance. +function canAdvance(step, inputs) { + if (step.id === 'start') return inputs.consent && inputs.name?.trim() && inputs.address?.trim(); + if (step.id === 'address') return !!inputs.address?.trim(); + if (step.type === 'cards' && step.required) return inputs[step.inputKey] != null; + if (step.type === 'slider' && step.required) return inputs[step.inputKey] != null; + return true; +} + +const SliderStep = ({ step }) => { + const { inputs, setInput } = useEstimateWizard(); + const val = inputs[step.inputKey] ?? step.min; + return ( +
+
+ {val} + + {val >= step.max ? `+ ${step.unit}` : step.unit} + +
+ setInput(step.inputKey, Number(e.target.value))} + className="w-full accent-amber-500" + /> +
+ {step.min} {step.unit} + {Math.round((step.min + step.max) / 2)} {step.unit} + {step.maxLabel} +
+
+ ); +}; + +const WizardShell = () => { + const { + currentStep: step, stepIndex, totalSteps, inputs, + setInput, next, back, complete, reset, resumed, session, + } = useEstimateWizard(); + + const isLast = stepIndex === totalSteps - 1; + const advance = canAdvance(step, inputs); + + const renderBody = () => { + if (step.type === 'cards') { + return ( + setInput(step.inputKey, v)} + /> + ); + } + if (step.type === 'slider') return ; + const Comp = STEP_COMPONENTS[step.id]; + return Comp ? : null; + }; + + const handleContinue = () => { + if (isLast) { complete(); return; } + next(); + }; + + return ( +
+ {/* Top bar */} +
+
+
+ + LynkedUpPro + Roof Options + + +
+ +
+
+ + {/* Resume hint */} + {resumed && session.status !== 'completed' && stepIndex === 0 && ( +
+
+ Welcome back — we saved your progress. You can continue or start over. +
+
+ )} + + {/* Body */} +
+
+ {/* key forces remount per step → re-triggers the enter animation */} +
+

+ {step.title} +

+ {step.subtitle && ( +

{step.subtitle}

+ )} +
{renderBody()}
+
+
+
+ + {/* Footer nav — one primary CTA */} +
+
+ {stepIndex > 0 && ( + + )} + + {step.skippable && !isLast && ( + + )} + + +
+
+
+ ); +}; + +export default WizardShell; diff --git a/src/modules/estimateWizard/components/steps/AddressStep.jsx b/src/modules/estimateWizard/components/steps/AddressStep.jsx new file mode 100644 index 0000000..8ae650f --- /dev/null +++ b/src/modules/estimateWizard/components/steps/AddressStep.jsx @@ -0,0 +1,44 @@ +/** + * AddressStep — Screen 2 "Property validation" (spec §4). + * Phase 1: confirm/edit the address captured at Start. + * Phase 2 will add geocoding (lat/lon, county, timezone) + a Leaflet map preview + * and populate property_profiles via /api/properties/validate-address. + */ +import React from 'react'; +import { MapPin, Map as MapIcon } from 'lucide-react'; +import { useEstimateWizard } from '../../EstimateWizardContext'; + +const AddressStep = () => { + const { inputs, setInput } = useEstimateWizard(); + + return ( +
+ + + {/* Map preview placeholder — Leaflet + geocode wired in Phase 2 */} +
+ +

+ We’ll confirm the location on a map and pull your area’s historical storm data in the next step. +

+
+ +

+ Final roof measurements are validated during your inspection — nothing here is binding. +

+
+ ); +}; + +export default AddressStep; diff --git a/src/modules/estimateWizard/components/steps/PlaceholderStep.jsx b/src/modules/estimateWizard/components/steps/PlaceholderStep.jsx new file mode 100644 index 0000000..67c926f --- /dev/null +++ b/src/modules/estimateWizard/components/steps/PlaceholderStep.jsx @@ -0,0 +1,31 @@ +/** + * PlaceholderStep — temporary stand-in for steps delivered in later phases. + * Keeps the full §4 journey navigable end-to-end during Phase 1 so the flow, + * autosave, and progress can be demoed. Each phase replaces its entry in the + * WizardShell step registry with the real component. + */ +import React from 'react'; +import { Hammer } from 'lucide-react'; + +const PHASE_BY_STEP = { + hailRisk: 'Phase 2 — Storm/Hail Risk Intelligence (§6)', + options: 'Phase 3 — Good / Better / Best options (§7)', + addons: 'Phase 3 — Add-ons & upgrades (§8)', + recommendation: 'Phase 4 — AI recommendation + 10-year value (§9, §10)', + report: 'Phase 5 — Customer report, CRM handoff (§11)', +}; + +const PlaceholderStep = ({ stepId }) => ( +
+ +

Coming next

+

+ {PHASE_BY_STEP[stepId] || 'This screen is delivered in a later phase.'} +

+

+ The journey, autosave, and progress are fully wired — Continue to walk the full flow. +

+
+); + +export default PlaceholderStep; diff --git a/src/modules/estimateWizard/components/steps/PremiumStep.jsx b/src/modules/estimateWizard/components/steps/PremiumStep.jsx new file mode 100644 index 0000000..23e293e --- /dev/null +++ b/src/modules/estimateWizard/components/steps/PremiumStep.jsx @@ -0,0 +1,57 @@ +/** + * PremiumStep — annual insurance premium (spec §5). + * Optional: amount / "I don't know" / skip. Enables the 10-year savings scenario + * (Phase 4). Never required — skipping must not block the wizard (spec §18). + */ +import React from 'react'; +import { DollarSign } from 'lucide-react'; +import { useEstimateWizard } from '../../EstimateWizardContext'; + +const PremiumStep = () => { + const { inputs, setInput } = useEstimateWizard(); + const unknown = inputs.premiumUnknown; + + return ( +
+
+ + Approximate annual premium + +
+ $ + setInput('premiumAmount', e.target.value === '' ? null : Number(e.target.value))} + /> +
+
+ + + +

+ This is only used to illustrate a potential savings scenario. Any savings must be confirmed with your + insurance carrier — we never guarantee a discount. +

+
+ ); +}; + +export default PremiumStep; diff --git a/src/modules/estimateWizard/components/steps/StartStep.jsx b/src/modules/estimateWizard/components/steps/StartStep.jsx new file mode 100644 index 0000000..8c814f8 --- /dev/null +++ b/src/modules/estimateWizard/components/steps/StartStep.jsx @@ -0,0 +1,106 @@ +/** + * StartStep — Screen 1 "Simple Start" (spec §4 / §5). + * Captures name, property address, mobile, email, contact method + consent. + * Consent is required before proceeding (spec §15 Privacy / §3 compliance). + */ +import React from 'react'; +import { User, MapPin, Phone, Mail } from 'lucide-react'; +import { useEstimateWizard } from '../../EstimateWizardContext'; + +const Field = ({ icon: Icon, label, children }) => ( + +); + +const inputCls = + 'w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 ' + + 'text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500'; + +const StartStep = () => { + const { inputs, setInput } = useEstimateWizard(); + + return ( +
+ + setInput('name', e.target.value)} + autoComplete="name" + /> + + + + setInput('address', e.target.value)} + autoComplete="street-address" + /> + + +
+ + setInput('phone', e.target.value)} + autoComplete="tel" + inputMode="tel" + /> + + + setInput('email', e.target.value)} + autoComplete="email" + inputMode="email" + /> + +
+ +
+ Best contact method? +
+ {['text', 'email', 'phone'].map((m) => ( + + ))} +
+
+ + +
+ ); +}; + +export default StartStep; diff --git a/src/modules/estimateWizard/data/questions.js b/src/modules/estimateWizard/data/questions.js new file mode 100644 index 0000000..4788cd2 --- /dev/null +++ b/src/modules/estimateWizard/data/questions.js @@ -0,0 +1,115 @@ +/** + * questions.js — Wizard question architecture (spec §5). + * + * Every question must improve pricing accuracy, recommendation quality, or sales + * follow-up (spec §5: "Do not ask curiosity questions"). Each entry maps to a key + * stored on estimateInputs and is consumed by the scoring engine (Phase 4). + * + * The ordered STEP + * _FLOW (below) defines the one-question-per-screen journey + * (spec §4). Some "screens" are composite (Start collects contact fields) — these + * use a dedicated component instead of the generic question renderer. + */ + +// Reusable answer choice sets +export const ISSUE_CHOICES = [ + { value: 'aging', label: 'Aging roof', desc: 'General wear, nearing end of life', icon: 'Clock', triggers: ['old_roof'] }, + { value: 'leak', label: 'Leak or active issue', desc: 'Water intrusion or visible damage', icon: 'Droplets', triggers: ['leak'] }, + { value: 'storm', label: 'Storm concern', desc: 'Possible hail or wind damage', icon: 'CloudLightning', triggers: [] }, +]; + +export const OWNERSHIP_CHOICES = [ + { value: '1-5', label: '1–5 years', desc: 'May sell soon', score: 1 }, + { value: '6-15', label: '6–15 years', desc: 'Medium-term', score: 2 }, + { value: '16+', label: '16+ years / forever', desc: 'Long-term home', score: 3 }, +]; + +export const BUDGET_CHOICES = [ + { value: 'lowest', label: 'Lowest upfront', desc: 'Minimize initial cost' }, + { value: 'balanced', label: 'Balanced', desc: 'Value for the money' }, + { value: 'longterm', label: 'Long-term protection', desc: 'Best lifecycle value' }, +]; + +export const HOA_CHOICES = [ + { value: 'no', label: 'No' }, + { value: 'yes', label: 'Yes' }, + { value: 'unsure', label: 'Not sure' }, +]; + +export const ATTIC_CHOICES = [ + { value: 'attic_hot', label: 'Hot attic', triggers: ['attic_hot'] }, + { value: 'energy_bills', label: 'High energy bills', triggers: ['energy_bills'] }, + { value: 'none', label: 'Not sure', triggers: [] }, +]; + +export const PESTS_CHOICES = [ + { value: 'yes', label: 'Yes', triggers: ['pests'] }, + { value: 'no', label: 'No', triggers: [] }, + { value: 'unsure', label: 'Not sure', triggers: [] }, +]; + +export const FINANCING_CHOICES = [ + { value: 'yes', label: 'Yes' }, + { value: 'maybe', label: 'Maybe' }, + { value: 'no', label: 'No' }, +]; + +/** + * STEP_FLOW — ordered screens. `type` selects the renderer in WizardShell. + * - 'component' → custom step component (key matches steps/ registry) + * - 'cards' → single-select card group bound to `inputKey` + * - 'slider' → numeric slider bound to `inputKey` + * `required` gates the Continue button; `skippable` allows an explicit Skip. + */ +export const STEP_FLOW = [ + { id: 'start', type: 'component', title: 'Build your roofing estimate in 3 minutes', + subtitle: 'No pressure. Your report will show Good, Better, and Best options based on your home and goals.' }, + + { id: 'address', type: 'component', title: 'What property is this for?', + subtitle: "We'll use this to pull local storm history. We validate measurements later during inspection." }, + + { id: 'roofAge', type: 'slider', inputKey: 'roofAgeYears', title: 'How old is your roof?', + subtitle: 'Slide to estimate roof age. We will validate later during inspection.', + min: 1, max: 20, step: 1, unit: 'yr', maxLabel: '20+ yrs', required: true }, + + { id: 'issue', type: 'cards', inputKey: 'issueType', title: 'What is happening right now?', + subtitle: 'This helps us tailor your options.', choices: ISSUE_CHOICES, required: true }, + + { id: 'ownership', type: 'cards', inputKey: 'ownershipHorizon', title: 'How long do you plan to live here?', + subtitle: 'This decides whether lowest upfront or long-term value matters more.', choices: OWNERSHIP_CHOICES, required: true }, + + { id: 'premium', type: 'component', title: 'Annual insurance premium?', + subtitle: 'Optional — it lets us estimate potential 10-year savings scenarios.' }, + + { id: 'hailRisk', type: 'component', title: 'Your area’s historical hail exposure', + subtitle: 'A historical risk estimate for your area — not a forecast.' }, + + { id: 'budget', type: 'cards', inputKey: 'budgetPreference', title: 'What matters most for budget?', + subtitle: 'So we recommend the right option for you — not the most expensive one.', choices: BUDGET_CHOICES, required: true }, + + { id: 'hoa', type: 'cards', inputKey: 'hoaConstraints', title: 'HOA or design constraints?', + subtitle: 'Some neighborhoods require approval for certain materials.', choices: HOA_CHOICES, skippable: true }, + + { id: 'attic', type: 'cards', inputKey: 'atticConcern', title: 'Attic comfort or ventilation issues?', + subtitle: 'Helps us suggest ventilation upgrades if useful.', choices: ATTIC_CHOICES, skippable: true }, + + { id: 'pests', type: 'cards', inputKey: 'pestConcern', title: 'Concerned about rodents or pests?', + subtitle: 'Helps us suggest critter-guard protection if useful.', choices: PESTS_CHOICES, skippable: true }, + + { id: 'financing', type: 'cards', inputKey: 'financingInterest', title: 'Interested in financing?', + subtitle: 'We can prepare financing options for your appointment.', choices: FINANCING_CHOICES, skippable: true }, + + { id: 'options', type: 'component', title: 'Your Good / Better / Best options', + subtitle: 'All three options, side by side. We highlight a best fit — you stay in control.' }, + + { id: 'addons', type: 'component', title: 'Optional upgrades', + subtitle: 'Add or remove anything. Your rep can revise after inspection.' }, + + { id: 'recommendation', type: 'component', title: 'Our recommendation', + subtitle: 'Here’s the best fit for your home and goals, and exactly why.' }, + + { id: 'report', type: 'component', title: 'Your roof options report', + subtitle: 'Email, print, or download — then book your free inspection.' }, +]; + +export const TOTAL_STEPS = STEP_FLOW.length; diff --git a/src/modules/estimateWizard/data/tenantConfig.js b/src/modules/estimateWizard/data/tenantConfig.js new file mode 100644 index 0000000..4ef34ab --- /dev/null +++ b/src/modules/estimateWizard/data/tenantConfig.js @@ -0,0 +1,188 @@ +/** + * tenantConfig.js — Estimate Wizard tenant-configurable defaults. + * + * Spec §14 (Admin Controls) requires that pricing, discounts, warranty text, + * package scope, service-life, and scoring weights are NOT hard-coded — they are + * tenant-controlled. For the demo these defaults live here and can be overridden + * by the admin page (Phase 5). Nothing customer-facing should bypass this object. + * + * Manufacturer-neutral by design (spec §3, §15): packages are generic categories, + * internal SKU/vendor names are intentionally absent from customer-facing fields. + */ + +// ── Good / Better / Best packages (spec §7) ────────────────────────────────── +export const DEFAULT_PACKAGES = [ + { + type: 'GOOD', + name: 'Standard Architectural', + category: 'Architectural / laminate shingle system', + materialClass: 'standard', + warrantyLabel: '30-year material warranty category', + position: 'Lowest upfront investment', + fit: 'Shorter ownership horizon, limited budget, lower hail exposure, or preparing to sell soon.', + // price rule: per-square installed price (demo). Real pricing engine lives in admin. + pricePerSquare: 420, + serviceLifeYears: 28, // tenant-configurable (e.g. 25–30) + insuranceDiscountPct: 0, // Good is not impact-rated → no assumed discount + hailResistance: 'Standard', + scope: ['Architectural laminate shingles', 'Code-required underlayment', 'Standard hip/ridge', 'Standard accessories'], + accent: 'zinc', + }, + { + type: 'BETTER', + name: 'Class 4 Impact-Rated', + category: 'Class 4 impact-rated architectural shingle system', + materialClass: 'impact-rated', + warrantyLabel: 'Enhanced manufacturer warranty category', + position: 'Balanced protection and budget', + fit: 'Medium-to-long ownership, meaningful hail exposure, wants stronger protection without premium-system cost.', + pricePerSquare: 565, + serviceLifeYears: 35, // tenant-configurable (e.g. 30–40) + insuranceDiscountPct: 15, // illustrative; "ask your carrier to confirm" + hailResistance: 'Higher', + scope: ['Class 4 impact-rated shingles', 'Upgraded underlayment', 'Standard hip/ridge', 'Improved hail resistance'], + accent: 'blue', + }, + { + type: 'BEST', + name: 'Premium Long-Life System', + category: 'Composite / synthetic or stone-coated steel category', + materialClass: 'premium', + warrantyLabel: 'Premium long-life warranty category', + position: 'Highest long-term protection', + fit: 'Long ownership, high hail exposure, high premium, wants fewer future roof cycles and strongest curb appeal.', + pricePerSquare: 950, + serviceLifeYears: 50, // tenant-configurable (e.g. 50+) + insuranceDiscountPct: 20, // illustrative; "ask your carrier to confirm" + hailResistance: 'Highest', + scope: ['Composite/synthetic or stone-coated steel', 'Premium underlayment', 'Upgraded accessories', 'Highest durability positioning'], + accent: 'amber', + }, +]; + +// ── Add-ons (spec §8) — plain-language, never fear-based ────────────────────── +export const DEFAULT_ADDONS = [ + { + id: 'hip-ridge', + name: 'High-profile hip & ridge', + value: 'Improves the finished look and strengthens the ridge-line appearance.', + price: 480, + compatibility: ['GOOD', 'BETTER', 'BEST'], + recommendedOn: ['BEST'], + triggerKeys: ['aesthetics'], + }, + { + id: 'solar-vents', + name: 'Solar attic vents', + value: 'Helps move hot attic air and may improve comfort and roof-system health.', + price: 650, + compatibility: ['GOOD', 'BETTER', 'BEST'], + recommendedOn: [], + triggerKeys: ['attic_hot', 'energy_bills'], + }, + { + id: 'gutter-guards', + name: 'Gutter guards', + value: 'Reduces gutter debris buildup and maintenance frequency.', + price: 900, + compatibility: ['GOOD', 'BETTER', 'BEST'], + recommendedOn: [], + triggerKeys: ['canopy'], + }, + { + id: 'critter-guards', + name: 'Critter guards', + value: 'Helps block common rodent and small-animal roofline entry points.', + price: 350, + compatibility: ['GOOD', 'BETTER', 'BEST'], + recommendedOn: [], + triggerKeys: ['pests'], + }, + { + id: 'upgraded-flashing', + name: 'Upgraded flashing / penetrations', + value: 'Improves protection around common leak points.', + price: 520, + compatibility: ['GOOD', 'BETTER', 'BEST'], + recommendedOn: ['BEST'], + triggerKeys: ['leak', 'old_roof', 'complex'], + }, + { + id: 'enhanced-underlayment', + name: 'Enhanced underlayment / ice-water zones', + value: 'Adds protection in vulnerable valleys, eaves, and penetrations.', + price: 700, + compatibility: ['GOOD', 'BETTER', 'BEST'], + recommendedOn: [], + triggerKeys: ['complex', 'leak'], + }, +]; + +// ── Scoring weights (spec §9) — deterministic, tenant-adjustable ────────────── +export const DEFAULT_SCORING_WEIGHTS = { + ownershipHorizon: 0.25, + hailRisk: 0.25, + insuranceSavings: 0.20, + budgetPreference: 0.15, + roofAge: 0.10, + aesthetics: 0.05, +}; + +// ── Storm-risk settings (spec §6, §14) ──────────────────────────────────────── +export const DEFAULT_STORM_SETTINGS = { + windowYears: 20, // 10 fallback / 30 for smoother trends + radiusMiles: 10, + minHailSizeInches: 0.75, + dedupeWindowHours: 6, +}; + +// ── Pricing engine knobs (spec §14) ─────────────────────────────────────────── +export const DEFAULT_PRICING = { + wasteFactor: 1.05, + minJobPrice: 6500, + pitchFactor: 1.0, // multiplied per pitch/stories/complexity in admin + taxPlaceholderPct: 0, + permitPlaceholder: 0, +}; + +// ── Insurance discount scenarios (spec §10/§14) — low/base/high ─────────────── +export const DEFAULT_DISCOUNT_SCENARIOS = { + GOOD: { low: 0, base: 0, high: 0 }, + BETTER: { low: 10, base: 15, high: 18 }, + BEST: { low: 15, base: 18, high: 22 }, +}; + +// ── Report branding (spec §14) ──────────────────────────────────────────────── +export const DEFAULT_BRANDING = { + company: 'LynkedUp Pro', + phone: '866-259-6533', + email: 'estimates@lynkeduppro.com', + serviceArea: 'Texas & surrounding regions', + primaryColor: '#fda913', + legalFooter: 'Preliminary estimate. Not a binding quote.', +}; + +// ── Legal / disclaimer (spec §15) — verbatim required template ──────────────── +export const DISCLAIMER_TEMPLATE = + 'This report is an educational, preliminary estimate based on the information provided and available ' + + 'property/storm data. Final pricing requires property measurement and contractor inspection. Insurance ' + + 'premium savings are illustrative only and must be confirmed with your insurance carrier or licensed ' + + 'insurance professional. Historical storm information is not a forecast or guarantee.'; + +export const TENANT_CONFIG_VERSION = 'tenant-config-v1'; + +export function getTenantConfig() { + // Phase 5 admin page will override these from localStorage / mock store. + return { + version: TENANT_CONFIG_VERSION, + packages: DEFAULT_PACKAGES, + addons: DEFAULT_ADDONS, + scoringWeights: DEFAULT_SCORING_WEIGHTS, + stormSettings: DEFAULT_STORM_SETTINGS, + pricing: DEFAULT_PRICING, + discountScenarios: DEFAULT_DISCOUNT_SCENARIOS, + branding: DEFAULT_BRANDING, + disclaimer: DISCLAIMER_TEMPLATE, + conservativeMode: false, // §18: hides customer-facing savings claims when true + }; +} From 110790d5aead3e45cb02c0ee2d371ba4d1fea0cb Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Tue, 9 Jun 2026 16:15:21 +0530 Subject: [PATCH 02/32] locate adress on map and find hail risk --- src/modules/estimateWizard/api/geocode.js | 65 +++++++++++++ .../components/HailRiskSnapshot.jsx | 83 ++++++++++++++++ .../estimateWizard/components/PropertyMap.jsx | 48 ++++++++++ .../estimateWizard/components/WizardShell.jsx | 3 +- .../components/steps/AddressStep.jsx | 79 +++++++++++---- .../components/steps/HailRiskStep.jsx | 83 ++++++++++++++++ .../estimateWizard/engine/hailProbability.js | 95 +++++++++++++++++++ .../estimateWizard/hooks/useWizardHailRisk.js | 87 +++++++++++++++++ 8 files changed, 522 insertions(+), 21 deletions(-) create mode 100644 src/modules/estimateWizard/api/geocode.js create mode 100644 src/modules/estimateWizard/components/HailRiskSnapshot.jsx create mode 100644 src/modules/estimateWizard/components/PropertyMap.jsx create mode 100644 src/modules/estimateWizard/components/steps/HailRiskStep.jsx create mode 100644 src/modules/estimateWizard/engine/hailProbability.js create mode 100644 src/modules/estimateWizard/hooks/useWizardHailRisk.js diff --git a/src/modules/estimateWizard/api/geocode.js b/src/modules/estimateWizard/api/geocode.js new file mode 100644 index 0000000..62d5bf5 --- /dev/null +++ b/src/modules/estimateWizard/api/geocode.js @@ -0,0 +1,65 @@ +/** + * geocode.js — module-local address geocoder for the Estimate Wizard. + * + * Uses OpenStreetMap Nominatim (keyless, CORS-open public service), consistent + * with the repo's existing keyless-public-API approach (IEM / NOAA in the storm + * module). This is NEW module code — it does not modify any existing hail/storm + * module; it only produces the lat/lon those modules' endpoints consume. + * + * Always resolves: on empty input, network failure, or no match it falls back to + * the demo service-area center (Plano, TX) so the wizard never dead-ends. The + * caller is told via `confidence` / `fallback` so wording stays honest (spec §6). + */ + +// Demo service-area center — matches the storm module's WFO=FWD coverage. +const FALLBACK = { + lat: 33.0198, + lon: -96.6989, + displayName: 'Plano, TX (approximate area)', + county: 'Collin', + state: 'TX', + confidence: 'low', + fallback: true, +}; + +const NOMINATIM = 'https://nominatim.openstreetmap.org/search'; + +export async function geocodeAddress(address) { + const q = (address || '').trim(); + if (q.length < 4) return { ...FALLBACK }; + + try { + const params = new URLSearchParams({ + q, + format: 'json', + addressdetails: '1', + limit: '1', + countrycodes: 'us', + }); + const res = await fetch(`${NOMINATIM}?${params.toString()}`, { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) throw new Error(`geocode ${res.status}`); + const data = await res.json(); + const hit = Array.isArray(data) ? data[0] : null; + if (!hit) return { ...FALLBACK }; + + const a = hit.address || {}; + // Nominatim "importance" (0..1) is a rough match-quality signal. + const importance = typeof hit.importance === 'number' ? hit.importance : 0.4; + const confidence = importance >= 0.5 ? 'high' : importance >= 0.3 ? 'moderate' : 'low'; + + return { + lat: parseFloat(hit.lat), + lon: parseFloat(hit.lon), + displayName: hit.display_name || q, + county: (a.county || '').replace(/ County$/i, ''), + state: a.state || a['ISO3166-2-lvl4'] || '', + confidence, + fallback: false, + }; + } catch { + // Network/CORS/parse failure → safe fallback, flagged as such. + return { ...FALLBACK }; + } +} diff --git a/src/modules/estimateWizard/components/HailRiskSnapshot.jsx b/src/modules/estimateWizard/components/HailRiskSnapshot.jsx new file mode 100644 index 0000000..c429054 --- /dev/null +++ b/src/modules/estimateWizard/components/HailRiskSnapshot.jsx @@ -0,0 +1,83 @@ +/** + * HailRiskSnapshot — presentational hail-risk card (spec §6). + * Strictly historical framing + mandatory confidence/source/disclaimer. + * Never says "your home will get hit" — only area-level historical exposure. + */ +import React from 'react'; +import { CloudHail, TrendingUp, ShieldQuestion, Clock, Info } from 'lucide-react'; + +const tierStyle = { + high: { text: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20', label: 'Higher historical exposure' }, + moderate: { text: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20', label: 'Moderate historical exposure' }, + low: { text: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20', label: 'Lower historical exposure' }, +}; + +const Metric = ({ icon: Icon, label, value, sub }) => ( +
+
+ {Icon && } {label} +
+
{value}
+ {sub &&
{sub}
} +
+); + +const HailRiskSnapshot = ({ snapshot }) => { + if (!snapshot) return null; + const tier = tierStyle[snapshot.exposureTier] || tierStyle.low; + const p = snapshot.probability10yr; + + return ( +
+ {/* Exposure banner */} +
+ +
+
{tier.label}
+

{snapshot.wording.probability}

+
+
+ + {/* Metrics */} +
+ + + +
+ + {/* Source + freshness */} +
+
+ Source: {snapshot.source} +
+
+ {snapshot.wording.lastUpdated} +
+
{snapshot.wording.confidence}
+
+ + {/* Trust rule (spec §6): historical, not a forecast */} +

+ This is a historical hazard estimate for your area — not a forecast or + guarantee for your specific home. Final assessment requires an on-site inspection. +

+
+ ); +}; + +export default HailRiskSnapshot; diff --git a/src/modules/estimateWizard/components/PropertyMap.jsx b/src/modules/estimateWizard/components/PropertyMap.jsx new file mode 100644 index 0000000..f5549cf --- /dev/null +++ b/src/modules/estimateWizard/components/PropertyMap.jsx @@ -0,0 +1,48 @@ +/** + * PropertyMap — small Leaflet preview of the geocoded property + risk radius. + * Uses the same default-marker-icon fix the rest of the app uses (Maps.jsx). + */ +import React from 'react'; +import { MapContainer, TileLayer, Marker, Circle } from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; +import L from 'leaflet'; +import icon from 'leaflet/dist/images/marker-icon.png'; +import iconShadow from 'leaflet/dist/images/marker-shadow.png'; + +const DefaultIcon = L.icon({ + iconUrl: icon, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], +}); +L.Marker.prototype.options.icon = DefaultIcon; + +const PropertyMap = ({ lat, lon, radiusMiles = 20, height = 220 }) => { + if (lat == null || lon == null) return null; + const center = [lat, lon]; + return ( +
+ + + + + +
+ ); +}; + +export default PropertyMap; diff --git a/src/modules/estimateWizard/components/WizardShell.jsx b/src/modules/estimateWizard/components/WizardShell.jsx index fe6f3cf..f3789a3 100644 --- a/src/modules/estimateWizard/components/WizardShell.jsx +++ b/src/modules/estimateWizard/components/WizardShell.jsx @@ -15,6 +15,7 @@ import ChoiceCards from './ChoiceCards'; import StartStep from './steps/StartStep'; import AddressStep from './steps/AddressStep'; import PremiumStep from './steps/PremiumStep'; +import HailRiskStep from './steps/HailRiskStep'; import PlaceholderStep from './steps/PlaceholderStep'; // Step registry — later phases swap PlaceholderStep entries for real components. @@ -22,7 +23,7 @@ const STEP_COMPONENTS = { start: StartStep, address: AddressStep, premium: PremiumStep, - hailRisk: (props) => , + hailRisk: HailRiskStep, options: (props) => , addons: (props) => , recommendation: (props) => , diff --git a/src/modules/estimateWizard/components/steps/AddressStep.jsx b/src/modules/estimateWizard/components/steps/AddressStep.jsx index 8ae650f..991968c 100644 --- a/src/modules/estimateWizard/components/steps/AddressStep.jsx +++ b/src/modules/estimateWizard/components/steps/AddressStep.jsx @@ -1,15 +1,30 @@ /** * AddressStep — Screen 2 "Property validation" (spec §4). - * Phase 1: confirm/edit the address captured at Start. - * Phase 2 will add geocoding (lat/lon, county, timezone) + a Leaflet map preview - * and populate property_profiles via /api/properties/validate-address. + * Geocodes the address (module-local Nominatim geocoder) and shows a Leaflet map + * preview, populating property_profiles (spec §12). Hail history is pulled in the + * next step from the existing /api/storm-history endpoint. */ -import React from 'react'; -import { MapPin, Map as MapIcon } from 'lucide-react'; +import React, { useState } from 'react'; +import { MapPin, Loader2, CheckCircle2, Search } from 'lucide-react'; import { useEstimateWizard } from '../../EstimateWizardContext'; +import { geocodeAddress } from '../../api/geocode'; +import PropertyMap from '../PropertyMap'; const AddressStep = () => { - const { inputs, setInput } = useEstimateWizard(); + const { inputs, setInput, session, patchSession, logEvent } = useEstimateWizard(); + const [locating, setLocating] = useState(false); + const property = session.property; + + const handleLocate = async () => { + setLocating(true); + try { + const prop = await geocodeAddress(inputs.address); + patchSession({ property: prop, hailSnapshot: null }); // re-locating invalidates a stale snapshot + logEvent('address_geocoded', { confidence: prop.confidence, fallback: prop.fallback }); + } finally { + setLocating(false); + } + }; return (
@@ -17,22 +32,46 @@ const AddressStep = () => { Confirm your property address - setInput('address', e.target.value)} - autoComplete="street-address" - /> +
+ { setInput('address', e.target.value); if (property) patchSession({ property: null, hailSnapshot: null }); }} + autoComplete="street-address" + /> + +
- {/* Map preview placeholder — Leaflet + geocode wired in Phase 2 */} -
- -

- We’ll confirm the location on a map and pull your area’s historical storm data in the next step. -

-
+ {property ? ( + <> + +
+ + + {property.fallback + ? 'We placed an approximate pin for your area. Your rep will confirm the exact location.' + : <>Located: {property.displayName}} + +
+ + ) : ( +
+ +

+ Tap Locate to confirm your home on the map and pull local storm history. +

+
+ )}

Final roof measurements are validated during your inspection — nothing here is binding. diff --git a/src/modules/estimateWizard/components/steps/HailRiskStep.jsx b/src/modules/estimateWizard/components/steps/HailRiskStep.jsx new file mode 100644 index 0000000..a09143a --- /dev/null +++ b/src/modules/estimateWizard/components/steps/HailRiskStep.jsx @@ -0,0 +1,83 @@ +/** + * HailRiskStep — Screen 6 "Hail risk" (spec §6). + * Runs geocode → /api/storm-history → Poisson model, freezes the snapshot into + * the session (spec §12), and renders the historical-only risk card + map. + */ +import React, { useEffect } from 'react'; +import { Loader2, AlertCircle } from 'lucide-react'; +import { useEstimateWizard } from '../../EstimateWizardContext'; +import { useWizardHailRisk } from '../../hooks/useWizardHailRisk'; +import PropertyMap from '../PropertyMap'; +import HailRiskSnapshot from '../HailRiskSnapshot'; + +const HailRiskStep = () => { + const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard(); + + // Reuse a previously frozen snapshot if the user navigates back/forward. + const alreadyHave = !!session.hailSnapshot; + + const { loading, error, property, snapshot } = useWizardHailRisk({ + address: inputs.address, + property: session.property, + enabled: !alreadyHave, + stormSettings: tenantConfig.stormSettings, + }); + + // Freeze property + snapshot into the session once resolved. + useEffect(() => { + if (alreadyHave) return; + if (snapshot && property) { + patchSession({ property, hailSnapshot: snapshot }); + logEvent('hail_snapshot_generated', { + exposureTier: snapshot.exposureTier, + p10Base: snapshot.probability10yr.base, + confidence: snapshot.confidence, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [snapshot, property, alreadyHave]); + + const shownProperty = session.property || property; + const shownSnapshot = session.hailSnapshot || snapshot; + + if (loading && !shownSnapshot) { + return ( +

+ +

+ Checking historical storm reports for your area… +

+
+ ); + } + + if (error && !shownSnapshot) { + return ( +
+ +
+

Storm history unavailable

+

+ We couldn’t load storm data right now — your rep will review local hail history during your visit. + You can still continue. +

+
+
+ ); + } + + return ( +
+ {shownProperty && ( + + )} + +
+ ); +}; + +export default HailRiskStep; diff --git a/src/modules/estimateWizard/engine/hailProbability.js b/src/modules/estimateWizard/engine/hailProbability.js new file mode 100644 index 0000000..16977ae --- /dev/null +++ b/src/modules/estimateWizard/engine/hailProbability.js @@ -0,0 +1,95 @@ +/** + * hailProbability.js — conservative historical hail-risk model (spec §6). + * + * IMPORTANT framing (spec §6): the output is a HISTORICAL hazard estimate for the + * surrounding area, NOT a property-level forecast or guarantee. The model computes + * a RANGE (confidence band), not one overconfident number, and stores every + * assumption so the report can be defended. + * + * lambda (λ) = deduped hail events / years + * P(at least one hail event over N years) = 1 - e^(-λN) + * + * Pure function — no I/O. Fed by /api/storm-history (existing storm module, used + * read-only). Returns the hail_risk_snapshot shape (spec §12). + */ + +function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); } +function pct(n) { return Math.round(n * 100); } + +/** + * @param {object} input + * eventCount {number} deduped hail events observed in the window + * windowYears {number} historical window analyzed + * radiusMiles {number} search radius around the property + * geocodeConfidence {'high'|'moderate'|'low'} + * maxHailInches {number|null} + * lastEventDate {string|null} ISO date of most recent hail event + * source {string} human-readable data source label + * generatedAt {string} ISO timestamp the snapshot was frozen + */ +export function computeHailRisk(input) { + const { + eventCount = 0, + windowYears = 5, + radiusMiles = 20, + geocodeConfidence = 'moderate', + maxHailInches = null, + lastEventDate = null, + source = 'NOAA / IEM Local Storm Reports', + generatedAt = new Date().toISOString(), + } = input || {}; + + const years = Math.max(1, windowYears); + const count = Math.max(0, eventCount); + + // Annualized rate (events/year). + const lambda = count / years; + + // Poisson confidence band using event-count standard error (√count). + const se = Math.sqrt(count); + const lambdaLow = Math.max(0, (count - se) / years); + const lambdaHigh = (count + se) / years; + + const prob = (lam, n) => 1 - Math.exp(-lam * n); + const p10Base = prob(lambda, 10); + const p10Low = prob(lambdaLow, 10); + const p10High = prob(lambdaHigh, 10); + + // 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 + const geoScore = geocodeConfidence === 'high' ? 1 : geocodeConfidence === 'moderate' ? 0.6 : 0.3; + const confidenceValue = +(0.4 * sampleScore + 0.3 * windowScore + 0.3 * geoScore).toFixed(2); + const confidenceLabel = confidenceValue >= 0.66 ? 'High' : confidenceValue >= 0.4 ? 'Moderate' : 'Low'; + + // Coarse exposure tier used downstream by the scoring engine (Phase 4). + const exposureTier = p10Base >= 0.66 ? 'high' : p10Base >= 0.33 ? 'moderate' : 'low'; + + return { + // hail_risk_snapshot fields (spec §12) + source, + windowYears: years, + radiusMiles, + eventCount: count, + annualRate: +lambda.toFixed(3), + maxHailInches, + lastEventDate, + probability10yr: { low: pct(p10Low), base: pct(p10Base), high: pct(p10High) }, + confidence: confidenceLabel, + confidenceValue, + exposureTier, + generatedAt, + + // Customer-facing wording (spec §6) — historical, never a forecast. + wording: { + annualRate: count === 0 + ? 'No qualifying hail events were found in the available reports for this area.' + : `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.`, + 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()}.`, + }, + }; +} diff --git a/src/modules/estimateWizard/hooks/useWizardHailRisk.js b/src/modules/estimateWizard/hooks/useWizardHailRisk.js new file mode 100644 index 0000000..410ce66 --- /dev/null +++ b/src/modules/estimateWizard/hooks/useWizardHailRisk.js @@ -0,0 +1,87 @@ +/** + * useWizardHailRisk — produces a frozen hail-risk snapshot for the wizard. + * + * Pipeline (all read-only against existing services): + * 1. geocode the address (module-local geocode.js → Nominatim) + * 2. GET /api/storm-history (EXISTING storm module endpoint — not modified) + * 3. run computeHailRisk() (module-local Poisson model, spec §6) + * + * Returns { loading, error, property, snapshot }. The caller freezes `snapshot` + * into the session (spec §12 hail_risk_snapshots) so the report stays immutable. + */ +import { useState, useEffect } from 'react'; +import { geocodeAddress } from '../api/geocode'; +import { computeHailRisk } from '../engine/hailProbability'; + +export function useWizardHailRisk({ address, property, enabled = true, stormSettings = {} }) { + const [loading, setLoading] = useState(enabled); + const [error, setError] = useState(null); + const [resolvedProperty, setResolvedProperty] = useState(property || null); + const [snapshot, setSnapshot] = useState(null); + + const radiusMiles = stormSettings.radiusMiles ?? 20; + // storm-history caps months at 60 (5 years); window honesty is stored in the snapshot. + const months = 60; + const windowYears = months / 12; + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + setLoading(true); + setError(null); + + (async () => { + try { + // 1. Resolve coordinates (reuse stored property if already geocoded). + let prop = property; + if (!prop || prop.lat == null || prop.lon == null) { + prop = await geocodeAddress(address); + } + if (cancelled) return; + setResolvedProperty(prop); + + // 2. Existing keyless storm endpoint (read-only). + let hailCount = 0; + let maxHail = null; + let lastEventDate = null; + let source = 'NOAA / IEM Local Storm Reports'; + try { + const res = await fetch( + `/api/storm-history?lat=${prop.lat}&lng=${prop.lon}&months=${months}&radiusMi=${Math.min(radiusMiles, 50)}` + ); + if (res.ok) { + const data = await res.json(); + hailCount = data?.summary?.byType?.hail ?? 0; + maxHail = data?.summary?.maxHail ?? null; + lastEventDate = data?.summary?.lastEvent?.date ?? null; + } + } catch { + // Leave defaults (count 0) — computeHailRisk handles the empty case honestly. + } + if (cancelled) return; + + // 3. Conservative Poisson model. + const snap = computeHailRisk({ + eventCount: hailCount, + windowYears, + radiusMiles, + geocodeConfidence: prop.confidence, + maxHailInches: maxHail, + lastEventDate, + source, + generatedAt: new Date().toISOString(), + }); + if (!cancelled) setSnapshot(snap); + } catch (e) { + if (!cancelled) setError(e.message || 'Could not load storm history.'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address, enabled, radiusMiles]); + + return { loading, error, property: resolvedProperty, snapshot }; +} From 03801a5087fdd3f1903032de6f7dd0928ec28d1a Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Tue, 9 Jun 2026 17:31:58 +0530 Subject: [PATCH 03/32] good better best prising plus add ons --- .../components/AddonToggles.jsx | 55 ++++++++++++ .../components/GoodBetterBestCards.jsx | 86 +++++++++++++++++++ .../estimateWizard/components/WizardShell.jsx | 11 ++- .../components/steps/AddonsStep.jsx | 78 +++++++++++++++++ .../components/steps/OptionsStep.jsx | 37 ++++++++ .../estimateWizard/data/tenantConfig.js | 4 + src/modules/estimateWizard/engine/pricing.js | 83 ++++++++++++++++++ 7 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 src/modules/estimateWizard/components/AddonToggles.jsx create mode 100644 src/modules/estimateWizard/components/GoodBetterBestCards.jsx create mode 100644 src/modules/estimateWizard/components/steps/AddonsStep.jsx create mode 100644 src/modules/estimateWizard/components/steps/OptionsStep.jsx create mode 100644 src/modules/estimateWizard/engine/pricing.js diff --git a/src/modules/estimateWizard/components/AddonToggles.jsx b/src/modules/estimateWizard/components/AddonToggles.jsx new file mode 100644 index 0000000..1e8cd72 --- /dev/null +++ b/src/modules/estimateWizard/components/AddonToggles.jsx @@ -0,0 +1,55 @@ +/** + * AddonToggles — optional upgrades as simple toggles (spec §8). + * Plain-language value statements, never fear-based. "Recommended" badge when the + * customer's answers or selected package trigger it. Price/total recompute instantly. + */ +import React from 'react'; +import { Plus, Check, Sparkles } from 'lucide-react'; +import { formatUSD, isAddonRecommended } from '../engine/pricing'; + +const AddonToggles = ({ addons, selectedIds, onToggle, packageType, triggers }) => { + return ( +
+ {addons.map((addon) => { + const on = selectedIds.includes(addon.id); + const recommended = isAddonRecommended(addon, packageType, triggers); + return ( + + ); + })} +
+ ); +}; + +export default AddonToggles; diff --git a/src/modules/estimateWizard/components/GoodBetterBestCards.jsx b/src/modules/estimateWizard/components/GoodBetterBestCards.jsx new file mode 100644 index 0000000..2336da4 --- /dev/null +++ b/src/modules/estimateWizard/components/GoodBetterBestCards.jsx @@ -0,0 +1,86 @@ +/** + * GoodBetterBestCards — the three-option presentation (spec §7). + * Rules honored: + * - ALWAYS show all three options (never hide alternatives). + * - Manufacturer-neutral copy (generic categories only). + * - A `highlightedType` may be passed (Phase 4 recommendation) to badge one card, + * but every option stays visible and selectable. + */ +import React from 'react'; +import { Check, Shield, Star } from 'lucide-react'; +import { pricePackage, formatUSD } from '../engine/pricing'; + +const ACCENT = { + zinc: { ring: 'ring-zinc-300 dark:ring-zinc-600', text: 'text-zinc-600 dark:text-zinc-300', chip: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300' }, + blue: { ring: 'ring-blue-400', text: 'text-blue-600 dark:text-blue-400', chip: 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400' }, + amber: { ring: 'ring-amber-400', text: 'text-amber-600 dark:text-amber-400', chip: 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400' }, +}; + +const TIER_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; + +const GoodBetterBestCards = ({ packages, pricing, selectedType, onSelect, highlightedType }) => { + return ( +
+ {packages.map((pkg) => { + const a = ACCENT[pkg.accent] || ACCENT.zinc; + const price = pricePackage(pkg, pricing); + const selected = selectedType === pkg.type; + const highlighted = highlightedType === pkg.type; + + return ( + + ); + })} +
+ ); +}; + +export default GoodBetterBestCards; diff --git a/src/modules/estimateWizard/components/WizardShell.jsx b/src/modules/estimateWizard/components/WizardShell.jsx index f3789a3..71033bc 100644 --- a/src/modules/estimateWizard/components/WizardShell.jsx +++ b/src/modules/estimateWizard/components/WizardShell.jsx @@ -16,6 +16,8 @@ import StartStep from './steps/StartStep'; import AddressStep from './steps/AddressStep'; import PremiumStep from './steps/PremiumStep'; import HailRiskStep from './steps/HailRiskStep'; +import OptionsStep from './steps/OptionsStep'; +import AddonsStep from './steps/AddonsStep'; import PlaceholderStep from './steps/PlaceholderStep'; // Step registry — later phases swap PlaceholderStep entries for real components. @@ -24,16 +26,17 @@ const STEP_COMPONENTS = { address: AddressStep, premium: PremiumStep, hailRisk: HailRiskStep, - options: (props) => , - addons: (props) => , + options: OptionsStep, + addons: AddonsStep, recommendation: (props) => , report: (props) => , }; // Validation per step: returns true when the user may advance. -function canAdvance(step, inputs) { +function canAdvance(step, inputs, session) { if (step.id === 'start') return inputs.consent && inputs.name?.trim() && inputs.address?.trim(); if (step.id === 'address') return !!inputs.address?.trim(); + if (step.id === 'options') return !!session.selectedPackage; if (step.type === 'cards' && step.required) return inputs[step.inputKey] != null; if (step.type === 'slider' && step.required) return inputs[step.inputKey] != null; return true; @@ -75,7 +78,7 @@ const WizardShell = () => { } = useEstimateWizard(); const isLast = stepIndex === totalSteps - 1; - const advance = canAdvance(step, inputs); + const advance = canAdvance(step, inputs, session); const renderBody = () => { if (step.type === 'cards') { diff --git a/src/modules/estimateWizard/components/steps/AddonsStep.jsx b/src/modules/estimateWizard/components/steps/AddonsStep.jsx new file mode 100644 index 0000000..2ccffd9 --- /dev/null +++ b/src/modules/estimateWizard/components/steps/AddonsStep.jsx @@ -0,0 +1,78 @@ +/** + * AddonsStep — Screen 7b "Add-ons" (spec §8). + * Add-ons pre-select from the customer's answers + chosen package (once), then the + * customer can toggle freely. Running total recomputes instantly. + */ +import React, { useEffect, useMemo } from 'react'; +import { useEstimateWizard } from '../../EstimateWizardContext'; +import AddonToggles from '../AddonToggles'; +import { + pricePackage, addonsTotal, deriveAddonTriggers, defaultSelectedAddons, formatUSD, +} from '../../engine/pricing'; + +const AddonsStep = () => { + const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard(); + const { addons, packages, pricing } = tenantConfig; + + const packageType = session.selectedPackage || 'BETTER'; + const triggers = useMemo(() => deriveAddonTriggers(inputs, session.hailSnapshot), [inputs, session.hailSnapshot]); + + // Pre-select recommended add-ons once (spec §8 default-selected rules). + useEffect(() => { + if (session.addonsInitialized) return; + patchSession({ + selectedAddons: defaultSelectedAddons(addons, packageType, triggers), + addonsInitialized: true, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.addonsInitialized]); + + const selectedIds = session.selectedAddons || []; + + const handleToggle = (id) => { + const next = selectedIds.includes(id) + ? selectedIds.filter((x) => x !== id) + : [...selectedIds, id]; + patchSession({ selectedAddons: next }); + logEvent('addon_toggled', { id, on: !selectedIds.includes(id) }); + }; + + const pkg = packages.find((p) => p.type === packageType); + const base = pkg ? pricePackage(pkg, pricing).base : 0; + const addonsSum = addonsTotal(addons, selectedIds); + const total = base + addonsSum; + + return ( +
+ + + {/* Running total for the selected package */} +
+
+ {pkg?.name} (base estimate) + {formatUSD(base)} +
+
+ Selected add-ons ({selectedIds.length}) + +{formatUSD(addonsSum)} +
+
+
+ Estimated total + ~{formatUSD(total)} +
+

+ Preliminary — verified at inspection. Add-ons can be revised with your rep. +

+
+
+ ); +}; + +export default AddonsStep; diff --git a/src/modules/estimateWizard/components/steps/OptionsStep.jsx b/src/modules/estimateWizard/components/steps/OptionsStep.jsx new file mode 100644 index 0000000..4e0a4bb --- /dev/null +++ b/src/modules/estimateWizard/components/steps/OptionsStep.jsx @@ -0,0 +1,37 @@ +/** + * OptionsStep — Screen 7 "Build options" (spec §7). + * Shows all three Good/Better/Best options; customer selects one. The AI highlight + * (Phase 4) will badge a recommended option without hiding the others. + */ +import React from 'react'; +import { Info } from 'lucide-react'; +import { useEstimateWizard } from '../../EstimateWizardContext'; +import GoodBetterBestCards from '../GoodBetterBestCards'; + +const OptionsStep = () => { + const { session, patchSession, tenantConfig, logEvent } = useEstimateWizard(); + + const handleSelect = (type) => { + patchSession({ selectedPackage: type }); + logEvent('package_selected', { package: type }); + }; + + return ( +
+ +
+ + These are preliminary ranges based on an assumed roof size. Final pricing requires a roof + measurement and inspection — your rep will confirm everything. This is not a binding quote. +
+
+ ); +}; + +export default OptionsStep; diff --git a/src/modules/estimateWizard/data/tenantConfig.js b/src/modules/estimateWizard/data/tenantConfig.js index 4ef34ab..7ed022f 100644 --- a/src/modules/estimateWizard/data/tenantConfig.js +++ b/src/modules/estimateWizard/data/tenantConfig.js @@ -143,6 +143,10 @@ export const DEFAULT_PRICING = { pitchFactor: 1.0, // multiplied per pitch/stories/complexity in admin taxPlaceholderPct: 0, permitPlaceholder: 0, + // No measurement source in the wizard (spec §3) → assume an average roof size + // so the customer sees a PRELIMINARY range. Verified during inspection. + assumedSquares: 25, // ~2,500 sq ft of roof surface + rangeSpread: 0.08, // ±8% band shown to the customer (low/base/high) }; // ── Insurance discount scenarios (spec §10/§14) — low/base/high ─────────────── diff --git a/src/modules/estimateWizard/engine/pricing.js b/src/modules/estimateWizard/engine/pricing.js new file mode 100644 index 0000000..404dfcf --- /dev/null +++ b/src/modules/estimateWizard/engine/pricing.js @@ -0,0 +1,83 @@ +/** + * pricing.js — preliminary package + add-on pricing (spec §7, §8, §10, §14). + * + * Pure functions. All inputs come from tenantConfig (nothing hard-coded per §14). + * Because the wizard has no verified roof measurement (spec §3), every price is a + * PRELIMINARY RANGE (low/base/high) and must be labeled as such — final pricing + * requires inspection. + */ + +const round = (n, to = 50) => Math.round(n / to) * to; + +/** + * Price a single package for an assumed roof size. + * @returns {{ low:number, base:number, high:number }} + */ +export function pricePackage(pkg, pricing) { + const squares = pricing.assumedSquares ?? 25; + const waste = pricing.wasteFactor ?? 1.05; + const pitch = pricing.pitchFactor ?? 1.0; + + const raw = pkg.pricePerSquare * squares * waste * pitch; + const base = Math.max(pricing.minJobPrice ?? 0, raw); + const spread = pricing.rangeSpread ?? 0.08; + + return { + low: round(base * (1 - spread)), + base: round(base), + high: round(base * (1 + spread)), + }; +} + +/** Price every package (keyed by type). */ +export function priceAllPackages(packages, pricing) { + const out = {}; + packages.forEach((p) => { out[p.type] = pricePackage(p, pricing); }); + return out; +} + +/** Total price of the selected add-ons (flat, demo). */ +export function addonsTotal(addons, selectedIds) { + return addons + .filter((a) => selectedIds.includes(a.id)) + .reduce((sum, a) => sum + (a.price || 0), 0); +} + +/** + * Derive add-on trigger keys from the customer's wizard answers + hail snapshot. + * Drives default-selected add-ons and AI "recommend when…" hints (spec §8). + */ +export function deriveAddonTriggers(inputs, hailSnapshot) { + const t = new Set(); + if (inputs.issueType === 'leak') t.add('leak'); + if (inputs.issueType === 'aging' || (inputs.roofAgeYears ?? 0) >= 15) t.add('old_roof'); + if (inputs.atticConcern === 'attic_hot') t.add('attic_hot'); + if (inputs.atticConcern === 'energy_bills') t.add('energy_bills'); + if (inputs.pestConcern === 'yes') t.add('pests'); + if (inputs.budgetPreference === 'longterm') t.add('aesthetics'); + if (hailSnapshot?.exposureTier === 'high') t.add('complex'); + return t; +} + +/** + * Which add-ons should be pre-selected for a given package + answers (spec §8). + * An add-on defaults ON if its triggers fire, or it's recommended on this package. + */ +export function defaultSelectedAddons(addons, packageType, triggers) { + return addons + .filter((a) => + (a.recommendedOn || []).includes(packageType) || + (a.triggerKeys || []).some((k) => triggers.has(k)) + ) + .map((a) => a.id); +} + +/** Is an add-on "recommended" for display badge purposes? */ +export function isAddonRecommended(addon, packageType, triggers) { + return (addon.recommendedOn || []).includes(packageType) || + (addon.triggerKeys || []).some((k) => triggers.has(k)); +} + +export function formatUSD(n) { + return `$${Math.round(n).toLocaleString('en-US')}`; +} From 0da194b157ccc83d9f783d8f14ea94b6e0111073 Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Tue, 9 Jun 2026 17:45:29 +0530 Subject: [PATCH 04/32] AI recomendation feature --- src/modules/estimateWizard/ai/bannedClaims.js | 44 +++++ src/modules/estimateWizard/ai/explain.js | 122 +++++++++++++ .../estimateWizard/ai/knowledgeBase.js | 34 ++++ .../components/TenYearValueChart.jsx | 52 ++++++ .../estimateWizard/components/WizardShell.jsx | 3 +- .../components/steps/RecommendationStep.jsx | 127 ++++++++++++++ .../estimateWizard/engine/scoringEngine.js | 163 ++++++++++++++++++ .../estimateWizard/engine/valueModel.js | 72 ++++++++ 8 files changed, 616 insertions(+), 1 deletion(-) create mode 100644 src/modules/estimateWizard/ai/bannedClaims.js create mode 100644 src/modules/estimateWizard/ai/explain.js create mode 100644 src/modules/estimateWizard/ai/knowledgeBase.js create mode 100644 src/modules/estimateWizard/components/TenYearValueChart.jsx create mode 100644 src/modules/estimateWizard/components/steps/RecommendationStep.jsx create mode 100644 src/modules/estimateWizard/engine/scoringEngine.js create mode 100644 src/modules/estimateWizard/engine/valueModel.js diff --git a/src/modules/estimateWizard/ai/bannedClaims.js b/src/modules/estimateWizard/ai/bannedClaims.js new file mode 100644 index 0000000..9f7f924 --- /dev/null +++ b/src/modules/estimateWizard/ai/bannedClaims.js @@ -0,0 +1,44 @@ +/** + * bannedClaims.js — guardrail for AI-generated customer copy (spec §15, §18). + * + * Every AI explanation MUST pass validateClaim() before it is shown or stored. + * On any violation the caller discards the AI text and uses the deterministic + * fallback. This enforces: no guaranteed discount, no guaranteed hail event, + * no insurance/legal advice, no manufacturer names (unless brand mode enabled). + */ + +// Manufacturer / brand names that must not appear in customer-facing copy (§3, §15). +const MANUFACTURER_NAMES = [ + 'gaf', 'owens corning', 'certainteed', 'malarkey', 'iko', 'tamko', + 'atlas', 'decra', 'davinci', 'brava', 'f-wave', 'euroshield', 'boral', 'timberline', +]; + +const BANNED_PATTERNS = [ + { id: 'guaranteed_discount', re: /\b(guarantee[ds]?|guaranteed)\b[^.]*\b(discount|savings|premium)\b/i }, + { id: 'guaranteed_discount2', re: /\b(discount|savings)\b[^.]*\bguarantee/i }, + { id: 'will_get_hail', re: /\b(will|going to|guaranteed to)\b[^.]*\b(hail|storm|be hit|get hit)\b/i }, + { id: 'never_replace', re: /\bnever (have to )?replace\b/i }, + { id: 'lifetime_no_maintenance', re: /\b(maintenance[- ]free|zero maintenance) (for )?life/i }, + { id: 'file_a_claim', re: /\b(file|submit) (a|an|your) (insurance )?claim\b/i }, + { id: 'insurance_advice', re: /\b(you should|we recommend|advise you to) (change|switch|drop|cancel) (your )?insurance\b/i }, +]; + +/** + * @returns {{ ok: boolean, violations: string[] }} + */ +export function validateClaim(text = '', { allowBrands = false } = {}) { + const violations = []; + const lower = text.toLowerCase(); + + for (const { id, re } of BANNED_PATTERNS) { + if (re.test(text)) violations.push(id); + } + if (!allowBrands) { + for (const name of MANUFACTURER_NAMES) { + if (lower.includes(name)) { violations.push(`manufacturer:${name}`); break; } + } + } + return { ok: violations.length === 0, violations }; +} + +export const BANNED_CLAIM_IDS = BANNED_PATTERNS.map((p) => p.id); diff --git a/src/modules/estimateWizard/ai/explain.js b/src/modules/estimateWizard/ai/explain.js new file mode 100644 index 0000000..4703337 --- /dev/null +++ b/src/modules/estimateWizard/ai/explain.js @@ -0,0 +1,122 @@ +/** + * explain.js — AI recommendation explanation + rep brief (spec §9). + * + * Design: + * - The deterministic engine already chose the package and the reasons. The AI + * only turns them into warm plain-language copy; it cannot change the result. + * - Reuses the app's existing Groq integration (config.groqApiKey). With no valid + * key (config.isDemoMode) it skips the API and uses a deterministic fallback. + * - EVERY AI output passes validateClaim(); on any violation we discard the AI + * text and use the safe fallback (spec §15, §18). + */ +import Groq from 'groq-sdk'; +import { config } from '../../../config/env'; +import { SYSTEM_PROMPT } from './knowledgeBase'; +import { validateClaim } from './bannedClaims'; + +const AI_MODEL = 'openai/gpt-oss-120b'; + +const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; + +function fmtUSD(n) { return `$${Math.round(n).toLocaleString('en-US')}`; } + +// ── Deterministic fallback (also the safety net if AI is unavailable/invalid) ── + +function fallbackExplanation({ inputs, recommendation, valueModel }) { + const type = recommendation.selectedPackage; + const label = PKG_LABEL[type]; + const reasons = recommendation.reasons; + + const row = valueModel.rows.find((r) => r.type === type); + let savingsLine = ''; + if (valueModel.premiumKnown && row?.savings) { + savingsLine = ` Over 10 years, an illustrative savings scenario ranges from ${fmtUSD(row.savings.low)} to ${fmtUSD(row.savings.high)} — this is only an estimate and must be confirmed with your insurance carrier.`; + } + + const customerExplanation = + `Based on your answers, the ${label} option appears to be the strongest fit for your home and goals.` + + ` ${reasons[0] || ''}${savingsLine} We still recommend reviewing all three options with your roofing consultant, since final pricing, roof measurement, ventilation, and insurance eligibility must be verified.`; + + return { customerExplanation, reasons, repBrief: buildRepBrief({ inputs, recommendation }), aiUsed: false, guardrail: { ok: true, violations: [] } }; +} + +// Rep brief is deterministic regardless of AI (spec §9) — concise sales prep. +function buildRepBrief({ inputs, recommendation }) { + const parts = []; + parts.push(`Recommended: ${PKG_LABEL[recommendation.selectedPackage]} (${recommendation.modelVersion}).`); + if (inputs.ownershipHorizon) parts.push(`Ownership ${inputs.ownershipHorizon} yrs.`); + if (inputs.budgetPreference) parts.push(`Budget pref: ${inputs.budgetPreference}.`); + if (inputs.premiumUnknown || !inputs.premiumAmount) parts.push('Premium skipped — bring savings-scenario explanation.'); + else parts.push(`Premium ~$${inputs.premiumAmount}/yr.`); + if (inputs.atticConcern && inputs.atticConcern !== 'none') parts.push('Mentioned attic/ventilation — bring ventilation guidance.'); + if (inputs.pestConcern === 'yes') parts.push('Pest concern — discuss critter guard.'); + if (inputs.issueType === 'leak') parts.push('Active leak — prioritize inspection.'); + if (inputs.hoaConstraints === 'yes') parts.push('HOA constraints — verify approval before final material.'); + if (inputs.financingInterest === 'yes') parts.push('Interested in financing — prepare options.'); + if (recommendation.budgetGuardApplied) parts.push('Budget guard applied (did not upsell to Best over stated low-cost preference).'); + if (recommendation.uncertaintyFlags.length) parts.push(`Data gaps: ${recommendation.uncertaintyFlags.join(', ')}.`); + return parts.join(' '); +} + +// ── AI path ─────────────────────────────────────────────────────────────────── + +function buildUserPrompt({ inputs, hailSnapshot, recommendation, valueModel }) { + const type = recommendation.selectedPackage; + const row = valueModel.rows.find((r) => r.type === type); + return `Write a 3-4 sentence plain-language explanation for the homeowner of why the ${PKG_LABEL[type]} roof option is highlighted, then a one-line internal rep brief. + +Context (do not contradict; do not add numbers not here): +- Recommended option: ${PKG_LABEL[type]} +- Deterministic reasons: ${recommendation.reasons.join(' | ')} +- Ownership horizon: ${inputs.ownershipHorizon || 'unknown'} +- Budget preference: ${inputs.budgetPreference || 'unknown'} +- Roof age: ${inputs.roofAgeYears ?? 'unknown'} years +- Area hail exposure (historical): ${hailSnapshot?.exposureTier || 'unknown'} +- Annual premium: ${inputs.premiumUnknown || !inputs.premiumAmount ? 'not provided' : '$' + inputs.premiumAmount} +- 10-yr illustrative savings (base, ${type}): ${row?.savings ? '$' + row.savings.base : 'n/a'} + +Respond ONLY as JSON: {"customerExplanation": "...", "repBrief": "..."}`; +} + +export async function generateExplanation(ctx) { + // No valid key → deterministic mode (still fully functional for the demo). + if (config.isDemoMode(config.groqApiKey)) { + return fallbackExplanation(ctx); + } + + try { + const groq = new Groq({ apiKey: config.groqApiKey, dangerouslyAllowBrowser: true }); + const completion = await groq.chat.completions.create({ + model: AI_MODEL, + temperature: 0.3, + max_tokens: 400, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: buildUserPrompt(ctx) }, + ], + }); + + const raw = completion.choices?.[0]?.message?.content || ''; + let parsed; + try { parsed = JSON.parse(raw); } catch { return fallbackExplanation(ctx); } + + const customerExplanation = String(parsed.customerExplanation || '').trim(); + const repBriefAI = String(parsed.repBrief || '').trim(); + + // Guardrail: validate the customer-facing text. Any violation → safe fallback. + const guardrail = validateClaim(customerExplanation); + if (!guardrail.ok || customerExplanation.length < 20) { + const fb = fallbackExplanation(ctx); + return { ...fb, guardrail, blockedAiText: customerExplanation || null }; + } + + // Rep brief stays deterministic + optional AI sentence (also validated). + const repValidated = validateClaim(repBriefAI).ok ? repBriefAI : ''; + const repBrief = [buildRepBrief(ctx), repValidated].filter(Boolean).join(' '); + + return { customerExplanation, reasons: ctx.recommendation.reasons, repBrief, aiUsed: true, guardrail }; + } catch { + return fallbackExplanation(ctx); + } +} diff --git a/src/modules/estimateWizard/ai/knowledgeBase.js b/src/modules/estimateWizard/ai/knowledgeBase.js new file mode 100644 index 0000000..27ad14a --- /dev/null +++ b/src/modules/estimateWizard/ai/knowledgeBase.js @@ -0,0 +1,34 @@ +/** + * knowledgeBase.js — approved content blocks for AI grounding (spec §9, §15). + * The AI must ground answers in these; unknown questions route to the rep. Keeping + * the approved facts in one place makes the guardrails auditable. + */ + +export const APPROVED_FACTS = { + class4: 'Class 4 means the roofing material has passed a higher impact-resistance test (UL 2218). Whether it affects an insurance premium is decided by the carrier, not the contractor.', + hailFraming: 'Historical hail data describes the surrounding AREA over time. It is not a forecast or a guarantee that a specific home will be hit.', + insuranceDisclaimer: 'Any insurance premium savings are illustrative and must be confirmed with the homeowner’s insurance carrier or licensed agent. Discounts depend on state, carrier, policy, roof class, and documentation.', + serviceLife: 'Premium systems are designed for longer service life, but actual lifespan depends on installation, ventilation, maintenance, climate, and product. Avoid promises like “never replace again.”', + preliminary: 'Every wizard estimate is preliminary until a roof measurement and contractor inspection verify it.', + neutrality: 'Describe systems by generic category (architectural shingle, Class 4 impact-rated shingle, composite/synthetic or stone-coated steel). Do not name manufacturers.', +}; + +// Compact system prompt for the AI explainer (kept short for fast demo responses). +export const SYSTEM_PROMPT = `You are LynkedUp Pro's roofing estimate explainer. You write short, plain-language, HONEST explanations for a homeowner comparing Good/Better/Best roof options. + +NON-NEGOTIABLE RULES: +- Never guarantee an insurance discount or savings. Say savings are illustrative and must be confirmed with their carrier. +- Never say a home WILL get hail or be hit. Only describe historical AREA exposure. +- Never give insurance or legal advice. Never tell them to file a claim or change insurance. +- Never name roofing manufacturers/brands. Use generic categories only. +- Never promise "never replace again" — say "designed for longer service life." +- Do not invent measurements, prices, or discounts beyond what you are given. +- The recommended option is decided by a deterministic engine; you EXPLAIN it, you do not change it. + +Approved facts you may use: +- ${APPROVED_FACTS.class4} +- ${APPROVED_FACTS.hailFraming} +- ${APPROVED_FACTS.insuranceDisclaimer} +- ${APPROVED_FACTS.serviceLife} + +Tone: warm, clear, non-pushy. No hype.`; diff --git a/src/modules/estimateWizard/components/TenYearValueChart.jsx b/src/modules/estimateWizard/components/TenYearValueChart.jsx new file mode 100644 index 0000000..ab6b088 --- /dev/null +++ b/src/modules/estimateWizard/components/TenYearValueChart.jsx @@ -0,0 +1,52 @@ +/** + * TenYearValueChart — upfront vs illustrative 10-yr savings, per option (spec §10). + * Savings bars only render when a premium was provided and conservative mode is off. + */ +import React from 'react'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts'; + +const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; +const BAR_COLOR = { GOOD: '#a1a1aa', BETTER: '#3b82f6', BEST: '#fda913' }; + +const TenYearValueChart = ({ valueModel }) => { + const data = valueModel.rows.map((r) => ({ + name: PKG_LABEL[r.type], + type: r.type, + Upfront: r.price.base, + 'Est. 10-yr savings': r.savings ? r.savings.base : 0, + })); + + const fmt = (n) => `$${(n / 1000).toFixed(0)}k`; + + return ( +
+

10-year value comparison

+
+ + + + + `$${Number(v).toLocaleString('en-US')}`} + contentStyle={{ borderRadius: 12, border: '1px solid #e4e4e7', fontSize: 12 }} + /> + + + {data.map((d) => )} + + {valueModel.premiumKnown && !valueModel.conservativeMode && ( + + )} + + +
+

+ {valueModel.premiumKnown + ? 'Savings shown are illustrative and depend on carrier approval, policy terms, and documentation.' + : 'Add your insurance premium earlier in the wizard to see an illustrative 10-year savings scenario.'} +

+
+ ); +}; + +export default TenYearValueChart; diff --git a/src/modules/estimateWizard/components/WizardShell.jsx b/src/modules/estimateWizard/components/WizardShell.jsx index 71033bc..fddf82c 100644 --- a/src/modules/estimateWizard/components/WizardShell.jsx +++ b/src/modules/estimateWizard/components/WizardShell.jsx @@ -18,6 +18,7 @@ import PremiumStep from './steps/PremiumStep'; import HailRiskStep from './steps/HailRiskStep'; import OptionsStep from './steps/OptionsStep'; import AddonsStep from './steps/AddonsStep'; +import RecommendationStep from './steps/RecommendationStep'; import PlaceholderStep from './steps/PlaceholderStep'; // Step registry — later phases swap PlaceholderStep entries for real components. @@ -28,7 +29,7 @@ const STEP_COMPONENTS = { hailRisk: HailRiskStep, options: OptionsStep, addons: AddonsStep, - recommendation: (props) => , + recommendation: RecommendationStep, report: (props) => , }; diff --git a/src/modules/estimateWizard/components/steps/RecommendationStep.jsx b/src/modules/estimateWizard/components/steps/RecommendationStep.jsx new file mode 100644 index 0000000..0778b93 --- /dev/null +++ b/src/modules/estimateWizard/components/steps/RecommendationStep.jsx @@ -0,0 +1,127 @@ +/** + * RecommendationStep — Screen 8 "AI recommendation" (spec §9, §10). + * + * Flow: + * 1. Deterministic scoring engine picks the highlight + reasons (logged). + * 2. 10-year value model computes the savings/lifecycle comparison. + * 3. AI turns the reasons into plain-language copy (guardrailed); falls back to + * deterministic text if AI is unavailable or trips a banned-claim check. + * 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 { useEstimateWizard } from '../../EstimateWizardContext'; +import { score } from '../../engine/scoringEngine'; +import { computeTenYearValue } from '../../engine/valueModel'; +import { generateExplanation } from '../../ai/explain'; +import GoodBetterBestCards from '../GoodBetterBestCards'; +import TenYearValueChart from '../TenYearValueChart'; + +const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; + +const RecommendationStep = () => { + const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard(); + + // Deterministic — recomputed from current answers, stable across renders. + const recommendation = useMemo( + () => score({ inputs, hailSnapshot: session.hailSnapshot, tenantConfig }), + [inputs, session.hailSnapshot, tenantConfig] + ); + const value = useMemo( + () => computeTenYearValue({ inputs, tenantConfig }), + [inputs, tenantConfig] + ); + + const [explanation, setExplanation] = useState(session.recommendation?.explanation || null); + const [loadingAI, setLoadingAI] = useState(true); + + // Freeze deterministic record + log it (reproducible audit, spec §18). + useEffect(() => { + patchSession({ recommendation: { ...recommendation, value } }); + logEvent('recommendation_scored', { + modelVersion: recommendation.modelVersion, + selectedPackage: recommendation.selectedPackage, + scores: Object.fromEntries(Object.entries(recommendation.scores).map(([k, v]) => [k, v.total])), + uncertaintyFlags: recommendation.uncertaintyFlags, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recommendation, value]); + + // AI explanation (async, guardrailed). + useEffect(() => { + let cancelled = false; + setLoadingAI(true); + generateExplanation({ inputs, hailSnapshot: session.hailSnapshot, recommendation, valueModel: value, tenantConfig }) + .then((res) => { + if (cancelled) return; + setExplanation(res); + patchSession({ recommendation: { ...recommendation, value, explanation: res } }); + logEvent('ai_explanation_generated', { aiUsed: res.aiUsed, guardrailOk: res.guardrail?.ok }); + }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recommendation, value]); + + // Lock the highlighted option in as the customer's selection if they hadn't chosen. + useEffect(() => { + if (!session.selectedPackage) patchSession({ selectedPackage: recommendation.selectedPackage }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const highlight = recommendation.selectedPackage; + + 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'} + + )} +
+ + {loadingAI && !explanation ? ( +
+ Preparing your explanation… +
+ ) : ( + <> +

+ {explanation?.customerExplanation} +

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

+ This recommendation considers your stated budget and goals — all three options stay available. + Insurance savings are illustrative and must be confirmed with your carrier. +

+
+ ); +}; + +export default RecommendationStep; diff --git a/src/modules/estimateWizard/engine/scoringEngine.js b/src/modules/estimateWizard/engine/scoringEngine.js new file mode 100644 index 0000000..b6c088f --- /dev/null +++ b/src/modules/estimateWizard/engine/scoringEngine.js @@ -0,0 +1,163 @@ +/** + * scoringEngine.js — deterministic Good/Better/Best recommendation (spec §9). + * + * Contract (spec §9, §18): + * - Deterministic & reproducible: same inputs + same weights ⇒ same scores. + * - Logged: returns the full per-factor breakdown so it can be audited. + * - AI may EXPLAIN the result but must never silently change it. + * - Never always recommends Best — budget preference + uncertainty penalties guard + * against over-recommendation (spec §15 fairness). + * + * Pure function. No I/O, no randomness, no Date. + */ + +const TYPES = ['GOOD', 'BETTER', 'BEST']; +export const SCORING_MODEL_VERSION = 'scoring-v1'; + +// ── Per-factor sub-scores (0..1) by package type ───────────────────────────── + +function ownershipFactor(horizon) { + switch (horizon) { + case '1-5': return { GOOD: 1.0, BETTER: 0.6, BEST: 0.3 }; + case '6-15': return { GOOD: 0.5, BETTER: 1.0, BEST: 0.7 }; + case '16+': return { GOOD: 0.3, BETTER: 0.7, BEST: 1.0 }; + default: return { GOOD: 0.6, BETTER: 0.7, BEST: 0.6 }; + } +} + +function hailFactor(exposureTier) { + switch (exposureTier) { + case 'low': return { GOOD: 1.0, BETTER: 0.7, BEST: 0.5 }; + case 'moderate': return { GOOD: 0.5, BETTER: 1.0, BEST: 0.8 }; + case 'high': return { GOOD: 0.2, BETTER: 0.8, BEST: 1.0 }; + default: return { GOOD: 0.6, BETTER: 0.7, BEST: 0.6 }; // unknown → neutral + } +} + +function budgetFactor(pref) { + switch (pref) { + case 'lowest': return { GOOD: 1.0, BETTER: 0.5, BEST: 0.2 }; + case 'balanced': return { GOOD: 0.6, BETTER: 1.0, BEST: 0.6 }; + case 'longterm': return { GOOD: 0.3, BETTER: 0.7, BEST: 1.0 }; + default: return { GOOD: 0.7, BETTER: 0.8, BEST: 0.6 }; + } +} + +function roofAgeFactor(years, issueType) { + const urgent = issueType === 'leak'; + if (years >= 15 || urgent) return { GOOD: 0.5, BETTER: 0.8, BEST: 1.0 }; + if (years >= 8) return { GOOD: 0.7, BETTER: 1.0, BEST: 0.8 }; + return { GOOD: 1.0, BETTER: 0.8, BEST: 0.6 }; +} + +// Insurance-savings factor scales with premium × each package's discount. +function insuranceFactor(premium, packages) { + if (!premium || premium <= 0) { + return { factors: { GOOD: 0.5, BETTER: 0.5, BEST: 0.5 }, known: false }; + } + const savings = {}; + packages.forEach((p) => { savings[p.type] = premium * ((p.insuranceDiscountPct || 0) / 100); }); + const max = Math.max(...Object.values(savings), 1); + const factors = {}; + TYPES.forEach((t) => { factors[t] = +(savings[t] / max).toFixed(3); }); + return { factors, known: true, savings }; +} + +function aestheticsFactor(inputs) { + const premiumLook = inputs.budgetPreference === 'longterm' || inputs.hoaConstraints === 'yes'; + return premiumLook + ? { GOOD: 0.5, BETTER: 0.8, BEST: 1.0 } + : { GOOD: 0.7, BETTER: 0.9, BEST: 1.0 }; +} + +// ── Reason synthesis (deterministic, feeds AI + fallback copy) ──────────────── + +function buildReasons(type, ctx) { + const { inputs, hailSnapshot } = ctx; + const r = []; + if (type === 'BEST') { + if (inputs.ownershipHorizon === '16+') r.push('You plan to stay in the home long-term (16+ years), so a longer-life system spreads its cost over more years.'); + if (hailSnapshot?.exposureTier === 'high') r.push('Your area shows higher historical hail exposure, where the most durable system adds the most protection.'); + if (inputs.premiumAmount > 0) r.push('Your insurance premium creates a potential savings scenario if your carrier recognizes qualifying impact-resistant materials.'); + r.push('It carries the strongest lifecycle value case and fewest expected future roof cycles.'); + } else if (type === 'BETTER') { + r.push('It improves hail resistance with a Class 4 impact-rated system at a balanced cost.'); + if (hailSnapshot?.exposureTier !== 'low') r.push('Given your area’s historical hail activity, the added impact resistance is meaningful.'); + if (inputs.premiumAmount > 0) r.push('It may qualify for insurance premium consideration where available — confirm eligibility with your carrier.'); + r.push('It balances stronger protection against premium-system cost.'); + } else { + if (inputs.budgetPreference === 'lowest') r.push('You prioritized the lowest upfront investment.'); + if (inputs.ownershipHorizon === '1-5') r.push('A shorter ownership horizon makes lowest upfront cost a rational choice.'); + if (hailSnapshot?.exposureTier === 'low') r.push('Your area shows lower historical hail exposure.'); + r.push('It uses a quality architectural shingle system that meets code with standard accessories.'); + } + return r.slice(0, 4); +} + +/** + * score() — main entry. + * @param {object} args { inputs, hailSnapshot, tenantConfig } + * @returns recommendation record (spec §12 recommendations) + */ +export function score({ inputs, hailSnapshot, tenantConfig }) { + const weights = tenantConfig.scoringWeights; + const packages = tenantConfig.packages; + + const fOwn = ownershipFactor(inputs.ownershipHorizon); + const fHail = hailFactor(hailSnapshot?.exposureTier); + const fBudget = budgetFactor(inputs.budgetPreference); + const fAge = roofAgeFactor(inputs.roofAgeYears ?? 10, inputs.issueType); + const fIns = insuranceFactor(inputs.premiumAmount, packages); + const fAes = aestheticsFactor(inputs); + + const uncertaintyFlags = []; + if (!inputs.premiumAmount || inputs.premiumUnknown) uncertaintyFlags.push('premium_unknown'); + if (!hailSnapshot || hailSnapshot.exposureTier == null) uncertaintyFlags.push('hail_unknown'); + if (inputs.hoaConstraints === 'unsure') uncertaintyFlags.push('hoa_unsure'); + + const scores = {}; + TYPES.forEach((t) => { + const factors = { + ownershipHorizon: fOwn[t], + hailRisk: fHail[t], + insuranceSavings: fIns.factors[t], + budgetPreference: fBudget[t], + roofAge: fAge[t], + aesthetics: fAes[t], + }; + let total = + factors.ownershipHorizon * weights.ownershipHorizon + + factors.hailRisk * weights.hailRisk + + factors.insuranceSavings * weights.insuranceSavings + + factors.budgetPreference * weights.budgetPreference + + factors.roofAge * weights.roofAge + + factors.aesthetics * weights.aesthetics; + + // Uncertainty penalty — applied to BEST so missing data never auto-upsells. + if (t === 'BEST') total -= 0.04 * uncertaintyFlags.length; + + scores[t] = { total: +total.toFixed(4), factors }; + }); + + // Pick the top-scoring package. + let selectedPackage = TYPES.reduce((best, t) => (scores[t].total > scores[best].total ? t : best), 'GOOD'); + + // Budget guard (spec §7/§15): a strong "lowest upfront" preference must not be + // overridden to BEST unless exposure is high AND the homeowner is long-term. + let budgetGuardApplied = false; + if (inputs.budgetPreference === 'lowest' && selectedPackage === 'BEST') { + const justified = hailSnapshot?.exposureTier === 'high' && inputs.ownershipHorizon === '16+'; + if (!justified) { selectedPackage = 'BETTER'; budgetGuardApplied = true; } + } + + return { + modelVersion: SCORING_MODEL_VERSION, + weights, + scores, + selectedPackage, + reasons: buildReasons(selectedPackage, { inputs, hailSnapshot }), + uncertaintyFlags, + budgetGuardApplied, + insuranceKnown: fIns.known, + }; +} diff --git a/src/modules/estimateWizard/engine/valueModel.js b/src/modules/estimateWizard/engine/valueModel.js new file mode 100644 index 0000000..a87f017 --- /dev/null +++ b/src/modules/estimateWizard/engine/valueModel.js @@ -0,0 +1,72 @@ +/** + * valueModel.js — 10-year value comparison (spec §10). + * + * Makes premium options understandable WITHOUT pretending savings are guaranteed: + * - 10-yr premium savings scenario = annual premium × assumed discount × 10 + * - shows low/base/high range (carrier discount is never certain) + * - upgrade breakeven only when pricing AND discount are known + * - lifecycle value delta is labeled a model estimate, not a promise + * + * Pure function. Honors conservativeMode (spec §18) by suppressing savings claims. + */ +import { pricePackage } from './pricing'; + +const YEARS = 10; + +export function computeTenYearValue({ inputs, tenantConfig }) { + const { packages, pricing, discountScenarios, conservativeMode } = tenantConfig; + const premium = (!inputs.premiumUnknown && inputs.premiumAmount > 0) ? inputs.premiumAmount : null; + + const goodPrice = pricePackage(packages.find((p) => p.type === 'GOOD'), pricing).base; + + const rows = packages.map((pkg) => { + const price = pricePackage(pkg, pricing); + const scen = discountScenarios[pkg.type] || { low: 0, base: 0, high: 0 }; + + // 10-year premium savings scenario (illustrative). + const savings = (premium && !conservativeMode) + ? { + low: Math.round(premium * (scen.low / 100) * YEARS), + base: Math.round(premium * (scen.base / 100) * YEARS), + high: Math.round(premium * (scen.high / 100) * YEARS), + } + : null; + + // Upgrade cost vs the Good baseline. + const upgradeCost = Math.max(0, price.base - goodPrice); + + // Breakeven (years) — only when both pricing and a positive savings scenario exist. + const annualSavingsBase = (premium && !conservativeMode) ? premium * (scen.base / 100) : 0; + const breakevenYears = (upgradeCost > 0 && annualSavingsBase > 0) + ? +(upgradeCost / annualSavingsBase).toFixed(1) + : null; + + // Lifecycle value delta (model estimate): savings + avoided future roof reserve − upgrade cost. + // Avoided-reserve proxy: extra service life beyond Good, valued at Good's price prorated. + const goodLife = packages.find((p) => p.type === 'GOOD').serviceLifeYears || 28; + const extraLife = Math.max(0, (pkg.serviceLifeYears || goodLife) - goodLife); + const avoidedReserve = Math.round((extraLife / goodLife) * goodPrice * 0.5); + const lifecycleDelta = (savings ? savings.base : 0) + avoidedReserve - upgradeCost; + + return { + type: pkg.type, + name: pkg.name, + price, + discountPct: scen.base, + serviceLifeYears: pkg.serviceLifeYears, + hailResistance: pkg.hailResistance, + savings, + upgradeCost, + breakevenYears, + avoidedReserve, + lifecycleDelta: Math.round(lifecycleDelta), + }; + }); + + return { + years: YEARS, + premiumKnown: !!premium, + conservativeMode: !!conservativeMode, + rows, + }; +} From 075d2dd60f3719a9517eccaabc276982357b1632 Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Tue, 9 Jun 2026 18:01:32 +0530 Subject: [PATCH 05/32] wizard admin feature in existing crm as separate route --- src/App.jsx | 6 + src/components/Layout.jsx | 2 + .../estimateWizard/CustomerEstimateWizard.jsx | 14 +- .../estimateWizard/EstimateWizardAdmin.jsx | 193 +++++++++++++++ src/modules/estimateWizard/ai/explain.js | 4 +- src/modules/estimateWizard/api/crmHandoff.js | 71 ++++++ .../estimateWizard/components/WizardShell.jsx | 6 +- .../components/steps/ReportStep.jsx | 135 +++++++++++ .../estimateWizard/data/tenantConfig.js | 50 +++- .../utils/wizardReportExport.js | 223 ++++++++++++++++++ 10 files changed, 695 insertions(+), 9 deletions(-) create mode 100644 src/modules/estimateWizard/EstimateWizardAdmin.jsx create mode 100644 src/modules/estimateWizard/api/crmHandoff.js create mode 100644 src/modules/estimateWizard/components/steps/ReportStep.jsx create mode 100644 src/modules/estimateWizard/utils/wizardReportExport.js diff --git a/src/App.jsx b/src/App.jsx index a9d4661..f24f9bf 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -46,6 +46,7 @@ import FieldStormZonePage from './pages/FieldStormZonePage'; import UserDetailsPage from './pages/UserDetailsPage'; import LeadVerificationPage from './pages/LeadVerification/LeadVerificationPage'; import CustomerEstimateWizard from './modules/estimateWizard/CustomerEstimateWizard'; +import EstimateWizardAdmin from './modules/estimateWizard/EstimateWizardAdmin'; // ... (existing imports) const ProtectedRoute = ({ children, allowedRoles }) => { @@ -77,6 +78,11 @@ function App() { } /> {/* Public DIY Roofing Estimate Wizard — homeowner-facing, no auth, chrome-less */} } /> + + + + } /> { { to: "/owner/people", icon: Users, label: "People" }, { to: "/owner/settings", icon: Settings, label: "Org Settings" }, ...commonItems, + { to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" }, ]; case 'ADMIN': return [ @@ -178,6 +179,7 @@ const Layout = () => { { to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" }, { to: "/admin/settings", icon: Settings, label: "Org Settings" }, ...commonItems, + { to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" }, ]; case 'CONTRACTOR': case 'SUBCONTRACTOR': diff --git a/src/modules/estimateWizard/CustomerEstimateWizard.jsx b/src/modules/estimateWizard/CustomerEstimateWizard.jsx index 2475729..24c3132 100644 --- a/src/modules/estimateWizard/CustomerEstimateWizard.jsx +++ b/src/modules/estimateWizard/CustomerEstimateWizard.jsx @@ -33,8 +33,20 @@ const CompletionScreen = () => { ); }; +const FeatureDisabledScreen = () => ( +
+
+

Estimate wizard unavailable

+

+ This tool is temporarily turned off. Please contact us directly and we’ll be glad to help. +

+
+
+); + const WizardRouter = () => { - const { session } = useEstimateWizard(); + const { session, tenantConfig } = useEstimateWizard(); + if (tenantConfig.featureEnabled === false) return ; // §18 kill-switch return session.status === 'completed' ? : ; }; diff --git a/src/modules/estimateWizard/EstimateWizardAdmin.jsx b/src/modules/estimateWizard/EstimateWizardAdmin.jsx new file mode 100644 index 0000000..d68dba5 --- /dev/null +++ b/src/modules/estimateWizard/EstimateWizardAdmin.jsx @@ -0,0 +1,193 @@ +/** + * EstimateWizardAdmin — internal admin controls + analytics (spec §14, §16, §18). + * + * Tenant-controls (no hard-coded pricing/copy): feature kill-switch, AI toggle, + * conservative mode, per-package price + base discount + service life, and report + * branding. Persists as overrides via saveConfigOverrides(). Also shows a funnel + * summary built from completed-session submissions (spec §16). + */ +import React, { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Save, RotateCcw, Power, Sparkles, ShieldCheck, BarChart3 } from 'lucide-react'; +import { + getTenantConfig, loadConfigOverrides, saveConfigOverrides, resetConfigOverrides, +} from './data/tenantConfig'; +import { loadSubmissions } from './api/crmHandoff'; + +const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; + +const Toggle = ({ label, desc, checked, onChange, icon: Icon }) => ( + +); + +const numInput = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500'; + +const EstimateWizardAdmin = () => { + const initial = useMemo(() => getTenantConfig(), []); + const [cfg, setCfg] = useState(initial); + + const submissions = useMemo(() => loadSubmissions(), []); + const analytics = useMemo(() => { + const total = submissions.length; + const dist = (key) => submissions.reduce((m, s) => { const k = s[key]; if (k) m[k] = (m[k] || 0) + 1; return m; }, {}); + const booked = submissions.filter((s) => s.report?.appointmentBooked).length; + const addonAttach = total ? (submissions.reduce((n, s) => n + (s.selectedAddons?.length || 0), 0) / total) : 0; + return { total, recommended: dist('recommendedPackage'), selected: dist('selectedPackage'), booked, addonAttach }; + }, [submissions]); + + const setFlag = (k, v) => setCfg((c) => ({ ...c, [k]: v })); + const setPkg = (type, field, value) => setCfg((c) => ({ + ...c, + packages: c.packages.map((p) => (p.type === type ? { ...p, [field]: value } : p)), + })); + const setDiscount = (type, value) => setCfg((c) => ({ + ...c, + discountScenarios: { ...c.discountScenarios, [type]: { ...c.discountScenarios[type], base: value } }, + })); + const setBranding = (field, value) => setCfg((c) => ({ ...c, branding: { ...c.branding, [field]: value } })); + + const handleSave = () => { + const prev = loadConfigOverrides(); + const overrides = { + ...prev, + featureEnabled: cfg.featureEnabled, + aiExplanationsEnabled: cfg.aiExplanationsEnabled, + conservativeMode: cfg.conservativeMode, + branding: cfg.branding, + discountScenarios: cfg.discountScenarios, + packages: cfg.packages.map((p) => ({ + type: p.type, pricePerSquare: p.pricePerSquare, serviceLifeYears: p.serviceLifeYears, + })), + }; + saveConfigOverrides(overrides); + toast.success('Estimate Wizard settings saved'); + }; + + const handleReset = () => { + resetConfigOverrides(); + setCfg(getTenantConfig()); + toast.message('Reset to defaults'); + }; + + return ( +
+
+ {/* Header */} +
+
+

+ Estimate Wizard — Admin +

+

+ Tenant controls for pricing, discounts, AI, branding, and analytics. +

+
+
+ + +
+
+ + {/* Feature flags */} +
+ setFlag('featureEnabled', v)} /> + setFlag('aiExplanationsEnabled', v)} /> + setFlag('conservativeMode', v)} /> +
+ + {/* Package pricing */} +
+

Packages, pricing & discounts

+
+ {cfg.packages.map((p) => ( +
+
+
{PKG_LABEL[p.type]}
+
{p.name}
+
+ + + +
+ ))} +
+

Customer copy always states discounts are illustrative — "ask your carrier to confirm."

+
+ + {/* Branding */} +
+

Report branding

+
+ + + +
+
+ + {/* Analytics */} +
+

+ Funnel analytics +

+ {analytics.total === 0 ? ( +

No completed estimates yet. Finish a wizard run to populate metrics.

+ ) : ( +
+ + + + + + +
+ )} +
+
+
+ ); +}; + +const Metric = ({ label, value }) => ( +
+
{label}
+
{value}
+
+); + +const DistRow = ({ label, dist }) => ( +
+
{label}
+
+ {['GOOD', 'BETTER', 'BEST'].map((t) => ( + {PKG_LABEL[t]}: {dist[t] || 0} + ))} +
+
+); + +export default EstimateWizardAdmin; diff --git a/src/modules/estimateWizard/ai/explain.js b/src/modules/estimateWizard/ai/explain.js index 4703337..33ed854 100644 --- a/src/modules/estimateWizard/ai/explain.js +++ b/src/modules/estimateWizard/ai/explain.js @@ -79,8 +79,8 @@ Respond ONLY as JSON: {"customerExplanation": "...", "repBrief": "..."}`; } export async function generateExplanation(ctx) { - // No valid key → deterministic mode (still fully functional for the demo). - if (config.isDemoMode(config.groqApiKey)) { + // Admin disabled AI (spec §18) or no valid key → deterministic mode. + if (ctx.tenantConfig?.aiExplanationsEnabled === false || config.isDemoMode(config.groqApiKey)) { return fallbackExplanation(ctx); } diff --git a/src/modules/estimateWizard/api/crmHandoff.js b/src/modules/estimateWizard/api/crmHandoff.js new file mode 100644 index 0000000..17e5967 --- /dev/null +++ b/src/modules/estimateWizard/api/crmHandoff.js @@ -0,0 +1,71 @@ +/** + * crmHandoff.js — completion handoff to CRM + nurture (spec §11, §13, §17 E2). + * + * The wizard is a self-contained module, so instead of mutating the global CRM + * store it writes a CRM-ready submission to localStorage. An internal page (or a + * future adapter) can read this list to create the contact/lead and fire the + * nurture playbook. Also returns the immutable compliance record (spec §18 G1). + */ + +const SUBMISSIONS_KEY = 'lup_estimate_wizard_submissions_v1'; + +export function loadSubmissions() { + try { + const raw = localStorage.getItem(SUBMISSIONS_KEY); + return raw ? JSON.parse(raw) : []; + } catch { return []; } +} + +function persist(list) { + try { localStorage.setItem(SUBMISSIONS_KEY, JSON.stringify(list)); } catch { /* quota */ } +} + +/** + * Build the immutable compliance record stored with the report (spec §18 G1): + * assumptions + source data + disclaimer version + package-config version + consent. + */ +export function buildComplianceRecord(session, tenantConfig) { + return { + consent: !!session.inputs.consent, + tenantConfigVersion: tenantConfig.version, + disclaimerVersion: tenantConfig.disclaimerVersion, + scoringModelVersion: session.recommendation?.modelVersion || null, + hailSource: session.hailSnapshot?.source || null, + hailWindowYears: session.hailSnapshot?.windowYears || null, + hailConfidence: session.hailSnapshot?.confidence || null, + assumedSquares: tenantConfig.pricing.assumedSquares, + conservativeMode: tenantConfig.conservativeMode, + aiUsed: session.recommendation?.explanation?.aiUsed ?? false, + generatedAt: new Date().toISOString(), + }; +} + +/** + * Create a CRM contact/lead payload + nurture trigger from a completed session. + * @returns the submission record (also persisted). + */ +export function submitToCrm(session, tenantConfig, reportMeta) { + const i = session.inputs; + const submission = { + sessionId: session.id, + submittedAt: new Date().toISOString(), + contact: { name: i.name, address: i.address, phone: i.phone, email: i.email, contactMethod: i.contactMethod }, + source: session.source, + selectedPackage: session.selectedPackage, + selectedAddons: session.selectedAddons, + recommendedPackage: session.recommendation?.selectedPackage || null, + hail: session.hailSnapshot + ? { exposureTier: session.hailSnapshot.exposureTier, p10Base: session.hailSnapshot.probability10yr?.base } + : null, + repBrief: session.recommendation?.explanation?.repBrief || null, + report: reportMeta || null, + compliance: buildComplianceRecord(session, tenantConfig), + // Nurture handoff (spec §14): which playbook trigger fired. + nurtureTrigger: 'wizard_completed', + }; + + const list = loadSubmissions(); + list.unshift(submission); + persist(list); + return submission; +} diff --git a/src/modules/estimateWizard/components/WizardShell.jsx b/src/modules/estimateWizard/components/WizardShell.jsx index fddf82c..3d0d76f 100644 --- a/src/modules/estimateWizard/components/WizardShell.jsx +++ b/src/modules/estimateWizard/components/WizardShell.jsx @@ -19,9 +19,9 @@ import HailRiskStep from './steps/HailRiskStep'; import OptionsStep from './steps/OptionsStep'; import AddonsStep from './steps/AddonsStep'; import RecommendationStep from './steps/RecommendationStep'; -import PlaceholderStep from './steps/PlaceholderStep'; +import ReportStep from './steps/ReportStep'; -// Step registry — later phases swap PlaceholderStep entries for real components. +// Step registry — every step now has a real component. const STEP_COMPONENTS = { start: StartStep, address: AddressStep, @@ -30,7 +30,7 @@ const STEP_COMPONENTS = { options: OptionsStep, addons: AddonsStep, recommendation: RecommendationStep, - report: (props) => , + report: ReportStep, }; // Validation per step: returns true when the user may advance. diff --git a/src/modules/estimateWizard/components/steps/ReportStep.jsx b/src/modules/estimateWizard/components/steps/ReportStep.jsx new file mode 100644 index 0000000..f70ee56 --- /dev/null +++ b/src/modules/estimateWizard/components/steps/ReportStep.jsx @@ -0,0 +1,135 @@ +/** + * ReportStep — Screen 9 "Report + next step" (spec §11, §13). + * Shows the report summary, lets the customer email/print/download/share, books + * the inspection, and fires the CRM + nurture handoff (once) with the immutable + * compliance record (spec §18 G1). + */ +import React, { useEffect, useState } from 'react'; +import { Download, Printer, Mail, Share2, CalendarCheck, FileText, Loader2, Check } from 'lucide-react'; +import { useEstimateWizard } from '../../EstimateWizardContext'; +import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport'; +import { submitToCrm } from '../../api/crmHandoff'; +import { formatUSD } from '../../engine/pricing'; + +const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; + +const ActionButton = ({ icon: Icon, label, onClick, busy, primary }) => ( + +); + +const ReportStep = () => { + const { session, tenantConfig, patchSession, logEvent } = useEstimateWizard(); + const model = buildReportModel(session, tenantConfig); + const [busy, setBusy] = useState(null); + const [booked, setBooked] = useState(!!session.report?.appointmentBooked); + + // Fire CRM + nurture handoff once (spec §13 / §17 E2). + useEffect(() => { + if (session.report) return; + const reportMeta = { reportId: model.reportId, version: 1, generatedAt: model.generatedAt, deliveryStatus: 'created' }; + patchSession({ report: reportMeta }); + submitToCrm(session, tenantConfig, reportMeta); + logEvent('report_generated', { reportId: model.reportId }); + logEvent('crm_handoff', { trigger: 'wizard_completed' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleDownload = async () => { + setBusy('download'); + try { await downloadReportPdf(session, tenantConfig); logEvent('report_downloaded', { reportId: model.reportId }); } + finally { setBusy(null); } + }; + + const handlePrint = async () => { + setBusy('print'); + try { + const { url } = await getReportBlobUrl(session, tenantConfig); + const w = window.open(url, '_blank'); + if (w) w.addEventListener('load', () => w.print()); + patchSession({ report: { ...session.report, printedAt: new Date().toISOString() } }); + logEvent('report_printed', { reportId: model.reportId }); + } finally { setBusy(null); } + }; + + const handleEmail = () => { + const subject = encodeURIComponent(`Your Roof Options Report (${model.reportId})`); + const body = encodeURIComponent( + `Hi ${model.contact.name || ''},\n\nHere is a summary of your roof options report.\n` + + `Recommended: ${PKG_LABEL[model.recommended]}\nEstimated total: ~${formatUSD(model.totals.total)}\n\n` + + `${tenantConfig.branding.company} · ${tenantConfig.branding.phone}\n\n` + + `Note: ${tenantConfig.disclaimer}` + ); + window.location.href = `mailto:${model.contact.email || ''}?subject=${subject}&body=${body}`; + patchSession({ report: { ...session.report, emailedAt: new Date().toISOString(), deliveryStatus: 'emailed' } }); + logEvent('report_emailed', { reportId: model.reportId }); + }; + + const handleShare = async () => { + const summary = `My roof options report (${PKG_LABEL[model.recommended]} recommended) from ${tenantConfig.branding.company}.`; + try { + if (navigator.share) await navigator.share({ title: 'Roof Options Report', text: summary }); + else { await navigator.clipboard?.writeText(summary); } + logEvent('report_shared', { reportId: model.reportId }); + } 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 */} +
+
+ + Your Roof Options Report + {model.reportId} +
+
+
Prepared for
{model.contact.name || '—'}
+
Recommended
{PKG_LABEL[model.recommended]}
+
Hail exposure
{model.hail?.exposureTier || '—'}
+
Estimated total
~{formatUSD(model.totals.total)}
+
+

+ {tenantConfig.disclaimer} +

+
+ + {/* Delivery actions */} +
+ + + + +
+ + {/* Next step CTA */} + {booked ? ( +
+ +
+ Inspection requested — your rep will reach out via {session.inputs.contactMethod}. Thank you! +
+
+ ) : ( + + )} +
+ ); +}; + +export default ReportStep; diff --git a/src/modules/estimateWizard/data/tenantConfig.js b/src/modules/estimateWizard/data/tenantConfig.js index 7ed022f..e1a6dac 100644 --- a/src/modules/estimateWizard/data/tenantConfig.js +++ b/src/modules/estimateWizard/data/tenantConfig.js @@ -174,11 +174,20 @@ export const DISCLAIMER_TEMPLATE = 'insurance professional. Historical storm information is not a forecast or guarantee.'; export const TENANT_CONFIG_VERSION = 'tenant-config-v1'; +export const DISCLAIMER_VERSION = 'disclaimer-v1'; -export function getTenantConfig() { - // Phase 5 admin page will override these from localStorage / mock store. +// Admin-editable overrides persist here (spec §14). The wizard merges them over +// the defaults so pricing/branding/feature flags are tenant-controlled, not code. +const CONFIG_STORAGE_KEY = 'lup_estimate_wizard_config_v1'; + +function defaults() { return { version: TENANT_CONFIG_VERSION, + disclaimerVersion: DISCLAIMER_VERSION, + // Feature flags (spec §18): kill-switch, AI toggle, conservative copy mode. + featureEnabled: true, + aiExplanationsEnabled: true, + conservativeMode: false, packages: DEFAULT_PACKAGES, addons: DEFAULT_ADDONS, scoringWeights: DEFAULT_SCORING_WEIGHTS, @@ -187,6 +196,41 @@ export function getTenantConfig() { discountScenarios: DEFAULT_DISCOUNT_SCENARIOS, branding: DEFAULT_BRANDING, disclaimer: DISCLAIMER_TEMPLATE, - conservativeMode: false, // §18: hides customer-facing savings claims when true + }; +} + +export function loadConfigOverrides() { + try { + const raw = localStorage.getItem(CONFIG_STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { return {}; } +} + +export function saveConfigOverrides(overrides) { + try { localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(overrides || {})); } catch { /* quota */ } +} + +export function resetConfigOverrides() { + try { localStorage.removeItem(CONFIG_STORAGE_KEY); } catch { /* ignore */ } +} + +export function getTenantConfig() { + const base = defaults(); + const o = loadConfigOverrides(); + + // Shallow-merge top-level objects so partial admin edits don't drop defaults. + return { + ...base, + ...o, + pricing: { ...base.pricing, ...(o.pricing || {}) }, + branding: { ...base.branding, ...(o.branding || {}) }, + scoringWeights: { ...base.scoringWeights, ...(o.scoringWeights || {}) }, + stormSettings: { ...base.stormSettings, ...(o.stormSettings || {}) }, + discountScenarios: { ...base.discountScenarios, ...(o.discountScenarios || {}) }, + // Packages: merge admin price/label edits by type, keep default scope/copy. + packages: base.packages.map((p) => { + const ov = (o.packages || []).find((x) => x.type === p.type); + return ov ? { ...p, ...ov } : p; + }), }; } diff --git a/src/modules/estimateWizard/utils/wizardReportExport.js b/src/modules/estimateWizard/utils/wizardReportExport.js new file mode 100644 index 0000000..3b3622d --- /dev/null +++ b/src/modules/estimateWizard/utils/wizardReportExport.js @@ -0,0 +1,223 @@ +/** + * wizardReportExport.js — branded customer report PDF (spec §11, §12 customer_reports). + * + * Sections (spec §11): cover, property summary, storm/hail snapshot, Good/Better/Best + * options, AI recommendation, 10-year comparison, selected add-ons, disclaimers + * (verbatim §15 template), next steps. Mirrors the app's jsPDF pattern (estimateExport.js). + * + * The same computed inputs drive the on-screen preview and the PDF so they match + * exactly (spec §18: "report generated by email and print must match the saved version"). + */ +import { pricePackage, addonsTotal, formatUSD } from '../engine/pricing'; +import { computeTenYearValue } from '../engine/valueModel'; + +const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; + +function reportId(sessionId) { + return `RPT-${(sessionId || '').replace(/[^a-zA-Z0-9]/g, '').slice(-8).toUpperCase() || 'PREVIEW'}`; +} + +/** Builds the structured report model — single source of truth for PDF + preview. */ +export function buildReportModel(session, tenantConfig) { + const i = session.inputs; + const value = session.recommendation?.value || computeTenYearValue({ inputs: i, tenantConfig }); + const recommended = session.recommendation?.selectedPackage || session.selectedPackage; + const selected = session.selectedPackage || recommended; + + const selectedPkg = tenantConfig.packages.find((p) => p.type === selected); + const base = selectedPkg ? pricePackage(selectedPkg, tenantConfig.pricing).base : 0; + const addonsSum = addonsTotal(tenantConfig.addons, session.selectedAddons || []); + + return { + reportId: reportId(session.id), + generatedAt: new Date().toISOString(), + branding: tenantConfig.branding, + disclaimer: tenantConfig.disclaimer, + contact: { name: i.name, address: i.address, phone: i.phone, email: i.email }, + property: session.property, + roof: { ageYears: i.roofAgeYears, ownership: i.ownershipHorizon, issue: i.issueType, premium: i.premiumUnknown ? null : i.premiumAmount }, + hail: session.hailSnapshot, + packages: tenantConfig.packages.map((p) => ({ + type: p.type, name: p.name, category: p.category, warranty: p.warrantyLabel, + hailResistance: p.hailResistance, price: pricePackage(p, tenantConfig.pricing), + })), + recommended, + selected, + explanation: session.recommendation?.explanation || null, + reasons: session.recommendation?.reasons || [], + value, + addons: (tenantConfig.addons || []).filter((a) => (session.selectedAddons || []).includes(a.id)), + totals: { base, addonsSum, total: base + addonsSum }, + }; +} + +export async function generateReportPdf(session, tenantConfig) { + const m = buildReportModel(session, tenantConfig); + const { default: jsPDF } = await import('jspdf'); + const { default: autoTable } = await import('jspdf-autotable'); + + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const W = doc.internal.pageSize.getWidth(); + const MARGIN = 14; + const accent = tenantConfig.branding.primaryColor || '#fda913'; + let y = 0; + + const text = (s, x, yy, opts) => doc.text(String(s ?? ''), x, yy, opts); + const ensure = (needed) => { if (y + needed > 285) { doc.addPage(); y = 18; } }; + const heading = (label) => { + ensure(14); + doc.setFont('helvetica', 'bold'); doc.setFontSize(11); + doc.setTextColor(20); text(label, MARGIN, y); y += 2; + doc.setDrawColor(accent); doc.setLineWidth(0.6); doc.line(MARGIN, y, W - MARGIN, y); y += 6; + }; + + // ── Cover band ── + doc.setFillColor(17, 17, 19); doc.rect(0, 0, W, 34, 'F'); + doc.setTextColor(255); doc.setFont('helvetica', 'bold'); doc.setFontSize(18); + text(m.branding.company, MARGIN, 16); + doc.setFont('helvetica', 'normal'); doc.setFontSize(10); + text('Your Roof Options Report', MARGIN, 24); + doc.setFontSize(8); + text(`${m.reportId} · ${new Date(m.generatedAt).toLocaleDateString()}`, MARGIN, 30); + y = 44; + + doc.setTextColor(40); doc.setFont('helvetica', 'bold'); doc.setFontSize(12); + text(`Prepared for ${m.contact.name || 'Homeowner'}`, MARGIN, y); y += 6; + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90); + text(m.contact.address || (m.property?.displayName ?? ''), MARGIN, y); y += 10; + + // ── Property summary ── + heading('Property summary'); + autoTable(doc, { + startY: y, theme: 'plain', margin: { left: MARGIN, right: MARGIN }, + styles: { fontSize: 9, cellPadding: 1.5 }, + body: [ + ['Roof age (estimated)', `${m.roof.ageYears ?? '—'} years`], + ['Planned ownership', m.roof.ownership ? `${m.roof.ownership} years` : '—'], + ['Current concern', m.roof.issue || '—'], + ['Annual insurance premium', m.roof.premium ? formatUSD(m.roof.premium) : 'Not provided'], + ], + columnStyles: { 0: { fontStyle: 'bold', cellWidth: 60, textColor: 60 }, 1: { textColor: 30 } }, + }); + y = doc.lastAutoTable.finalY + 8; + + // ── Storm / hail snapshot ── + heading('Storm / hail risk snapshot (historical)'); + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(60); + if (m.hail) { + const p = m.hail.probability10yr; + const lines = [ + m.hail.wording?.probability || '', + `Annualized rate: ${m.hail.annualRate}/yr · 10-yr range: ${m.hail.eventCount === 0 ? '—' : `${p.low}–${p.high}%`} · Confidence: ${m.hail.confidence}`, + `Source: ${m.hail.source} · ~${m.hail.radiusMiles} mi · ${m.hail.wording?.lastUpdated || ''}`, + ]; + lines.forEach((l) => { ensure(6); doc.splitTextToSize(l, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.6; }); }); + } else { + text('Storm history will be reviewed by your rep during inspection.', MARGIN, y); y += 5; + } + y += 4; + + // ── Good / Better / Best ── + heading('Your Good / Better / Best options'); + autoTable(doc, { + startY: y, margin: { left: MARGIN, right: MARGIN }, + head: [['Option', 'System', 'Warranty', 'Hail', 'Estimated range']], + body: m.packages.map((p) => [ + PKG_LABEL[p.type] + (p.type === m.recommended ? ' ★' : ''), + p.name, p.warranty, p.hailResistance, `${formatUSD(p.price.low)}–${formatUSD(p.price.high)}`, + ]), + styles: { fontSize: 8.5, cellPadding: 2 }, + headStyles: { fillColor: [24, 24, 27], textColor: 255 }, + alternateRowStyles: { fillColor: [248, 248, 248] }, + }); + y = doc.lastAutoTable.finalY + 8; + + // ── AI recommendation ── + heading(`Recommendation: ${PKG_LABEL[m.recommended] || '—'}`); + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(50); + if (m.explanation?.customerExplanation) { + doc.splitTextToSize(m.explanation.customerExplanation, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.8; }); + y += 2; + } + m.reasons.forEach((r) => { + ensure(6); + doc.setTextColor(accent); text('•', MARGIN, y); doc.setTextColor(60); + doc.splitTextToSize(r, W - MARGIN * 2 - 4).forEach((ln, idx) => { ensure(5); text(ln, MARGIN + 4, y); if (idx >= 0) y += 4.6; }); + }); + y += 4; + + // ── 10-year comparison ── + heading('10-year value comparison'); + autoTable(doc, { + startY: y, margin: { left: MARGIN, right: MARGIN }, + head: [['Option', 'Upfront (base)', 'Est. 10-yr savings', 'Service life']], + body: m.value.rows.map((r) => [ + PKG_LABEL[r.type], + formatUSD(r.price.base), + r.savings ? `${formatUSD(r.savings.low)}–${formatUSD(r.savings.high)}` : '—', + `${r.serviceLifeYears}+ yrs`, + ]), + styles: { fontSize: 8.5, cellPadding: 2 }, + headStyles: { fillColor: [24, 24, 27], textColor: 255 }, + }); + y = doc.lastAutoTable.finalY + 6; + doc.setFont('helvetica', 'italic'); doc.setFontSize(7.5); doc.setTextColor(120); + text('Savings shown are illustrative and must be confirmed with your insurance carrier.', MARGIN, y); y += 8; + + // ── Selected add-ons ── + heading('Selected add-ons'); + if (m.addons.length) { + autoTable(doc, { + startY: y, margin: { left: MARGIN, right: MARGIN }, + body: m.addons.map((a) => [a.name, a.value, `+${formatUSD(a.price)}`]), + styles: { fontSize: 8.5, cellPadding: 2 }, + columnStyles: { 1: { textColor: 90 }, 2: { halign: 'right', fontStyle: 'bold' } }, + }); + y = doc.lastAutoTable.finalY + 4; + } else { + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90); + text('No add-ons selected.', MARGIN, y); y += 6; + } + ensure(8); + doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(20); + text(`Estimated total (${PKG_LABEL[m.selected]} + add-ons): ~${formatUSD(m.totals.total)}`, MARGIN, y); y += 10; + + // ── Disclaimers (verbatim) ── + heading('Important disclaimers'); + doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(90); + doc.splitTextToSize(m.disclaimer, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.2; }); + y += 6; + + // ── Next steps ── + heading('Next steps'); + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(50); + ['Book your free inspection to verify measurements and finalize pricing.', + `Call or text us at ${m.branding.phone}.`, + 'Have your insurance documents handy if a hail claim may be relevant.'].forEach((s) => { + ensure(6); doc.setTextColor(accent); text('→', MARGIN, y); doc.setTextColor(50); text(s, MARGIN + 5, y); y += 5.5; + }); + + // Footer on every page + const pages = doc.internal.getNumberOfPages(); + for (let pg = 1; pg <= pages; pg++) { + doc.setPage(pg); + doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(150); + text(`${m.branding.company} · ${m.branding.phone} · ${m.reportId}`, MARGIN, 292); + text(`Page ${pg} of ${pages}`, W - MARGIN, 292, { align: 'right' }); + } + + const filename = `Roof-Options-${(m.contact.name || 'Report').replace(/\s+/g, '-')}-${m.reportId}.pdf`; + return { doc, filename, model: m }; +} + +export async function downloadReportPdf(session, tenantConfig) { + const { doc, filename, model } = await generateReportPdf(session, tenantConfig); + doc.save(filename); + return { filename, reportId: model.reportId }; +} + +export async function getReportBlobUrl(session, tenantConfig) { + const { doc, filename, model } = await generateReportPdf(session, tenantConfig); + const url = doc.output('bloburl'); + return { url, filename, reportId: model.reportId }; +} From 5eb06b2809773ee3c64476fd573cf8d9606102c7 Mon Sep 17 00:00:00 2001 From: Goutam Date: Tue, 9 Jun 2026 21:10:57 +0530 Subject: [PATCH 06/32] feat: integrate Stripe payment module with API + UI components --- .env.example | 7 + api/payments/create-checkout-session.js | 85 ++++++++ api/payments/session-status.js | 55 +++++ api/payments/transactions.js | 27 +++ api/payments/webhook.js | 125 +++++++++++ eslint.config.js | 7 + package.json | 2 + pnpm-lock.yaml | 24 +++ pnpm-workspace.yaml | 3 + src/App.jsx | 26 +++ src/components/Layout.jsx | 3 +- src/modules/payments/README.md | 88 ++++++++ .../payments/components/InvoiceList.jsx | 138 ++++++++++++ .../components/StripeConfigNotice.jsx | 33 +++ .../components/TransactionHistory.jsx | 73 +++++++ src/modules/payments/config/stripeConfig.js | 39 ++++ .../payments/context/PaymentProvider.jsx | 89 ++++++++ src/modules/payments/data/invoices.js | 45 ++++ src/modules/payments/index.js | 13 ++ src/modules/payments/pages/DemoCheckout.jsx | 138 ++++++++++++ .../payments/pages/PaymentCancelled.jsx | 39 ++++ src/modules/payments/pages/PaymentSuccess.jsx | 201 ++++++++++++++++++ src/modules/payments/pages/PaymentsPage.jsx | 81 +++++++ .../payments/services/checkoutService.js | 74 +++++++ vite.config.js | 20 ++ 25 files changed, 1434 insertions(+), 1 deletion(-) create mode 100644 api/payments/create-checkout-session.js create mode 100644 api/payments/session-status.js create mode 100644 api/payments/transactions.js create mode 100644 api/payments/webhook.js create mode 100644 pnpm-workspace.yaml create mode 100644 src/modules/payments/README.md create mode 100644 src/modules/payments/components/InvoiceList.jsx create mode 100644 src/modules/payments/components/StripeConfigNotice.jsx create mode 100644 src/modules/payments/components/TransactionHistory.jsx create mode 100644 src/modules/payments/config/stripeConfig.js create mode 100644 src/modules/payments/context/PaymentProvider.jsx create mode 100644 src/modules/payments/data/invoices.js create mode 100644 src/modules/payments/index.js create mode 100644 src/modules/payments/pages/DemoCheckout.jsx create mode 100644 src/modules/payments/pages/PaymentCancelled.jsx create mode 100644 src/modules/payments/pages/PaymentSuccess.jsx create mode 100644 src/modules/payments/pages/PaymentsPage.jsx create mode 100644 src/modules/payments/services/checkoutService.js diff --git a/.env.example b/.env.example index 1909953..2889016 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,10 @@ VITE_GROQ_API_KEY=Your-API-Key # Weather Widget (OpenWeatherMap) VITE_OPENWEATHER_API_KEY=Your-API-Key + +# --- Payments Module (Stripe Hosted Checkout) --- +# Frontend (safe to expose). Until set, the Payments page shows a "configure" notice. +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_WITH_YOUR_PUBLISHABLE_KEY +# Backend only (Vercel env / .env for `vercel dev`) — NEVER expose these to the client. +STRIPE_SECRET_KEY=sk_test_REPLACE_WITH_YOUR_SECRET_KEY +STRIPE_WEBHOOK_SECRET=whsec_REPLACE_WITH_YOUR_WEBHOOK_SECRET diff --git a/api/payments/create-checkout-session.js b/api/payments/create-checkout-session.js new file mode 100644 index 0000000..d94b3e7 --- /dev/null +++ b/api/payments/create-checkout-session.js @@ -0,0 +1,85 @@ +/** + * Stripe — Create Checkout Session + * -------------------------------- + * Creates a Stripe-hosted Checkout Session and returns its URL so the frontend + * can redirect the customer to Stripe's secure payment page. We never see or + * store card details — that is the whole point of using hosted Checkout. + * + * Flow: Pay Now → POST here → Stripe Session → redirect to session.url → + * Stripe hosted page → success_url / cancel_url back to the app. + * + * Env (set in Vercel project settings, and .env locally for `vercel dev`): + * STRIPE_SECRET_KEY sk_live_… / sk_test_… (server only — never exposed) + * + * If STRIPE_SECRET_KEY is missing we return 503 with a clear message instead of + * crashing, so the module degrades gracefully until credentials are provided. + */ + +// Server-side source of truth for invoice amounts. Prices are resolved HERE, +// never trusted from the client, so a tampered request can't change the amount. +// In production this would be a DB/CRM lookup; mirrors src/modules/payments/data/invoices.js. +const INVOICES = { + 'INV-2041': { title: 'Roof Repair — Leak Detection & Shingle Replacement', amount: 184500, currency: 'usd' }, + 'INV-2039': { title: 'Full Roof Inspection & Storm Damage Report', amount: 32500, currency: 'usd' }, + 'INV-2012': { title: 'Gutter Cleaning & Maintenance Plan (Q1)', amount: 14900, currency: 'usd' }, +}; + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim(); + if (!secretKey) { + return res.status(503).json({ + error: 'Stripe is not configured. Set STRIPE_SECRET_KEY to enable payments.', + code: 'stripe_not_configured', + }); + } + + try { + const { invoiceId, customerEmail } = req.body || {}; + const invoice = INVOICES[invoiceId]; + if (!invoice) { + return res.status(404).json({ error: `Unknown invoice: ${invoiceId}` }); + } + + // Lazy import keeps the module out of the cold-start path when unconfigured. + const Stripe = (await import('stripe')).default; + const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' }); + + // Where Stripe sends the customer back. Derive the app origin from the + // request so this works across local/preview/production without config. + const origin = + req.headers.origin || + (req.headers.host ? `https://${req.headers.host}` : ''); + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + payment_method_types: ['card'], + line_items: [ + { + quantity: 1, + price_data: { + currency: invoice.currency, + unit_amount: invoice.amount, // integer, in cents + product_data: { + name: invoice.title, + metadata: { invoiceId }, + }, + }, + }, + ], + ...(customerEmail ? { customer_email: customerEmail } : {}), + // Echoed back on the session + every webhook event for reconciliation. + metadata: { invoiceId }, + success_url: `${origin}/payments/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/payments/cancel?invoice=${encodeURIComponent(invoiceId)}`, + }); + + return res.status(200).json({ id: session.id, url: session.url }); + } catch (err) { + console.error('[create-checkout-session] error:', err.message); + return res.status(500).json({ error: err.message || 'Failed to create checkout session' }); + } +} diff --git a/api/payments/session-status.js b/api/payments/session-status.js new file mode 100644 index 0000000..b8f7504 --- /dev/null +++ b/api/payments/session-status.js @@ -0,0 +1,55 @@ +/** + * Stripe — Retrieve Checkout Session status + * ----------------------------------------- + * The success return page calls this with the `session_id` Stripe appended to + * the success_url. We retrieve the session server-side (using the secret key) + * and return a trimmed, safe status object the UI can render and record. + * + * Returning here does NOT replace the webhook — the webhook is the authoritative + * record of truth (it fires even if the customer closes the tab). This endpoint + * just gives the returning customer immediate, verified confirmation. + * + * Env: STRIPE_SECRET_KEY + */ + +export default async function handler(req, res) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim(); + if (!secretKey) { + return res.status(503).json({ + error: 'Stripe is not configured.', + code: 'stripe_not_configured', + }); + } + + const sessionId = req.query?.session_id; + if (!sessionId) { + return res.status(400).json({ error: 'session_id is required' }); + } + + try { + const Stripe = (await import('stripe')).default; + const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' }); + + const session = await stripe.checkout.sessions.retrieve(sessionId); + + return res.status(200).json({ + id: session.id, + // status: open | complete | expired + status: session.status, + // payment_status: paid | unpaid | no_payment_required + paymentStatus: session.payment_status, + amountTotal: session.amount_total, + currency: session.currency, + customerEmail: session.customer_details?.email || null, + invoiceId: session.metadata?.invoiceId || null, + created: session.created ? session.created * 1000 : null, + }); + } catch (err) { + console.error('[session-status] error:', err.message); + return res.status(500).json({ error: err.message || 'Failed to retrieve session' }); + } +} diff --git a/api/payments/transactions.js b/api/payments/transactions.js new file mode 100644 index 0000000..8e55368 --- /dev/null +++ b/api/payments/transactions.js @@ -0,0 +1,27 @@ +/** + * Stripe — Transaction history + * ---------------------------- + * Returns the payment records written by the webhook (the server-side source of + * truth). Reads from Vercel KV when connected; returns an empty list otherwise + * so the frontend degrades gracefully and falls back to its local record. + * + * GET /api/payments/transactions -> { transactions: [...] } + */ + +export default async function handler(req, res) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const { kv } = await import('@vercel/kv'); + const raw = await kv.lrange('payments:transactions', 0, 199); + const transactions = (raw || []).map((item) => + typeof item === 'string' ? JSON.parse(item) : item + ); + return res.status(200).json({ transactions }); + } catch { + // KV not configured — the frontend will use its local record instead. + return res.status(200).json({ transactions: [] }); + } +} diff --git a/api/payments/webhook.js b/api/payments/webhook.js new file mode 100644 index 0000000..28eaf64 --- /dev/null +++ b/api/payments/webhook.js @@ -0,0 +1,125 @@ +/** + * Stripe — Webhook receiver + * ------------------------- + * The authoritative record of truth for payments. Stripe POSTs events here; + * we verify the signature, then react to payment lifecycle events. This fires + * server-to-server even if the customer closes their browser, so the + * transaction record must be written here (not only on the success page). + * + * Configure in the Stripe Dashboard → Developers → Webhooks: + * Endpoint URL: https://.vercel.app/api/payments/webhook + * Events: checkout.session.completed, + * checkout.session.async_payment_succeeded, + * checkout.session.async_payment_failed, + * checkout.session.expired + * + * Env: + * STRIPE_SECRET_KEY sk_… (to construct the Stripe client) + * STRIPE_WEBHOOK_SECRET whsec_… (to verify the signature) + * + * Local testing: `stripe listen --forward-to localhost:5173/api/payments/webhook` + * + * Persistence uses Vercel KV when connected (same optional pattern as + * api/hail-webhook.js); without KV it logs the event and ack's 200. + */ + +// Stripe requires the *raw* request body to verify the signature, so the +// default JSON body parser must be disabled for this route. +export const config = { api: { bodyParser: false } }; + +async function readRawBody(req) { + const chunks = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks); +} + +/** Persist a payment record (best-effort; no-op without Vercel KV). */ +async function recordPayment(record) { + try { + const { kv } = await import('@vercel/kv'); + // Keep a capped, newest-first list of transactions. + await kv.lpush('payments:transactions', JSON.stringify(record)); + await kv.ltrim('payments:transactions', 0, 199); + if (record.invoiceId && record.status === 'paid') { + await kv.set(`payments:invoice:${record.invoiceId}`, 'paid'); + } + console.log('[payments/webhook] recorded in KV:', record.id); + } catch { + console.log('[payments/webhook] KV unavailable — event received but not persisted:', record); + } +} + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim(); + const webhookSecret = (process.env.STRIPE_WEBHOOK_SECRET || '').trim(); + if (!secretKey || !webhookSecret) { + return res.status(503).json({ error: 'Stripe webhook is not configured.' }); + } + + let event; + try { + const Stripe = (await import('stripe')).default; + const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' }); + + const rawBody = await readRawBody(req); + const signature = req.headers['stripe-signature']; + event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); + } catch (err) { + // Signature verification failed — reject (could be a forged request). + console.error('[payments/webhook] signature verification failed:', err.message); + return res.status(400).send(`Webhook Error: ${err.message}`); + } + + try { + switch (event.type) { + case 'checkout.session.completed': + case 'checkout.session.async_payment_succeeded': { + const s = event.data.object; + await recordPayment({ + id: s.id, + invoiceId: s.metadata?.invoiceId || null, + amount: s.amount_total, + currency: s.currency, + customerEmail: s.customer_details?.email || null, + status: s.payment_status === 'paid' ? 'paid' : s.payment_status, + eventType: event.type, + createdAt: new Date().toISOString(), + }); + break; + } + case 'checkout.session.async_payment_failed': { + const s = event.data.object; + await recordPayment({ + id: s.id, + invoiceId: s.metadata?.invoiceId || null, + amount: s.amount_total, + currency: s.currency, + status: 'failed', + eventType: event.type, + createdAt: new Date().toISOString(), + }); + break; + } + case 'checkout.session.expired': { + const s = event.data.object; + console.log('[payments/webhook] checkout session expired/cancelled:', s.id); + break; + } + default: + // Unhandled event types are acknowledged so Stripe stops retrying. + console.log('[payments/webhook] unhandled event:', event.type); + } + } catch (err) { + console.error('[payments/webhook] handler error:', err.message); + // Still 200 below if we got a valid event — returning 500 makes Stripe + // retry, which is only desirable for transient persistence failures. + } + + return res.status(200).json({ received: true }); +} diff --git a/eslint.config.js b/eslint.config.js index 4fa125d..5d1987d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,4 +26,11 @@ export default defineConfig([ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], }, }, + { + // Serverless functions and build config run on Node, not the browser. + files: ['api/**/*.js', '**/*.config.js'], + languageOptions: { + globals: { ...globals.node }, + }, + }, ]) diff --git a/package.json b/package.json index 1fc5808..a014f1b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@dnd-kit/utilities": "^3.2.2", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@stripe/stripe-js": "^9.7.0", "@vercel/analytics": "^1.6.1", "autoprefixer": "^10.4.23", "class-variance-authority": "^0.7.1", @@ -36,6 +37,7 @@ "react-router-dom": "^7.13.0", "recharts": "^3.7.0", "sonner": "^2.0.7", + "stripe": "^22.2.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^3.4.1", "three": "^0.182.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f40e551..f6a00ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@react-three/fiber': specifier: ^9.5.0 version: 9.5.0(@types/react@19.2.10)(immer@11.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0) + '@stripe/stripe-js': + specifier: ^9.7.0 + version: 9.7.0 '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(react@19.2.4) @@ -83,6 +86,9 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + stripe: + specifier: ^22.2.0 + version: 22.2.0 tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -686,6 +692,10 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stripe/stripe-js@9.7.0': + resolution: {integrity: sha512-r1ElolvWXM4aYnZZVHvKW3EDL8JcwEuIgTuWxlB5lvC+YsvjkQ0gX35x9d8dTDubX395fViLVqkaolVs1PmIQQ==} + engines: {node: '>=12.16'} + '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} @@ -803,6 +813,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@use-gesture/core@10.3.1': resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} @@ -2055,6 +2066,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@22.2.0: + resolution: {integrity: sha512-WFGpMOom9QZqso1kcnSwJsCdC1QHDlMoCOxBZRf3JraMzhkfw7dgSdD2a1CFZrqC+mzAfqeEtYILrZhWKIDruA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -2801,6 +2821,8 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@stripe/stripe-js@9.7.0': {} + '@tweenjs/tween.js@23.1.3': {} '@types/babel__core@7.20.5': @@ -4286,6 +4308,8 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@22.2.0: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..84b3853 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + core-js: set this to true or false + esbuild: set this to true or false diff --git a/src/App.jsx b/src/App.jsx index 4a48c8b..f13f236 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,7 @@ import Maps from './pages/Maps'; import AdminSchedule from './pages/AdminSchedule'; import CustomerProfile from './pages/CustomerProfile'; import LeaderboardPage from './pages/LeaderboardPage'; +import { PaymentProvider, PaymentsPage, PaymentSuccess, PaymentCancelled, DemoCheckout } from './modules/payments'; import ErrorBoundary from './components/ErrorBoundary'; import SmoothScroll from './components/SmoothScroll'; import { Toaster } from 'sonner'; @@ -90,6 +91,15 @@ function App() { } /> + {/* Payments Module (independent) — Stripe hosted checkout */} + + + + + + } /> + {/* Self-service profile for internal roles */} @@ -432,6 +442,22 @@ function App() { } /> + {/* Stripe hosted-checkout return pages (public, full-screen). + Public on purpose: the round-trip to Stripe drops the in-memory + auth session, so these must render without it. */} + + + + } /> + + + + } /> + {/* Simulated hosted checkout — only used in demo mode (no Stripe keys). */} + } /> + {/* Catch all */} } /> diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 6b74130..bd90f48 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -6,7 +6,7 @@ import { LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare, ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase, FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning, - HardHat, ShieldCheck + HardHat, ShieldCheck, CreditCard } from 'lucide-react'; import PageTransition from './PageTransition'; @@ -210,6 +210,7 @@ const Layout = () => { default: // Customer or Fallback return [ { to: "/customer/profile", icon: LayoutDashboard, label: "Dashboard" }, + { to: "/portal/payments", icon: CreditCard, label: "Payments" }, ...commonItems, ]; } diff --git a/src/modules/payments/README.md b/src/modules/payments/README.md new file mode 100644 index 0000000..be82cca --- /dev/null +++ b/src/modules/payments/README.md @@ -0,0 +1,88 @@ +# Payments Module — Stripe Hosted Checkout + +A **completely independent** payments module using Stripe's official **hosted +Checkout** flow. No custom card forms, no card data ever touches this app — +the customer enters payment details only on Stripe's secure page. + +> This is **real Stripe integration architecture**, not a simulation. It ships +> with credential **placeholders**; the moment real keys are added it goes live. +> Until then, the UI shows a clear "configure Stripe" notice. + +## Flow +``` +Customer → Pay Now + → POST /api/payments/create-checkout-session (amount resolved server-side) + → redirect to Stripe-hosted checkout page (session.url) + → customer pays on stripe.com + → Stripe redirects back: + success_url → /payments/success?session_id=… (verifies + records) + cancel_url → /payments/cancel + → Stripe also POSTs /api/payments/webhook (authoritative record) +``` + +Statuses handled: **success**, **processing/pending** (async methods), +**failed**, and **cancelled**. + +## Structure +``` +api/payments/ # Vercel serverless functions (backend) +├── create-checkout-session.js # creates the Checkout Session, returns session.url +├── session-status.js # verifies a session for the return page +├── webhook.js # signature-verified Stripe event receiver +└── transactions.js # serves webhook-written history (Vercel KV) + +src/modules/payments/ # frontend (self-contained) +├── config/stripeConfig.js # publishable key + isConfigured flag +├── services/checkoutService.js # startCheckout() + getSessionStatus() +├── context/PaymentProvider.jsx # history (server KV ∪ local), invoices +├── data/invoices.js # sample invoices (display only) +├── components/ # InvoiceList, TransactionHistory, ConfigNotice +├── pages/ # PaymentsPage, PaymentSuccess, PaymentCancelled +└── index.js +``` + +## Routes (registered in `src/App.jsx`) +| Path | Access | Purpose | +|---|---|---| +| `/portal/payments` | CUSTOMER | Invoice list + history | +| `/payments/success` | public | Stripe success return (verifies session) | +| `/payments/cancel` | public | Stripe cancel return | + +The return pages are **public** on purpose: the full-page round-trip to Stripe +loses the app's in-memory auth session, so the return pages must render without +it. (With persisted/JWT auth in production this is seamless.) + +## Configuration +Add to your environment (see repo `.env.example`): +``` +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_… # frontend (safe to expose) +STRIPE_SECRET_KEY=sk_test_… # backend only — never expose +STRIPE_WEBHOOK_SECRET=whsec_… # backend only — webhook signature +``` +In Vercel: add `STRIPE_SECRET_KEY` + `STRIPE_WEBHOOK_SECRET` as Project env vars, +and `VITE_STRIPE_PUBLISHABLE_KEY` as a build-time var. + +### Stripe Dashboard → Webhooks +- Endpoint: `https:///api/payments/webhook` +- Events: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, + `checkout.session.async_payment_failed`, `checkout.session.expired` + +### Persistence +The webhook writes records to **Vercel KV** when connected (same optional pattern +as `api/hail-webhook.js`); without KV it logs and the frontend falls back to its +local record of the customer's own completed payments. + +## Local testing +``` +npm i # installs stripe + @stripe/stripe-js +# put test keys in .env, then: +vercel dev # runs the /api functions for real +# webhook: +stripe listen --forward-to localhost:3000/api/payments/webhook +``` +`npm run dev` also bridges `create-checkout-session` and `session-status` via the +existing Vite dev-API plugin (needs test keys in `.env`). + +## Dependencies added +- `@stripe/stripe-js` (frontend redirect fallback) +- `stripe` (backend Node SDK) diff --git a/src/modules/payments/components/InvoiceList.jsx b/src/modules/payments/components/InvoiceList.jsx new file mode 100644 index 0000000..e818c8b --- /dev/null +++ b/src/modules/payments/components/InvoiceList.jsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { FileText, Clock, Lock, Loader2, CheckCircle2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { SpotlightCard } from '../../../components/SpotlightCard'; +import { formatAmount } from '../data/invoices'; +import { stripeConfig } from '../config/stripeConfig'; +import { startCheckout } from '../services/checkoutService'; + +/** + * Lists outstanding invoices with a "Pay Now" button that redirects the + * customer to Stripe's hosted Checkout page. No card details are collected here. + */ +const InvoiceList = ({ invoices, paidIds = [], customerEmail }) => { + const navigate = useNavigate(); + const [redirectingId, setRedirectingId] = useState(null); + + const isPaid = (inv) => inv.status === 'paid' || paidIds.includes(inv.id); + const due = invoices.filter((i) => !isPaid(i)); + const paid = invoices.filter(isPaid); + + const handlePay = async (invoice) => { + if (redirectingId) return; + setRedirectingId(invoice.id); + + // No Stripe keys → use the simulated hosted-checkout page so the demo + // works without credentials. With keys, redirect to real Stripe. + if (!stripeConfig.isConfigured) { + navigate(`/payments/demo-checkout?invoice=${encodeURIComponent(invoice.id)}`); + return; + } + + try { + // On success this navigates away to Stripe; control won't return. + await startCheckout({ invoiceId: invoice.id, customerEmail }); + } catch (err) { + toast.error('Unable to start checkout', { description: err.message }); + setRedirectingId(null); + } + }; + + return ( +
+
+

+ Outstanding Invoices +

+ + {due.length > 0 ? ( +
+ {due.map((inv) => { + const busy = redirectingId === inv.id; + return ( + +
+
+
+ +
+
+
+

{inv.title}

+ + {inv.id} + +
+

+ {inv.description} +

+

+ Due {new Date(inv.dueDate).toLocaleDateString()} · {inv.property} +

+
+
+ +
+ + {formatAmount(inv.amount, inv.currency, stripeConfig.locale)} + + +
+
+
+ ); + })} +
+ ) : ( +
+ You're all caught up — no outstanding invoices. 🎉 +
+ )} +
+ + {paid.length > 0 && ( +
+

+ Paid +

+
+ {paid.map((inv) => ( +
+
+ +
+

{inv.title}

+

{inv.id}

+
+
+ + {formatAmount(inv.amount, inv.currency, stripeConfig.locale)} + +
+ ))} +
+
+ )} + +

+ Payments are processed securely by Stripe. We never see or store your card details. +

+
+ ); +}; + +export default InvoiceList; diff --git a/src/modules/payments/components/StripeConfigNotice.jsx b/src/modules/payments/components/StripeConfigNotice.jsx new file mode 100644 index 0000000..826622d --- /dev/null +++ b/src/modules/payments/components/StripeConfigNotice.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { FlaskConical } from 'lucide-react'; + +/** + * Shown when no real Stripe publishable key is configured. Payments run through + * the simulated checkout (no real charge); adding keys switches to live Stripe. + */ +const StripeConfigNotice = () => ( +
+
+
+ +
+
+

+ Demo mode — simulated Stripe Checkout +

+

+ No Stripe keys are configured, so "Pay Now" runs a simulated checkout + (no real charge). Add the keys below to switch to live Stripe-hosted + Checkout — no code changes needed: +

+
    +
  • VITE_STRIPE_PUBLISHABLE_KEY (frontend)
  • +
  • STRIPE_SECRET_KEY (backend)
  • +
  • STRIPE_WEBHOOK_SECRET (backend / webhook)
  • +
+
+
+
+); + +export default StripeConfigNotice; diff --git a/src/modules/payments/components/TransactionHistory.jsx b/src/modules/payments/components/TransactionHistory.jsx new file mode 100644 index 0000000..5c12178 --- /dev/null +++ b/src/modules/payments/components/TransactionHistory.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Receipt, CreditCard, XCircle } from 'lucide-react'; +import { SpotlightCard } from '../../../components/SpotlightCard'; +import { stripeConfig } from '../config/stripeConfig'; +import { formatAmount } from '../data/invoices'; + +const STATUS_STYLES = { + paid: { + label: 'Paid', + chip: 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300', + icon: CreditCard, + iconWrap: 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-100 dark:border-emerald-500/20', + }, + failed: { + label: 'Failed', + chip: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300', + icon: XCircle, + iconWrap: 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 border-red-100 dark:border-red-500/20', + }, +}; + +/** + * Lists payment records (merged server + local history). Read-only. + */ +const TransactionHistory = ({ transactions }) => { + if (!transactions.length) { + return ( +
+ + No transactions yet. Completed payments will appear here. +
+ ); + } + + return ( +
+ {transactions.map((t) => { + const style = STATUS_STYLES[t.status] || STATUS_STYLES.paid; + const Icon = style.icon; + return ( + +
+
+
+ +
+
+

{t.invoiceId || 'Payment'}

+

+ {t.customerEmail ? `${t.customerEmail} · ` : ''} + {t.createdAt ? new Date(t.createdAt).toLocaleString() : ''} +

+

{t.id}

+
+
+ +
+ + {formatAmount(t.amount, t.currency || stripeConfig.currency, stripeConfig.locale)} + + + {style.label} + +
+
+
+ ); + })} +
+ ); +}; + +export default TransactionHistory; diff --git a/src/modules/payments/config/stripeConfig.js b/src/modules/payments/config/stripeConfig.js new file mode 100644 index 0000000..093d69b --- /dev/null +++ b/src/modules/payments/config/stripeConfig.js @@ -0,0 +1,39 @@ +/** + * Stripe configuration (frontend) for the Payments module. + * + * Only the PUBLISHABLE key lives on the frontend — it is safe to expose. The + * Secret Key and Webhook Secret are backend-only (see /api/payments/*). + * + * Set in `.env` (see .env.example): + * VITE_STRIPE_PUBLISHABLE_KEY=pk_test_… or pk_live_… + * + * Until a real key is provided, `isConfigured` is false and the UI shows a + * clear "configure Stripe" notice rather than attempting a broken redirect. + */ + +const PLACEHOLDER = 'pk_test_REPLACE_WITH_YOUR_PUBLISHABLE_KEY'; + +const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || PLACEHOLDER; + +export const stripeConfig = { + publishableKey, + + // Base path for the payment API endpoints (Vercel serverless functions). + apiBase: '/api/payments', + + // True only when a real publishable key is present. + isConfigured: + typeof publishableKey === 'string' && + publishableKey.startsWith('pk_') && + publishableKey !== PLACEHOLDER, + + currency: 'usd', + locale: 'en-US', + + merchant: { + name: 'LynkedUp Pro Roofing', + supportEmail: 'billing@lynkeduppro.com', + }, +}; + +export default stripeConfig; diff --git a/src/modules/payments/context/PaymentProvider.jsx b/src/modules/payments/context/PaymentProvider.jsx new file mode 100644 index 0000000..c6b4fa2 --- /dev/null +++ b/src/modules/payments/context/PaymentProvider.jsx @@ -0,0 +1,89 @@ +/** + * PaymentProvider — module-local state, fully independent from the app's global + * mockStore so the Payments module can be added/removed in isolation. + * + * Transaction history is sourced from two places and merged by id: + * 1. The server (api/payments/transactions → Vercel KV, written by the + * webhook) — the production source of truth. + * 2. A local record (localStorage) written when a customer returns to the + * success page — gives instant feedback and works without KV in the demo. + */ + +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { INVOICES } from '../data/invoices'; +import { stripeConfig } from '../config/stripeConfig'; + +const STORAGE_KEY = 'lynkeduppro.payments.transactions'; + +const loadLocal = () => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +}; + +const persistLocal = (txns) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(txns)); + } catch { + /* localStorage unavailable (private mode) — keep in-memory only. */ + } +}; + +const mergeById = (a, b) => { + const map = new Map(); + [...a, ...b].forEach((t) => { + if (t && t.id) map.set(t.id, { ...map.get(t.id), ...t }); + }); + return Array.from(map.values()).sort( + (x, y) => new Date(y.createdAt || 0) - new Date(x.createdAt || 0) + ); +}; + +const PaymentContext = createContext(null); + +export const PaymentProvider = ({ children }) => { + const [invoices] = useState(INVOICES); + const [transactions, setTransactions] = useState(loadLocal); + + // Pull the server-side (webhook-written) history and merge it in. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(`${stripeConfig.apiBase}/transactions`); + if (!res.ok) return; + const { transactions: server = [] } = await res.json(); + if (cancelled || !server.length) return; + setTransactions((prev) => mergeById(prev, server)); + } catch { + /* offline / endpoint unavailable — local history is fine. */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + + /** Record a transaction confirmed on the success return page. */ + const recordTransaction = useCallback((txn) => { + if (!txn?.id) return; + setTransactions((prev) => { + const next = mergeById(prev, [txn]); + persistLocal(next); + return next; + }); + }, []); + + const value = { invoices, transactions, recordTransaction }; + + return {children}; +}; + +export const usePayments = () => { + const ctx = useContext(PaymentContext); + if (!ctx) throw new Error('usePayments must be used within a .'); + return ctx; +}; diff --git a/src/modules/payments/data/invoices.js b/src/modules/payments/data/invoices.js new file mode 100644 index 0000000..dcb42c9 --- /dev/null +++ b/src/modules/payments/data/invoices.js @@ -0,0 +1,45 @@ +/** + * Sample invoices for the customer to pay (display only). Amounts are stored in + * cents to match Stripe's API. The SERVER independently re-resolves the amount + * from its own copy (see api/payments/create-checkout-session.js) so the client + * can never alter what is charged — these values are for rendering the list. + * + * In production this list would come from the CRM/billing backend. + */ + +export const INVOICES = [ + { + id: 'INV-2041', + title: 'Roof Repair — Leak Detection & Shingle Replacement', + description: 'Emergency leak repair on north-facing slope, 12 shingles replaced.', + property: '2612 Dunwick Dr, Plano, TX 75023', + dueDate: '2026-06-15', + amount: 184500, + currency: 'usd', + }, + { + id: 'INV-2039', + title: 'Full Roof Inspection & Storm Damage Report', + description: 'Comprehensive drone inspection with insurance-ready documentation.', + property: '2612 Dunwick Dr, Plano, TX 75023', + dueDate: '2026-05-25', + amount: 32500, + currency: 'usd', + }, + { + id: 'INV-2012', + title: 'Gutter Cleaning & Maintenance Plan (Q1)', + description: 'Quarterly gutter clearing and downspout flush.', + property: '2612 Dunwick Dr, Plano, TX 75023', + dueDate: '2026-03-18', + amount: 14900, + currency: 'usd', + }, +]; + +/** Format an integer cent amount into a localized currency string. */ +export const formatAmount = (cents, currency = 'usd', locale = 'en-US') => + new Intl.NumberFormat(locale, { + style: 'currency', + currency: (currency || 'usd').toUpperCase(), + }).format((cents || 0) / 100); diff --git a/src/modules/payments/index.js b/src/modules/payments/index.js new file mode 100644 index 0000000..a5ca7ec --- /dev/null +++ b/src/modules/payments/index.js @@ -0,0 +1,13 @@ +/** + * Payments Module — public surface. + * + * Self-contained Stripe Checkout integration. Import from here so the rest of + * the app never reaches into the module internals. + */ + +export { default as PaymentsPage } from './pages/PaymentsPage'; +export { default as PaymentSuccess } from './pages/PaymentSuccess'; +export { default as PaymentCancelled } from './pages/PaymentCancelled'; +export { default as DemoCheckout } from './pages/DemoCheckout'; +export { PaymentProvider, usePayments } from './context/PaymentProvider'; +export { stripeConfig } from './config/stripeConfig'; diff --git a/src/modules/payments/pages/DemoCheckout.jsx b/src/modules/payments/pages/DemoCheckout.jsx new file mode 100644 index 0000000..510009c --- /dev/null +++ b/src/modules/payments/pages/DemoCheckout.jsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { Lock, Loader2, ArrowLeft, FlaskConical, CheckCircle2 } from 'lucide-react'; +import { INVOICES, formatAmount } from '../data/invoices'; +import { stripeConfig } from '../config/stripeConfig'; + +/** + * Demo-only stand-in for Stripe's hosted Checkout page. Used ONLY when no + * Stripe keys are configured, so the flow (Pay Now → checkout → success → + * history) works for a demo without credentials. It collects no real card data + * and makes no charge. When real keys are added, this page is never reached — + * `startCheckout` redirects to the genuine Stripe-hosted page instead. + * + * Mirrors Stripe Checkout's two-column hosted layout for visual familiarity. + */ +const DemoCheckout = () => { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const invoiceId = params.get('invoice'); + const invoice = INVOICES.find((i) => i.id === invoiceId); + + const [paying, setPaying] = useState(false); + + if (!invoice) { + return ( + +

Invoice not found.

+ navigate('/portal/payments')} /> +
+ ); + } + + const amountLabel = formatAmount(invoice.amount, invoice.currency, stripeConfig.locale); + + const handlePay = () => { + if (paying) return; + setPaying(true); + // Simulate Stripe's processing latency, then return to the success page. + setTimeout(() => { + navigate( + `/payments/success?demo=1&invoice=${encodeURIComponent(invoice.id)}` + + `&amount=${invoice.amount}¤cy=${invoice.currency}` + ); + }, 1600); + }; + + return ( + + {/* Demo banner */} +
+ + Demo mode — simulated Stripe Checkout. No real card or charge. Add Stripe keys to go live. +
+ +
+ {/* Left: order summary (Stripe-style) */} +
+

{stripeConfig.merchant.name}

+

{amountLabel}

+ +
+
+

{invoice.title}

+

{invoice.id}

+
+ {amountLabel} +
+
+ Total due + {amountLabel} +
+
+ + {/* Right: payment (read-only demo — no real card entry) */} +
+

+ Pay with card +

+ +
+ +
+ + +
+
+

+ Test card pre-filled for the demo — just press Pay. +

+ + + +

+ Powered by Stripe (simulated) +

+ + {!paying && navigate('/payments/cancel?invoice=' + invoice.id)} />} +
+
+
+ ); +}; + +const Shell = ({ children }) => ( +
+
+ {children} +
+
+); + +const Row = ({ label, value }) => ( +
+ {label} + {value} +
+); + +const BackButton = ({ onClick }) => ( + +); + +export default DemoCheckout; diff --git a/src/modules/payments/pages/PaymentCancelled.jsx b/src/modules/payments/pages/PaymentCancelled.jsx new file mode 100644 index 0000000..8dca9a2 --- /dev/null +++ b/src/modules/payments/pages/PaymentCancelled.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { Ban, ArrowLeft } from 'lucide-react'; +import { SpotlightCard } from '../../../components/SpotlightCard'; + +/** + * Stripe redirects here (cancel_url) when the customer backs out of the hosted + * Checkout page without paying. Public route — no charge was made. + */ +const PaymentCancelled = () => { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const invoice = params.get('invoice'); + + return ( +
+
+ +
+ +
+

Payment Cancelled

+

+ No charge was made{invoice ? ` for ${invoice}` : ''}. You can complete the + payment whenever you're ready. +

+ +
+
+
+ ); +}; + +export default PaymentCancelled; diff --git a/src/modules/payments/pages/PaymentSuccess.jsx b/src/modules/payments/pages/PaymentSuccess.jsx new file mode 100644 index 0000000..25733bc --- /dev/null +++ b/src/modules/payments/pages/PaymentSuccess.jsx @@ -0,0 +1,201 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { CheckCircle2, XCircle, Loader2, Clock, ArrowLeft, Receipt } from 'lucide-react'; +import { SpotlightCard } from '../../../components/SpotlightCard'; +import { stripeConfig } from '../config/stripeConfig'; +import { formatAmount } from '../data/invoices'; +import { getSessionStatus } from '../services/checkoutService'; +import { usePayments } from '../context/PaymentProvider'; + +/** + * Stripe redirects here (success_url) after the customer leaves the hosted + * Checkout page. We verify the session server-side, then show + record the + * result. This route is PUBLIC so it renders even though the in-memory auth + * session was lost during the full-page round-trip to Stripe. + * + * Handled states: verifying → paid | pending (async) | failed | error. + */ +const PaymentSuccess = () => { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const { recordTransaction } = usePayments(); + const sessionId = params.get('session_id'); + const isDemo = params.get('demo') === '1'; + + // Initialize from the URL so the effect never needs a synchronous setState + // (which would trigger cascading renders). + const [state, setState] = useState(() => + isDemo ? 'paid' : sessionId ? 'verifying' : 'error' + ); // verifying | paid | pending | failed | error + const [details, setDetails] = useState(() => + isDemo + ? { + id: `cs_demo_${Date.now()}`, + invoiceId: params.get('invoice'), + amountTotal: Number(params.get('amount')) || 0, + currency: params.get('currency') || stripeConfig.currency, + customerEmail: null, + } + : null + ); + const [errorMsg, setErrorMsg] = useState(() => + sessionId || isDemo ? '' : 'Missing session reference.' + ); + const recordedRef = useRef(false); + + useEffect(() => { + // Demo mode: no server to verify against — record the simulated payment. + if (isDemo) { + if (!recordedRef.current && details) { + recordedRef.current = true; + recordTransaction({ + id: details.id, + invoiceId: details.invoiceId, + amount: details.amountTotal, + currency: details.currency, + customerEmail: null, + status: 'paid', + createdAt: new Date().toISOString(), + demo: true, + }); + } + return; + } + + if (!sessionId) return; + + let cancelled = false; + (async () => { + try { + const data = await getSessionStatus(sessionId); + if (cancelled) return; + setDetails(data); + + const paid = data.paymentStatus === 'paid' || data.status === 'complete'; + if (paid) { + setState('paid'); + if (!recordedRef.current) { + recordedRef.current = true; + recordTransaction({ + id: data.id, + invoiceId: data.invoiceId, + amount: data.amountTotal, + currency: data.currency, + customerEmail: data.customerEmail, + status: 'paid', + createdAt: new Date().toISOString(), + }); + } + } else if (data.status === 'open') { + // Payment still processing (e.g. async method). + setState('pending'); + } else { + setState('failed'); + } + } catch (err) { + if (cancelled) return; + setState('error'); + setErrorMsg(err.message); + } + })(); + + return () => { + cancelled = true; + }; + }, [sessionId, isDemo, details, recordTransaction]); + + return ( +
+
+ + {state === 'verifying' && ( + } + title="Confirming your payment…" + subtitle="Verifying with Stripe, one moment." + /> + )} + + {state === 'pending' && ( + } + title="Payment processing" + subtitle="Your payment is being processed. We'll update your history once it settles." + /> + )} + + {state === 'paid' && details && ( + <> + } + title="Payment Successful" + subtitle={`${formatAmount(details.amountTotal, details.currency, stripeConfig.locale)} paid to ${stripeConfig.merchant.name}.`} + /> + + + )} + + {state === 'failed' && ( + } + title="Payment not completed" + subtitle="This payment didn't go through. You can try again from your invoices." + /> + )} + + {state === 'error' && ( + } + title="Couldn't verify payment" + subtitle={errorMsg || 'Please check your transaction history in a moment.'} + /> + )} + + + +
+
+ ); +}; + +const Body = ({ icon, title, subtitle }) => ( + <> +
+ {icon} +
+

{title}

+

{subtitle}

+ +); + +const Receipt2 = ({ details }) => { + const rows = [ + { label: 'Invoice', value: details.invoiceId || '—' }, + { label: 'Email', value: details.customerEmail || '—' }, + { label: 'Session', value: details.id, mono: true }, + ]; + return ( +
+
+ Receipt +
+ {rows.map((r) => ( +
+ + {r.label} + + + {r.value} + +
+ ))} +
+ ); +}; + +export default PaymentSuccess; diff --git a/src/modules/payments/pages/PaymentsPage.jsx b/src/modules/payments/pages/PaymentsPage.jsx new file mode 100644 index 0000000..412d1d4 --- /dev/null +++ b/src/modules/payments/pages/PaymentsPage.jsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { CreditCard } from 'lucide-react'; +import { useAuth } from '../../../context/AuthContext'; +import { usePayments } from '../context/PaymentProvider'; +import { stripeConfig } from '../config/stripeConfig'; +import InvoiceList from '../components/InvoiceList'; +import TransactionHistory from '../components/TransactionHistory'; +import StripeConfigNotice from '../components/StripeConfigNotice'; + +const TabButton = ({ id, label, activeTab, onSelect }) => ( + +); + +/** + * Payments module entry point (customer portal). Lists invoices to pay via + * Stripe-hosted Checkout and shows transaction history. Shares no state with + * the rest of the app beyond reading the logged-in user's email. + */ +const PaymentsPage = () => { + const { user } = useAuth(); + const { invoices, transactions } = usePayments(); + const [activeTab, setActiveTab] = useState('pay'); + + // Invoices marked paid by the (server) transaction history. + const paidIds = transactions + .filter((t) => t.status === 'paid' && t.invoiceId) + .map((t) => t.invoiceId); + + return ( +
+
+ {/* Header */} +
+
+ +
+
+

+ Payments +

+

+ Pay invoices securely via Stripe and review past transactions. +

+
+
+ + {!stripeConfig.isConfigured && } + + {/* Tabs */} +
+ + +
+ + {/* Content */} +
+ {activeTab === 'pay' ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default PaymentsPage; diff --git a/src/modules/payments/services/checkoutService.js b/src/modules/payments/services/checkoutService.js new file mode 100644 index 0000000..a17a723 --- /dev/null +++ b/src/modules/payments/services/checkoutService.js @@ -0,0 +1,74 @@ +/** + * Checkout service — the only place the frontend talks to Stripe / the payment + * API. The UI calls these functions; it never handles card data. + * + * `startCheckout` follows Stripe's recommended hosted-checkout flow: + * 1. Ask our backend to create a Checkout Session (amount resolved server-side). + * 2. Redirect the browser to the Stripe-hosted `session.url`. + * 3. Stripe handles payment, then redirects back to success_url / cancel_url. + * + * A Stripe.js `redirectToCheckout({ sessionId })` fallback (which uses the + * publishable key) is kept for older API behavior, but `session.url` is the + * current default and avoids loading Stripe.js for the redirect. + */ + +import { loadStripe } from '@stripe/stripe-js'; +import { stripeConfig } from '../config/stripeConfig'; + +let stripePromise; +const getStripe = () => { + if (!stripePromise) stripePromise = loadStripe(stripeConfig.publishableKey); + return stripePromise; +}; + +/** + * Create a session for an invoice and redirect to Stripe's hosted page. + * Throws (with a friendly message) if the backend isn't configured/reachable. + */ +export async function startCheckout({ invoiceId, customerEmail } = {}) { + if (!stripeConfig.isConfigured) { + const err = new Error( + 'Stripe is not configured yet. Add your publishable key to enable payments.' + ); + err.code = 'stripe_not_configured'; + throw err; + } + + const res = await fetch(`${stripeConfig.apiBase}/create-checkout-session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invoiceId, customerEmail }), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || 'Could not start checkout. Please try again.'); + } + + // Preferred: redirect straight to the Stripe-hosted Checkout URL. + if (data.url) { + window.location.assign(data.url); + return; + } + + // Fallback: Stripe.js redirect using the session id + publishable key. + const stripe = await getStripe(); + if (!stripe) throw new Error('Unable to initialize Stripe.'); + const { error } = await stripe.redirectToCheckout({ sessionId: data.id }); + if (error) throw new Error(error.message); +} + +/** + * Fetch the verified status of a completed Checkout Session (used by the + * success return page). Returns the trimmed status object from the backend. + */ +export async function getSessionStatus(sessionId) { + const res = await fetch( + `${stripeConfig.apiBase}/session-status?session_id=${encodeURIComponent(sessionId)}` + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || 'Could not verify payment status.'); + } + return data; +} diff --git a/vite.config.js b/vite.config.js index 3b60404..8f55add 100644 --- a/vite.config.js +++ b/vite.config.js @@ -22,6 +22,11 @@ const DEV_API_HANDLERS = { 'hail-proxy': () => import('./api/hail-proxy.js'), 'storm-events': () => import('./api/storm-events.js'), 'storm-polygons': () => import('./api/storm-polygons.js'), + // Payments module (needs real Stripe test keys in .env to function locally). + // The webhook is intentionally excluded — it needs the raw body + Stripe CLI. + 'payments/create-checkout-session': () => import('./api/payments/create-checkout-session.js'), + 'payments/session-status': () => import('./api/payments/session-status.js'), + 'payments/transactions': () => import('./api/payments/transactions.js'), } // Server-side env vars the handlers read (mirrors what you'd set in Vercel). @@ -30,6 +35,8 @@ const SERVER_ENV_KEYS = [ 'HAIL_RECON_ACCESS_KEY', 'HAIL_RECON_ACCESS_SECRET', 'HAIL_RECON_WEBHOOK_SECRET', + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', ] function devApi(env) { @@ -62,6 +69,19 @@ function devApi(env) { res.end(JSON.stringify(obj)) return res } + res.send = (body) => { res.end(typeof body === 'string' ? body : JSON.stringify(body)); return res } + + // Parse a JSON body for POST/PUT/PATCH (Vercel does this automatically). + if (['POST', 'PUT', 'PATCH'].includes(req.method)) { + req.body = await new Promise((resolve) => { + let data = '' + req.on('data', (c) => { data += c }) + req.on('end', () => { + try { resolve(data ? JSON.parse(data) : {}) } catch { resolve({}) } + }) + req.on('error', () => resolve({})) + }) + } try { const mod = await load() From 5cf34da58bd1cf74f424ffea59d85a14c4aed960 Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Tue, 9 Jun 2026 22:43:01 +0530 Subject: [PATCH 07/32] new lead created by estimate wizard now list in leads page --- src/data/mockStore.jsx | 42 +++++++++- .../estimateWizard/CustomerEstimateWizard.jsx | 4 +- .../estimateWizard/EstimateWizardContext.jsx | 34 ++++++++ src/modules/estimateWizard/api/crmHandoff.js | 82 +++++++++++++++++++ .../components/steps/ReportStep.jsx | 28 +++++-- .../components/steps/StartStep.jsx | 12 ++- 6 files changed, 191 insertions(+), 11 deletions(-) diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 57c5c69..095922b 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -9140,6 +9140,31 @@ const DEFAULT_EMPTY_PROFILE = { const MockStoreContext = createContext(); +// --------------------------------------------------------------------------- +// Estimate Wizard → CRM lead persistence. +// The public DIY Estimate Wizard runs in a separate session from the authed CRM, +// so its leads must survive a reload. They're persisted to localStorage and +// rehydrated into the leads list on store init, deduped by wizard session id. +// --------------------------------------------------------------------------- +const WIZARD_LEADS_KEY = 'lup_estimate_wizard_leads_v1'; + +function loadWizardLeads() { + try { + const raw = localStorage.getItem(WIZARD_LEADS_KEY); + const list = raw ? JSON.parse(raw) : []; + return Array.isArray(list) ? list : []; + } catch { return []; } +} + +function persistWizardLead(lead) { + try { + const list = loadWizardLeads(); + const sid = lead?.estimateWizard?.sessionId; + if (sid && list.some(l => l?.estimateWizard?.sessionId === sid)) return; // already saved + localStorage.setItem(WIZARD_LEADS_KEY, JSON.stringify([lead, ...list])); + } catch { /* quota */ } +} + export const MockStoreProvider = ({ children }) => { const [properties, setProperties] = useState([]); const [users, setUsers] = useState(MOCK_USERS); @@ -9154,7 +9179,7 @@ export const MockStoreProvider = ({ children }) => { const [projects, setProjects] = useState(MOCK_PROJECTS); const [orders, setOrders] = useState(MOCK_ORDERS); const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES); - const [leads, setLeads] = useState(() => seedLeadsFromAttribution()); + const [leads, setLeads] = useState(() => [...loadWizardLeads(), ...seedLeadsFromAttribution()]); const [dispatchLeads, setDispatchLeads] = useState(DISPATCH_LEADS_INITIAL); const [templates, setTemplates] = useState(MASTER_TEMPLATES_INITIAL); const [estimates, setEstimates] = useState(MOCK_ESTIMATES_INITIAL); @@ -9979,6 +10004,21 @@ export const MockStoreProvider = ({ children }) => { return newLead; }, + // Lead created by the public Estimate Wizard. Persisted to localStorage + // (survives reload) and deduped by wizard session id. + addWizardLead: (lead) => { + const created = { + ...lead, + id: lead.id || `lead_wiz_${Date.now()}`, + createdAt: lead.createdAt || new Date().toISOString(), + status: 'New', + }; + const sid = created.estimateWizard?.sessionId; + setLeads(prev => (sid && prev.some(l => l?.estimateWizard?.sessionId === sid)) ? prev : [created, ...prev]); + persistWizardLead(created); + return created; + }, + // Templates templates, addTemplate: (tpl) => { diff --git a/src/modules/estimateWizard/CustomerEstimateWizard.jsx b/src/modules/estimateWizard/CustomerEstimateWizard.jsx index 24c3132..dd98808 100644 --- a/src/modules/estimateWizard/CustomerEstimateWizard.jsx +++ b/src/modules/estimateWizard/CustomerEstimateWizard.jsx @@ -19,8 +19,8 @@ const CompletionScreen = () => { Thanks{inputs.name ? `, ${inputs.name.split(' ')[0]}` : ''}!

- Your roof options report is being prepared. The branded report, AI recommendation, and - booking step are delivered in Phase 5. + Your roof options report is ready and your details have been sent to our team — + a rep will reach out to confirm your free inspection.

+ Date: Tue, 9 Jun 2026 23:10:59 +0530 Subject: [PATCH 08/32] package content fully tenant-editable and versioned in the admin --- .../estimateWizard/EstimateWizardAdmin.jsx | 130 ++++++++++++++---- .../estimateWizard/data/tenantConfig.js | 28 ++++ 2 files changed, 134 insertions(+), 24 deletions(-) diff --git a/src/modules/estimateWizard/EstimateWizardAdmin.jsx b/src/modules/estimateWizard/EstimateWizardAdmin.jsx index d68dba5..e0825e5 100644 --- a/src/modules/estimateWizard/EstimateWizardAdmin.jsx +++ b/src/modules/estimateWizard/EstimateWizardAdmin.jsx @@ -8,9 +8,9 @@ */ import React, { useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { Save, RotateCcw, Power, Sparkles, ShieldCheck, BarChart3 } from 'lucide-react'; +import { Save, RotateCcw, Power, Sparkles, ShieldCheck, BarChart3, Package, History } from 'lucide-react'; import { - getTenantConfig, loadConfigOverrides, saveConfigOverrides, resetConfigOverrides, + getTenantConfig, loadConfigOverrides, publishConfigOverrides, resetConfigOverrides, } from './data/tenantConfig'; import { loadSubmissions } from './api/crmHandoff'; @@ -31,9 +31,16 @@ const Toggle = ({ label, desc, checked, onChange, icon: Icon }) => ( const numInput = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500'; +const textInput = numInput; +const areaInput = numInput + ' min-h-[64px] resize-y leading-relaxed'; + const EstimateWizardAdmin = () => { const initial = useMemo(() => getTenantConfig(), []); const [cfg, setCfg] = useState(initial); + const [versionInfo, setVersionInfo] = useState(() => { + const o = loadConfigOverrides(); + return { version: initial.version, versionedAt: o.versionedAt || null, history: o.versionHistory || [] }; + }); const submissions = useMemo(() => loadSubmissions(), []); const analytics = useMemo(() => { @@ -49,6 +56,8 @@ const EstimateWizardAdmin = () => { ...c, packages: c.packages.map((p) => (p.type === type ? { ...p, [field]: value } : p)), })); + // Included scope is edited as one item per line. + const setScope = (type, text) => setPkg(type, 'scope', text.split('\n').map((s) => s.trim()).filter(Boolean)); const setDiscount = (type, value) => setCfg((c) => ({ ...c, discountScenarios: { ...c.discountScenarios, [type]: { ...c.discountScenarios[type], base: value } }, @@ -64,17 +73,31 @@ const EstimateWizardAdmin = () => { conservativeMode: cfg.conservativeMode, branding: cfg.branding, discountScenarios: cfg.discountScenarios, + // Full Good/Better/Best content is tenant-controlled (spec §4/§14). packages: cfg.packages.map((p) => ({ - type: p.type, pricePerSquare: p.pricePerSquare, serviceLifeYears: p.serviceLifeYears, + type: p.type, + name: p.name, + category: p.category, + warrantyLabel: p.warrantyLabel, + hailResistance: p.hailResistance, + position: p.position, + fit: p.fit, + scope: p.scope, + pricePerSquare: p.pricePerSquare, + serviceLifeYears: p.serviceLifeYears, })), }; - saveConfigOverrides(overrides); - toast.success('Estimate Wizard settings saved'); + const saved = publishConfigOverrides(overrides); // bumps version + history + setVersionInfo({ version: saved.version, versionedAt: saved.versionedAt, history: saved.versionHistory }); + setCfg((c) => ({ ...c, version: saved.version })); + toast.success(`Published ${saved.version}`); }; const handleReset = () => { resetConfigOverrides(); - setCfg(getTenantConfig()); + const fresh = getTenantConfig(); + setCfg(fresh); + setVersionInfo({ version: fresh.version, versionedAt: null, history: [] }); toast.message('Reset to defaults'); }; @@ -88,15 +111,25 @@ const EstimateWizardAdmin = () => { Estimate Wizard — Admin

- Tenant controls for pricing, discounts, AI, branding, and analytics. + Tenant controls for package content, pricing, discounts, AI, branding, and analytics.

+
+ + {versionInfo.version} + + {versionInfo.versionedAt && ( + + published {new Date(versionInfo.versionedAt).toLocaleString()} + + )} +
@@ -108,25 +141,52 @@ const EstimateWizardAdmin = () => { setFlag('conservativeMode', v)} />
- {/* Package pricing */} + {/* Good / Better / Best content — fully tenant-controlled (spec §4/§14) */}
-

Packages, pricing & discounts

-
+

+ Good / Better / Best content +

+

+ Every customer-facing field is editable here — nothing is hard-coded. Saving publishes a new version. +

+
{cfg.packages.map((p) => ( -
-
-
{PKG_LABEL[p.type]}
-
{p.name}
+
+
{PKG_LABEL[p.type]}
+
+ + + + + +