new lead created by estimate wizard now list in leads page
This commit is contained in:
+41
-1
@@ -9140,6 +9140,31 @@ const DEFAULT_EMPTY_PROFILE = {
|
||||
|
||||
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 }) => {
|
||||
const [properties, setProperties] = useState([]);
|
||||
const [users, setUsers] = useState(MOCK_USERS);
|
||||
@@ -9154,7 +9179,7 @@ export const MockStoreProvider = ({ children }) => {
|
||||
const [projects, setProjects] = useState(MOCK_PROJECTS);
|
||||
const [orders, setOrders] = useState(MOCK_ORDERS);
|
||||
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 [templates, setTemplates] = useState(MASTER_TEMPLATES_INITIAL);
|
||||
const [estimates, setEstimates] = useState(MOCK_ESTIMATES_INITIAL);
|
||||
@@ -9979,6 +10004,21 @@ export const MockStoreProvider = ({ children }) => {
|
||||
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,
|
||||
addTemplate: (tpl) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user