estimate module setup and wizard ui

This commit is contained in:
Mayur Shinde
2026-06-09 14:47:25 +05:30
parent 944a745892
commit 716d096fc8
14 changed files with 1013 additions and 3 deletions
@@ -0,0 +1,51 @@
/**
* ChoiceCards — single-select card group (spec §5: cards with 23 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 (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{choices.map((choice) => {
const selected = value === choice.value;
const Icon = choice.icon ? Icons[choice.icon] : null;
return (
<button
key={choice.value}
type="button"
onClick={() => onChange(choice.value)}
aria-pressed={selected}
className={`
group relative text-left rounded-2xl border p-4 transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-amber-500
${selected
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 shadow-md shadow-amber-500/10'
: 'border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 hover:border-amber-400 hover:-translate-y-0.5'
}
`}
>
{Icon && (
<Icon
size={22}
className={`mb-2 ${selected ? 'text-amber-500' : 'text-zinc-400 group-hover:text-amber-500'}`}
/>
)}
<div className={`font-semibold ${selected ? 'text-amber-700 dark:text-amber-300' : 'text-zinc-900 dark:text-white'}`}>
{choice.label}
</div>
{choice.desc && (
<div className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{choice.desc}</div>
)}
{selected && (
<Icons.CheckCircle2 size={18} className="absolute top-3 right-3 text-amber-500" />
)}
</button>
);
})}
</div>
);
};
export default ChoiceCards;
@@ -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 (
<div className="w-full">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold tracking-wider uppercase text-zinc-400">
Step {stepIndex + 1} of {totalSteps}
</span>
<span className="text-xs font-semibold text-amber-500">{pct}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-zinc-200 dark:bg-zinc-800 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
};
export default WizardProgress;
@@ -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) => <PlaceholderStep stepId="hailRisk" {...props} />,
options: (props) => <PlaceholderStep stepId="options" {...props} />,
addons: (props) => <PlaceholderStep stepId="addons" {...props} />,
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
report: (props) => <PlaceholderStep stepId="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 (
<div className="space-y-6 pt-2">
<div className="text-center">
<span className="text-5xl font-bold text-amber-500">{val}</span>
<span className="text-lg text-zinc-400 ml-1">
{val >= step.max ? `+ ${step.unit}` : step.unit}
</span>
</div>
<input
type="range"
min={step.min}
max={step.max}
step={step.step}
value={val}
onChange={(e) => setInput(step.inputKey, Number(e.target.value))}
className="w-full accent-amber-500"
/>
<div className="flex justify-between text-xs text-zinc-400">
<span>{step.min} {step.unit}</span>
<span>{Math.round((step.min + step.max) / 2)} {step.unit}</span>
<span>{step.maxLabel}</span>
</div>
</div>
);
};
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 (
<ChoiceCards
choices={step.choices}
value={inputs[step.inputKey]}
onChange={(v) => setInput(step.inputKey, v)}
/>
);
}
if (step.type === 'slider') return <SliderStep step={step} />;
const Comp = STEP_COMPONENTS[step.id];
return Comp ? <Comp /> : null;
};
const handleContinue = () => {
if (isLast) { complete(); return; }
next();
};
return (
<div className="min-h-screen w-full bg-zinc-50 dark:bg-[#09090b] flex flex-col">
{/* Top bar */}
<header className="w-full border-b border-zinc-200 dark:border-white/5 bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md">
<div className="max-w-2xl mx-auto px-4 py-4">
<div className="flex items-center justify-between mb-3">
<span className="font-bold tracking-tight text-zinc-900 dark:text-white">
LynkedUp<span className="text-amber-500">Pro</span>
<span className="ml-2 text-xs font-medium text-zinc-400">Roof Options</span>
</span>
<button
onClick={reset}
title="Start over"
className="text-xs text-zinc-400 hover:text-amber-500 flex items-center gap-1"
>
<RotateCcw size={13} /> Start over
</button>
</div>
<WizardProgress stepIndex={stepIndex} totalSteps={totalSteps} />
</div>
</header>
{/* Resume hint */}
{resumed && session.status !== 'completed' && stepIndex === 0 && (
<div className="max-w-2xl mx-auto w-full px-4 pt-3">
<div className="rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
Welcome back we saved your progress. You can continue or start over.
</div>
</div>
)}
{/* Body */}
<main className="flex-1 w-full">
<div className="max-w-2xl mx-auto px-4 py-8">
{/* key forces remount per step → re-triggers the enter animation */}
<div key={step.id} className="animate-in fade-in slide-in-from-right-4 duration-300">
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-white tracking-tight">
{step.title}
</h1>
{step.subtitle && (
<p className="text-zinc-500 dark:text-zinc-400 mt-1.5 mb-6">{step.subtitle}</p>
)}
<div className="mt-6">{renderBody()}</div>
</div>
</div>
</main>
{/* Footer nav — one primary CTA */}
<footer className="sticky bottom-0 w-full border-t border-zinc-200 dark:border-white/5 bg-white/80 dark:bg-zinc-900/70 backdrop-blur-md">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center gap-3">
{stepIndex > 0 && (
<button
onClick={back}
className="rounded-xl border border-zinc-200 dark:border-white/10 px-4 py-2.5 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:border-amber-400 flex items-center gap-1.5"
>
<ArrowLeft size={16} /> Back
</button>
)}
{step.skippable && !isLast && (
<button
onClick={next}
className="text-sm font-medium text-zinc-400 hover:text-amber-500 px-2"
>
Skip
</button>
)}
<button
onClick={handleContinue}
disabled={!advance}
className={`ml-auto rounded-xl px-6 py-2.5 text-sm font-semibold flex items-center gap-1.5 transition-all
${advance
? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25 hover:shadow-amber-500/40'
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-400 cursor-not-allowed'}`}
>
{isLast ? 'Finish' : stepIndex === 0 ? 'Start estimate' : 'Continue'}
{!isLast && <ArrowRight size={16} />}
</button>
</div>
</footer>
</div>
);
};
export default WizardShell;
@@ -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 (
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
<MapPin size={15} className="text-amber-500" /> Confirm your property address
</span>
<input
className="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"
placeholder="Street, city, state, ZIP"
value={inputs.address}
onChange={(e) => setInput('address', e.target.value)}
autoComplete="street-address"
/>
</label>
{/* Map preview placeholder — Leaflet + geocode wired in Phase 2 */}
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-6 flex flex-col items-center justify-center text-center">
<MapIcon size={28} className="text-zinc-400 mb-2" />
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Well confirm the location on a map and pull your areas historical storm data in the next step.
</p>
</div>
<p className="text-xs text-zinc-400">
Final roof measurements are validated during your inspection nothing here is binding.
</p>
</div>
);
};
export default AddressStep;
@@ -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 }) => (
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-8 flex flex-col items-center justify-center text-center">
<Hammer size={30} className="text-amber-500 mb-3" />
<p className="font-semibold text-zinc-700 dark:text-zinc-200">Coming next</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
{PHASE_BY_STEP[stepId] || 'This screen is delivered in a later phase.'}
</p>
<p className="text-xs text-zinc-400 mt-3">
The journey, autosave, and progress are fully wired Continue to walk the full flow.
</p>
</div>
);
export default PlaceholderStep;
@@ -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 (
<div className="space-y-4">
<div className={`transition-opacity ${unknown ? 'opacity-40 pointer-events-none' : ''}`}>
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
<DollarSign size={15} className="text-amber-500" /> Approximate annual premium
</span>
<div className="relative">
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400">$</span>
<input
type="number"
min="0"
inputMode="numeric"
className="w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 pl-7 pr-3.5 py-2.5 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500"
placeholder="e.g. 4,000"
value={inputs.premiumAmount ?? ''}
onChange={(e) => setInput('premiumAmount', e.target.value === '' ? null : Number(e.target.value))}
/>
</div>
</div>
<button
type="button"
onClick={() => {
const nextUnknown = !unknown;
setInput('premiumUnknown', nextUnknown);
if (nextUnknown) setInput('premiumAmount', null);
}}
className={`w-full rounded-xl border px-3 py-2.5 text-sm font-medium transition-all
${unknown
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-amber-400'}`}
>
I dont know my premium
</button>
<p className="text-xs text-zinc-400">
This is only used to illustrate a potential savings scenario. Any savings must be confirmed with your
insurance carrier we never guarantee a discount.
</p>
</div>
);
};
export default PremiumStep;
@@ -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 }) => (
<label className="block">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
{Icon && <Icon size={15} className="text-amber-500" />} {label}
</span>
{children}
</label>
);
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 (
<div className="space-y-4">
<Field icon={User} label="Name">
<input
className={inputCls}
placeholder="First and last name"
value={inputs.name}
onChange={(e) => setInput('name', e.target.value)}
autoComplete="name"
/>
</Field>
<Field icon={MapPin} label="Property address">
<input
className={inputCls}
placeholder="Street, city, state, ZIP"
value={inputs.address}
onChange={(e) => setInput('address', e.target.value)}
autoComplete="street-address"
/>
</Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field icon={Phone} label="Mobile number">
<input
className={inputCls}
placeholder="(555) 123-4567"
value={inputs.phone}
onChange={(e) => setInput('phone', e.target.value)}
autoComplete="tel"
inputMode="tel"
/>
</Field>
<Field icon={Mail} label="Email">
<input
className={inputCls}
placeholder="you@email.com"
value={inputs.email}
onChange={(e) => setInput('email', e.target.value)}
autoComplete="email"
inputMode="email"
/>
</Field>
</div>
<div>
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 block">Best contact method?</span>
<div className="flex gap-2">
{['text', 'email', 'phone'].map((m) => (
<button
key={m}
type="button"
onClick={() => setInput('contactMethod', m)}
className={`flex-1 rounded-xl border px-3 py-2 text-sm font-medium capitalize transition-all
${inputs.contactMethod === m
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-amber-400'}`}
>
{m}
</button>
))}
</div>
</div>
<label className="flex items-start gap-2.5 rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 accent-amber-500"
checked={inputs.consent}
onChange={(e) => setInput('consent', e.target.checked)}
/>
<span className="text-xs text-zinc-600 dark:text-zinc-400 leading-relaxed">
I agree to be contacted about my roof estimate and understand my address and any insurance
details I provide are used to generate this educational report. This is not a binding quote.
</span>
</label>
</div>
);
};
export default StartStep;