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')}`; +}