estimate module setup and wizard ui
This commit is contained in:
@@ -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 (
|
||||
<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">
|
||||
We’ll confirm the location on a map and pull your area’s 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 don’t 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;
|
||||
Reference in New Issue
Block a user