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
+41 -1
View File
@@ -9140,6 +9140,31 @@ const DEFAULT_EMPTY_PROFILE = {
const MockStoreContext = createContext(); const MockStoreContext = createContext();
// ---------------------------------------------------------------------------
// Estimate Wizard CRM lead persistence.
// The public DIY Estimate Wizard runs in a separate session from the authed CRM,
// so its leads must survive a reload. They're persisted to localStorage and
// rehydrated into the leads list on store init, deduped by wizard session id.
// ---------------------------------------------------------------------------
const WIZARD_LEADS_KEY = 'lup_estimate_wizard_leads_v1';
function loadWizardLeads() {
try {
const raw = localStorage.getItem(WIZARD_LEADS_KEY);
const list = raw ? JSON.parse(raw) : [];
return Array.isArray(list) ? list : [];
} catch { return []; }
}
function persistWizardLead(lead) {
try {
const list = loadWizardLeads();
const sid = lead?.estimateWizard?.sessionId;
if (sid && list.some(l => l?.estimateWizard?.sessionId === sid)) return; // already saved
localStorage.setItem(WIZARD_LEADS_KEY, JSON.stringify([lead, ...list]));
} catch { /* quota */ }
}
export const MockStoreProvider = ({ children }) => { export const MockStoreProvider = ({ children }) => {
const [properties, setProperties] = useState([]); const [properties, setProperties] = useState([]);
const [users, setUsers] = useState(MOCK_USERS); const [users, setUsers] = useState(MOCK_USERS);
@@ -9154,7 +9179,7 @@ export const MockStoreProvider = ({ children }) => {
const [projects, setProjects] = useState(MOCK_PROJECTS); const [projects, setProjects] = useState(MOCK_PROJECTS);
const [orders, setOrders] = useState(MOCK_ORDERS); const [orders, setOrders] = useState(MOCK_ORDERS);
const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES); const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES);
const [leads, setLeads] = useState(() => seedLeadsFromAttribution()); const [leads, setLeads] = useState(() => [...loadWizardLeads(), ...seedLeadsFromAttribution()]);
const [dispatchLeads, setDispatchLeads] = useState(DISPATCH_LEADS_INITIAL); const [dispatchLeads, setDispatchLeads] = useState(DISPATCH_LEADS_INITIAL);
const [templates, setTemplates] = useState(MASTER_TEMPLATES_INITIAL); const [templates, setTemplates] = useState(MASTER_TEMPLATES_INITIAL);
const [estimates, setEstimates] = useState(MOCK_ESTIMATES_INITIAL); const [estimates, setEstimates] = useState(MOCK_ESTIMATES_INITIAL);
@@ -9979,6 +10004,21 @@ export const MockStoreProvider = ({ children }) => {
return newLead; return newLead;
}, },
// Lead created by the public Estimate Wizard. Persisted to localStorage
// (survives reload) and deduped by wizard session id.
addWizardLead: (lead) => {
const created = {
...lead,
id: lead.id || `lead_wiz_${Date.now()}`,
createdAt: lead.createdAt || new Date().toISOString(),
status: 'New',
};
const sid = created.estimateWizard?.sessionId;
setLeads(prev => (sid && prev.some(l => l?.estimateWizard?.sessionId === sid)) ? prev : [created, ...prev]);
persistWizardLead(created);
return created;
},
// Templates // Templates
templates, templates,
addTemplate: (tpl) => { addTemplate: (tpl) => {
@@ -19,8 +19,8 @@ const CompletionScreen = () => {
Thanks{inputs.name ? `, ${inputs.name.split(' ')[0]}` : ''}! Thanks{inputs.name ? `, ${inputs.name.split(' ')[0]}` : ''}!
</h1> </h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-2"> <p className="text-zinc-500 dark:text-zinc-400 mt-2">
Your roof options report is being prepared. The branded report, AI recommendation, and Your roof options report is ready and your details have been sent to our team
booking step are delivered in Phase 5. a rep will reach out to confirm your free inspection.
</p> </p>
<button <button
onClick={reset} onClick={reset}
@@ -17,6 +17,28 @@ import { getTenantConfig } from './data/tenantConfig';
const STORAGE_KEY = 'lup_estimate_wizard_session_v1'; 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); const EstimateWizardContext = createContext(null);
// A stable-ish id without relying on Date.now/Math.random being available everywhere. // 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 */ } try { localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)); } catch { /* quota */ }
}, [source]); }, [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 = { const value = {
session, session,
inputs: session.inputs, inputs: session.inputs,
@@ -141,6 +174,7 @@ export function EstimateWizardProvider({ children, source = 'public' }) {
back, back,
complete, complete,
reset, reset,
prefillDemo,
}; };
return ( return (
@@ -9,6 +9,87 @@
const SUBMISSIONS_KEY = 'lup_estimate_wizard_submissions_v1'; 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() { export function loadSubmissions() {
try { try {
const raw = localStorage.getItem(SUBMISSIONS_KEY); const raw = localStorage.getItem(SUBMISSIONS_KEY);
@@ -59,6 +140,7 @@ export function submitToCrm(session, tenantConfig, reportMeta) {
: null, : null,
repBrief: session.recommendation?.explanation?.repBrief || null, repBrief: session.recommendation?.explanation?.repBrief || null,
report: reportMeta || null, report: reportMeta || null,
crmLeadId: reportMeta?.crmLeadId || null, // link to the global mock-store lead
compliance: buildComplianceRecord(session, tenantConfig), compliance: buildComplianceRecord(session, tenantConfig),
// Nurture handoff (spec §14): which playbook trigger fired. // Nurture handoff (spec §14): which playbook trigger fired.
nurtureTrigger: 'wizard_completed', nurtureTrigger: 'wizard_completed',
@@ -4,11 +4,12 @@
* the inspection, and fires the CRM + nurture handoff (once) with the immutable * the inspection, and fires the CRM + nurture handoff (once) with the immutable
* compliance record (spec §18 G1). * 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 { Download, Printer, Mail, Share2, CalendarCheck, FileText, Loader2, Check } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext'; import { useEstimateWizard } from '../../EstimateWizardContext';
import { useMockStore } from '../../../../data/mockStore';
import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport'; import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport';
import { submitToCrm } from '../../api/crmHandoff'; import { submitToCrm, buildLeadFromSession } from '../../api/crmHandoff';
import { formatUSD } from '../../engine/pricing'; import { formatUSD } from '../../engine/pricing';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
@@ -29,18 +30,33 @@ const ActionButton = ({ icon: Icon, label, onClick, busy, primary }) => (
const ReportStep = () => { const ReportStep = () => {
const { session, tenantConfig, patchSession, logEvent } = useEstimateWizard(); const { session, tenantConfig, patchSession, logEvent } = useEstimateWizard();
const { addWizardLead } = useMockStore();
const model = buildReportModel(session, tenantConfig); const model = buildReportModel(session, tenantConfig);
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [booked, setBooked] = useState(!!session.report?.appointmentBooked); 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(() => { useEffect(() => {
if (session.report) return; if (firedRef.current || session.report) return;
const reportMeta = { reportId: model.reportId, version: 1, generatedAt: model.generatedAt, deliveryStatus: 'created' }; 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 }); patchSession({ report: reportMeta });
submitToCrm(session, tenantConfig, reportMeta); submitToCrm(session, tenantConfig, reportMeta);
logEvent('report_generated', { reportId: model.reportId }); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -4,7 +4,7 @@
* Consent is required before proceeding (spec §15 Privacy / §3 compliance). * Consent is required before proceeding (spec §15 Privacy / §3 compliance).
*/ */
import React from 'react'; 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'; import { useEstimateWizard } from '../../EstimateWizardContext';
const Field = ({ icon: Icon, label, children }) => ( 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'; 'text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500';
const StartStep = () => { const StartStep = () => {
const { inputs, setInput } = useEstimateWizard(); const { inputs, setInput, prefillDemo } = useEstimateWizard();
return ( return (
<div className="space-y-4"> <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"> <Field icon={User} label="Name">
<input <input
className={inputCls} className={inputCls}