new lead created by estimate wizard now list in leads page

This commit is contained in:
Mayur Shinde
2026-06-09 22:43:01 +05:30
parent 075d2dd60f
commit 5cf34da58b
6 changed files with 191 additions and 11 deletions
@@ -19,8 +19,8 @@ const CompletionScreen = () => {
Thanks{inputs.name ? `, ${inputs.name.split(' ')[0]}` : ''}!
</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-2">
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.
</p>
<button
onClick={reset}
@@ -17,6 +17,28 @@ import { getTenantConfig } from './data/tenantConfig';
const STORAGE_KEY = 'lup_estimate_wizard_session_v1';
// One-click demo seed (spec demo support). Every value matches a valid choice in
// questions.js so the wizard renders selected cards correctly. Address is a real
// Plano, TX location so the Nominatim geocoder + storm-history lookup resolve.
export const DEMO_INPUTS = {
name: 'Jordan Sample',
address: '2700 W 15th St, Plano, TX 75075',
phone: '(972) 555-0142',
email: 'jordan.sample@example.com',
contactMethod: 'text',
consent: true,
roofAgeYears: 14,
issueType: 'storm',
ownershipHorizon: '16+',
premiumAmount: 2400,
premiumUnknown: false,
budgetPreference: 'balanced',
hoaConstraints: 'no',
atticConcern: 'energy_bills',
pestConcern: 'no',
financingInterest: 'maybe',
};
const EstimateWizardContext = createContext(null);
// A stable-ish id without relying on Date.now/Math.random being available everywhere.
@@ -125,6 +147,17 @@ export function EstimateWizardProvider({ children, source = 'public' }) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)); } catch { /* quota */ }
}, [source]);
// Fill every input with a valid demo answer so the wizard can be exercised fast.
const prefillDemo = useCallback(() => {
setSession(s => ({
...s,
inputs: { ...s.inputs, ...DEMO_INPUTS },
property: null, // force a fresh geocode on the address step
hailSnapshot: null,
events: [...s.events, { eventType: 'demo_prefill', payload: {}, occurredAt: new Date().toISOString() }],
}));
}, []);
const value = {
session,
inputs: session.inputs,
@@ -141,6 +174,7 @@ export function EstimateWizardProvider({ children, source = 'public' }) {
back,
complete,
reset,
prefillDemo,
};
return (
@@ -9,6 +9,87 @@
const SUBMISSIONS_KEY = 'lup_estimate_wizard_submissions_v1';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
// Parse a free-typed "Street, City, ST 12345" string into CRM lead fields.
function parseAddress(raw) {
const address = (raw || '').trim();
const zip = (address.match(/\b(\d{5})(?:-\d{4})?\b/) || [])[1] || '';
const stateM = address.match(/,\s*([A-Z]{2})\b/) || address.match(/\b([A-Z]{2})\b(?=\s+\d{5})/);
const state = stateM ? stateM[1] : 'TX';
const cityM = address.match(/,\s*([^,]+?)\s*,\s*[A-Z]{2}\b/);
const city = cityM ? cityM[1].trim() : 'Plano';
return { address, city, state, zip };
}
/**
* Map a completed wizard session → the global CRM mock store lead shape, so the
* estimate shows up as a real lead. The caller passes this to MockStore.addLead()
* (addLead injects id / createdAt / status). Pure — no React, no side effects.
*/
export function buildLeadFromSession(session, tenantConfig, reportMeta) {
const i = session.inputs;
const { address, city, state, zip } = parseAddress(i.address);
const nameParts = (i.name || '').trim().split(/\s+/);
const firstName = nameParts[0] || 'Homeowner';
const lastName = nameParts.slice(1).join(' ');
const tier = session.hailSnapshot?.exposureTier || null;
const urgency = tier === 'high' ? 'high' : 'standard';
const priority = tier === 'high' ? 'high' : tier === 'moderate' ? 'medium' : 'low';
const rec = session.recommendation?.selectedPackage;
const sel = session.selectedPackage;
const noteParts = [
'DIY Estimate Wizard self-serve estimate.',
rec ? `Recommended: ${PKG_LABEL[rec] || rec}.` : '',
sel ? `Customer selected: ${PKG_LABEL[sel] || sel}.` : '',
tier ? `Historical hail exposure: ${tier}.` : '',
session.selectedAddons?.length ? `Add-ons: ${session.selectedAddons.join(', ')}.` : '',
reportMeta?.reportId ? `Report ${reportMeta.reportId}.` : '',
].filter(Boolean);
return {
firstName,
lastName,
address,
city,
state,
zip,
lat: session.property?.lat ?? null,
lng: session.property?.lon ?? null,
urgency,
priority,
leadSource: 'Estimate Wizard',
phones: i.phone ? [{ id: '1', number: i.phone, type: 'Mobile', isPrimary: true }] : [],
emails: i.email ? [{ email: i.email, isPrimary: true }] : [],
contactMethod: i.contactMethod,
propertyType: 'Single Family',
propertyPhotos: [],
leadType: 'Retail',
workType: 'Roof Replacement',
tradeType: 'Roofing',
referralNote: '',
canvasserId: '',
canvasserName: '',
assignedTo: '',
followUpDate: '',
isQuickCapture: false,
insuranceCompany: '', claimNumber: '', claimStatus: '',
adjusterName: '', adjusterPhone: '', policyNumber: '',
notes: noteParts.join(' '),
createdByName: tenantConfig?.branding?.company || 'Estimate Wizard',
// Traceability back to the wizard session / report.
estimateWizard: {
sessionId: session.id,
reportId: reportMeta?.reportId || null,
recommendedPackage: rec || null,
selectedPackage: sel || null,
estimatedTotal: reportMeta?.estimatedTotal ?? null,
},
};
}
export function loadSubmissions() {
try {
const raw = localStorage.getItem(SUBMISSIONS_KEY);
@@ -59,6 +140,7 @@ export function submitToCrm(session, tenantConfig, reportMeta) {
: null,
repBrief: session.recommendation?.explanation?.repBrief || null,
report: reportMeta || null,
crmLeadId: reportMeta?.crmLeadId || null, // link to the global mock-store lead
compliance: buildComplianceRecord(session, tenantConfig),
// Nurture handoff (spec §14): which playbook trigger fired.
nurtureTrigger: 'wizard_completed',
@@ -4,11 +4,12 @@
* the inspection, and fires the CRM + nurture handoff (once) with the immutable
* compliance record (spec §18 G1).
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Download, Printer, Mail, Share2, CalendarCheck, FileText, Loader2, Check } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import { useMockStore } from '../../../../data/mockStore';
import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport';
import { submitToCrm } from '../../api/crmHandoff';
import { submitToCrm, buildLeadFromSession } from '../../api/crmHandoff';
import { formatUSD } from '../../engine/pricing';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
@@ -29,18 +30,33 @@ const ActionButton = ({ icon: Icon, label, onClick, busy, primary }) => (
const ReportStep = () => {
const { session, tenantConfig, patchSession, logEvent } = useEstimateWizard();
const { addWizardLead } = useMockStore();
const model = buildReportModel(session, tenantConfig);
const [busy, setBusy] = useState(null);
const [booked, setBooked] = useState(!!session.report?.appointmentBooked);
const firedRef = useRef(false);
// Fire CRM + nurture handoff once (spec §13 / §17 E2).
// Fire CRM + nurture handoff once (spec §13 / §17 E2). The ref guard prevents a
// duplicate lead under React StrictMode's double-invoked mount effect.
useEffect(() => {
if (session.report) return;
const reportMeta = { reportId: model.reportId, version: 1, generatedAt: model.generatedAt, deliveryStatus: 'created' };
if (firedRef.current || session.report) return;
firedRef.current = true;
// Create a real lead in the global CRM mock store so the estimate shows up
// in the pipeline (addLead injects id / createdAt / status: 'New').
const lead = addWizardLead(buildLeadFromSession(session, tenantConfig, {
reportId: model.reportId,
estimatedTotal: model.totals.total,
}));
const reportMeta = {
reportId: model.reportId, version: 1, generatedAt: model.generatedAt,
deliveryStatus: 'created', crmLeadId: lead?.id || null, estimatedTotal: model.totals.total,
};
patchSession({ report: reportMeta });
submitToCrm(session, tenantConfig, reportMeta);
logEvent('report_generated', { reportId: model.reportId });
logEvent('crm_handoff', { trigger: 'wizard_completed' });
logEvent('crm_handoff', { trigger: 'wizard_completed', crmLeadId: lead?.id || null });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -4,7 +4,7 @@
* Consent is required before proceeding (spec §15 Privacy / §3 compliance).
*/
import React from 'react';
import { User, MapPin, Phone, Mail } from 'lucide-react';
import { User, MapPin, Phone, Mail, Wand2 } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
const Field = ({ icon: Icon, label, children }) => (
@@ -21,10 +21,18 @@ const inputCls =
'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();
const { inputs, setInput, prefillDemo } = useEstimateWizard();
return (
<div className="space-y-4">
<button
type="button"
onClick={prefillDemo}
className="w-full flex items-center justify-center gap-2 rounded-xl border border-dashed border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2.5 text-sm font-medium text-amber-700 dark:text-amber-300 hover:border-amber-400"
>
<Wand2 size={15} /> Try a demo address (auto-fills every answer)
</button>
<Field icon={User} label="Name">
<input
className={inputCls}