From 716d096fc855dfac25321b7b1f29518758d51c0d Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Tue, 9 Jun 2026 14:47:25 +0530 Subject: [PATCH] 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 + }; +}