wizard admin feature in existing crm as separate route
This commit is contained in:
@@ -46,6 +46,7 @@ import FieldStormZonePage from './pages/FieldStormZonePage';
|
||||
import UserDetailsPage from './pages/UserDetailsPage';
|
||||
import LeadVerificationPage from './pages/LeadVerification/LeadVerificationPage';
|
||||
import CustomerEstimateWizard from './modules/estimateWizard/CustomerEstimateWizard';
|
||||
import EstimateWizardAdmin from './modules/estimateWizard/EstimateWizardAdmin';
|
||||
|
||||
// ... (existing imports)
|
||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||
@@ -77,6 +78,11 @@ function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
{/* Public DIY Roofing Estimate Wizard — homeowner-facing, no auth, chrome-less */}
|
||||
<Route path="/estimate" element={<CustomerEstimateWizard />} />
|
||||
<Route path="/estimate-wizard/admin" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||
<EstimateWizardAdmin />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route
|
||||
path="/chat-assistant"
|
||||
element={
|
||||
|
||||
@@ -157,6 +157,7 @@ const Layout = () => {
|
||||
{ to: "/owner/people", icon: Users, label: "People" },
|
||||
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
|
||||
...commonItems,
|
||||
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
||||
];
|
||||
case 'ADMIN':
|
||||
return [
|
||||
@@ -178,6 +179,7 @@ const Layout = () => {
|
||||
{ to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
|
||||
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
|
||||
...commonItems,
|
||||
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
||||
];
|
||||
case 'CONTRACTOR':
|
||||
case 'SUBCONTRACTOR':
|
||||
|
||||
@@ -33,8 +33,20 @@ const CompletionScreen = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const FeatureDisabledScreen = () => (
|
||||
<div className="min-h-screen w-full bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center px-4">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-xl font-bold text-zinc-900 dark:text-white">Estimate wizard unavailable</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-2">
|
||||
This tool is temporarily turned off. Please contact us directly and we’ll be glad to help.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const WizardRouter = () => {
|
||||
const { session } = useEstimateWizard();
|
||||
const { session, tenantConfig } = useEstimateWizard();
|
||||
if (tenantConfig.featureEnabled === false) return <FeatureDisabledScreen />; // §18 kill-switch
|
||||
return session.status === 'completed' ? <CompletionScreen /> : <WizardShell />;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* EstimateWizardAdmin — internal admin controls + analytics (spec §14, §16, §18).
|
||||
*
|
||||
* Tenant-controls (no hard-coded pricing/copy): feature kill-switch, AI toggle,
|
||||
* conservative mode, per-package price + base discount + service life, and report
|
||||
* branding. Persists as overrides via saveConfigOverrides(). Also shows a funnel
|
||||
* summary built from completed-session submissions (spec §16).
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Save, RotateCcw, Power, Sparkles, ShieldCheck, BarChart3 } from 'lucide-react';
|
||||
import {
|
||||
getTenantConfig, loadConfigOverrides, saveConfigOverrides, resetConfigOverrides,
|
||||
} from './data/tenantConfig';
|
||||
import { loadSubmissions } from './api/crmHandoff';
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
const Toggle = ({ label, desc, checked, onChange, icon: Icon }) => (
|
||||
<label className="flex items-start justify-between gap-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 cursor-pointer">
|
||||
<div className="flex items-start gap-2.5">
|
||||
{Icon && <Icon size={18} className="text-amber-500 mt-0.5" />}
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-900 dark:text-white">{label}</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="checkbox" className="mt-1 h-5 w-5 accent-amber-500 shrink-0" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
</label>
|
||||
);
|
||||
|
||||
const numInput = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
const EstimateWizardAdmin = () => {
|
||||
const initial = useMemo(() => getTenantConfig(), []);
|
||||
const [cfg, setCfg] = useState(initial);
|
||||
|
||||
const submissions = useMemo(() => loadSubmissions(), []);
|
||||
const analytics = useMemo(() => {
|
||||
const total = submissions.length;
|
||||
const dist = (key) => submissions.reduce((m, s) => { const k = s[key]; if (k) m[k] = (m[k] || 0) + 1; return m; }, {});
|
||||
const booked = submissions.filter((s) => s.report?.appointmentBooked).length;
|
||||
const addonAttach = total ? (submissions.reduce((n, s) => n + (s.selectedAddons?.length || 0), 0) / total) : 0;
|
||||
return { total, recommended: dist('recommendedPackage'), selected: dist('selectedPackage'), booked, addonAttach };
|
||||
}, [submissions]);
|
||||
|
||||
const setFlag = (k, v) => setCfg((c) => ({ ...c, [k]: v }));
|
||||
const setPkg = (type, field, value) => setCfg((c) => ({
|
||||
...c,
|
||||
packages: c.packages.map((p) => (p.type === type ? { ...p, [field]: value } : p)),
|
||||
}));
|
||||
const setDiscount = (type, value) => setCfg((c) => ({
|
||||
...c,
|
||||
discountScenarios: { ...c.discountScenarios, [type]: { ...c.discountScenarios[type], base: value } },
|
||||
}));
|
||||
const setBranding = (field, value) => setCfg((c) => ({ ...c, branding: { ...c.branding, [field]: value } }));
|
||||
|
||||
const handleSave = () => {
|
||||
const prev = loadConfigOverrides();
|
||||
const overrides = {
|
||||
...prev,
|
||||
featureEnabled: cfg.featureEnabled,
|
||||
aiExplanationsEnabled: cfg.aiExplanationsEnabled,
|
||||
conservativeMode: cfg.conservativeMode,
|
||||
branding: cfg.branding,
|
||||
discountScenarios: cfg.discountScenarios,
|
||||
packages: cfg.packages.map((p) => ({
|
||||
type: p.type, pricePerSquare: p.pricePerSquare, serviceLifeYears: p.serviceLifeYears,
|
||||
})),
|
||||
};
|
||||
saveConfigOverrides(overrides);
|
||||
toast.success('Estimate Wizard settings saved');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetConfigOverrides();
|
||||
setCfg(getTenantConfig());
|
||||
toast.message('Reset to defaults');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-zinc-50 dark:bg-[#09090b]">
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<span className="w-2 h-6 bg-amber-500 rounded-full" /> Estimate Wizard — Admin
|
||||
</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 text-sm ml-4">
|
||||
Tenant controls for pricing, discounts, AI, branding, and analytics.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleReset} className="rounded-xl border border-zinc-200 dark:border-white/10 px-3 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:border-amber-400 flex items-center gap-1.5">
|
||||
<RotateCcw size={15} /> Reset
|
||||
</button>
|
||||
<button onClick={handleSave} className="rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white px-4 py-2 text-sm font-semibold flex items-center gap-1.5 shadow-lg shadow-amber-500/25">
|
||||
<Save size={15} /> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature flags */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Toggle icon={Power} label="Feature enabled" desc="Master kill-switch for the public wizard." checked={cfg.featureEnabled} onChange={(v) => setFlag('featureEnabled', v)} />
|
||||
<Toggle icon={Sparkles} label="AI explanations" desc="Use AI copy (off = deterministic text)." checked={cfg.aiExplanationsEnabled} onChange={(v) => setFlag('aiExplanationsEnabled', v)} />
|
||||
<Toggle icon={ShieldCheck} label="Conservative mode" desc="Hide customer-facing savings claims." checked={cfg.conservativeMode} onChange={(v) => setFlag('conservativeMode', v)} />
|
||||
</div>
|
||||
|
||||
{/* Package pricing */}
|
||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3">Packages, pricing & discounts</h2>
|
||||
<div className="space-y-3">
|
||||
{cfg.packages.map((p) => (
|
||||
<div key={p.type} className="grid grid-cols-2 sm:grid-cols-4 gap-3 items-end">
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wide text-zinc-400">{PKG_LABEL[p.type]}</div>
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-200 truncate">{p.name}</div>
|
||||
</div>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Price / square ($)
|
||||
<input type="number" className={numInput} value={p.pricePerSquare} onChange={(e) => setPkg(p.type, 'pricePerSquare', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Service life (yrs)
|
||||
<input type="number" className={numInput} value={p.serviceLifeYears} onChange={(e) => setPkg(p.type, 'serviceLifeYears', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Base discount (%)
|
||||
<input type="number" className={numInput} value={cfg.discountScenarios[p.type]?.base ?? 0} onChange={(e) => setDiscount(p.type, Number(e.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-3">Customer copy always states discounts are illustrative — "ask your carrier to confirm."</p>
|
||||
</section>
|
||||
|
||||
{/* Branding */}
|
||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3">Report branding</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Company
|
||||
<input className={numInput} value={cfg.branding.company} onChange={(e) => setBranding('company', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Phone
|
||||
<input className={numInput} value={cfg.branding.phone} onChange={(e) => setBranding('phone', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Primary color
|
||||
<input className={numInput} value={cfg.branding.primaryColor} onChange={(e) => setBranding('primaryColor', e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Analytics */}
|
||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<BarChart3 size={17} className="text-amber-500" /> Funnel analytics
|
||||
</h2>
|
||||
{analytics.total === 0 ? (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">No completed estimates yet. Finish a wizard run to populate metrics.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Metric label="Completed reports" value={analytics.total} />
|
||||
<Metric label="Appointments booked" value={analytics.booked} />
|
||||
<Metric label="Avg add-ons / report" value={analytics.addonAttach.toFixed(1)} />
|
||||
<Metric label="Best recommended" value={analytics.recommended.BEST || 0} />
|
||||
<DistRow label="Recommended split" dist={analytics.recommended} />
|
||||
<DistRow label="Customer-selected split" dist={analytics.selected} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Metric = ({ label, value }) => (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<div className="text-xs text-zinc-400">{label}</div>
|
||||
<div className="text-xl font-bold text-zinc-900 dark:text-white mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DistRow = ({ label, dist }) => (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3 col-span-2">
|
||||
<div className="text-xs text-zinc-400 mb-1">{label}</div>
|
||||
<div className="flex gap-3 text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||
{['GOOD', 'BETTER', 'BEST'].map((t) => (
|
||||
<span key={t}>{PKG_LABEL[t]}: <span className="text-amber-600 dark:text-amber-400">{dist[t] || 0}</span></span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default EstimateWizardAdmin;
|
||||
@@ -79,8 +79,8 @@ Respond ONLY as JSON: {"customerExplanation": "...", "repBrief": "..."}`;
|
||||
}
|
||||
|
||||
export async function generateExplanation(ctx) {
|
||||
// No valid key → deterministic mode (still fully functional for the demo).
|
||||
if (config.isDemoMode(config.groqApiKey)) {
|
||||
// Admin disabled AI (spec §18) or no valid key → deterministic mode.
|
||||
if (ctx.tenantConfig?.aiExplanationsEnabled === false || config.isDemoMode(config.groqApiKey)) {
|
||||
return fallbackExplanation(ctx);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* crmHandoff.js — completion handoff to CRM + nurture (spec §11, §13, §17 E2).
|
||||
*
|
||||
* The wizard is a self-contained module, so instead of mutating the global CRM
|
||||
* store it writes a CRM-ready submission to localStorage. An internal page (or a
|
||||
* future adapter) can read this list to create the contact/lead and fire the
|
||||
* nurture playbook. Also returns the immutable compliance record (spec §18 G1).
|
||||
*/
|
||||
|
||||
const SUBMISSIONS_KEY = 'lup_estimate_wizard_submissions_v1';
|
||||
|
||||
export function loadSubmissions() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SUBMISSIONS_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function persist(list) {
|
||||
try { localStorage.setItem(SUBMISSIONS_KEY, JSON.stringify(list)); } catch { /* quota */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable compliance record stored with the report (spec §18 G1):
|
||||
* assumptions + source data + disclaimer version + package-config version + consent.
|
||||
*/
|
||||
export function buildComplianceRecord(session, tenantConfig) {
|
||||
return {
|
||||
consent: !!session.inputs.consent,
|
||||
tenantConfigVersion: tenantConfig.version,
|
||||
disclaimerVersion: tenantConfig.disclaimerVersion,
|
||||
scoringModelVersion: session.recommendation?.modelVersion || null,
|
||||
hailSource: session.hailSnapshot?.source || null,
|
||||
hailWindowYears: session.hailSnapshot?.windowYears || null,
|
||||
hailConfidence: session.hailSnapshot?.confidence || null,
|
||||
assumedSquares: tenantConfig.pricing.assumedSquares,
|
||||
conservativeMode: tenantConfig.conservativeMode,
|
||||
aiUsed: session.recommendation?.explanation?.aiUsed ?? false,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CRM contact/lead payload + nurture trigger from a completed session.
|
||||
* @returns the submission record (also persisted).
|
||||
*/
|
||||
export function submitToCrm(session, tenantConfig, reportMeta) {
|
||||
const i = session.inputs;
|
||||
const submission = {
|
||||
sessionId: session.id,
|
||||
submittedAt: new Date().toISOString(),
|
||||
contact: { name: i.name, address: i.address, phone: i.phone, email: i.email, contactMethod: i.contactMethod },
|
||||
source: session.source,
|
||||
selectedPackage: session.selectedPackage,
|
||||
selectedAddons: session.selectedAddons,
|
||||
recommendedPackage: session.recommendation?.selectedPackage || null,
|
||||
hail: session.hailSnapshot
|
||||
? { exposureTier: session.hailSnapshot.exposureTier, p10Base: session.hailSnapshot.probability10yr?.base }
|
||||
: null,
|
||||
repBrief: session.recommendation?.explanation?.repBrief || null,
|
||||
report: reportMeta || null,
|
||||
compliance: buildComplianceRecord(session, tenantConfig),
|
||||
// Nurture handoff (spec §14): which playbook trigger fired.
|
||||
nurtureTrigger: 'wizard_completed',
|
||||
};
|
||||
|
||||
const list = loadSubmissions();
|
||||
list.unshift(submission);
|
||||
persist(list);
|
||||
return submission;
|
||||
}
|
||||
@@ -19,9 +19,9 @@ import HailRiskStep from './steps/HailRiskStep';
|
||||
import OptionsStep from './steps/OptionsStep';
|
||||
import AddonsStep from './steps/AddonsStep';
|
||||
import RecommendationStep from './steps/RecommendationStep';
|
||||
import PlaceholderStep from './steps/PlaceholderStep';
|
||||
import ReportStep from './steps/ReportStep';
|
||||
|
||||
// Step registry — later phases swap PlaceholderStep entries for real components.
|
||||
// Step registry — every step now has a real component.
|
||||
const STEP_COMPONENTS = {
|
||||
start: StartStep,
|
||||
address: AddressStep,
|
||||
@@ -30,7 +30,7 @@ const STEP_COMPONENTS = {
|
||||
options: OptionsStep,
|
||||
addons: AddonsStep,
|
||||
recommendation: RecommendationStep,
|
||||
report: (props) => <PlaceholderStep stepId="report" {...props} />,
|
||||
report: ReportStep,
|
||||
};
|
||||
|
||||
// Validation per step: returns true when the user may advance.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* ReportStep — Screen 9 "Report + next step" (spec §11, §13).
|
||||
* Shows the report summary, lets the customer email/print/download/share, books
|
||||
* the inspection, and fires the CRM + nurture handoff (once) with the immutable
|
||||
* compliance record (spec §18 G1).
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Download, Printer, Mail, Share2, CalendarCheck, FileText, Loader2, Check } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport';
|
||||
import { submitToCrm } from '../../api/crmHandoff';
|
||||
import { formatUSD } from '../../engine/pricing';
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
const ActionButton = ({ icon: Icon, label, onClick, busy, primary }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
className={`flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-all disabled:opacity-50
|
||||
${primary
|
||||
? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25'
|
||||
: 'border border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-zinc-200 hover:border-amber-400'}`}
|
||||
>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : Icon && <Icon size={16} />} {label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const ReportStep = () => {
|
||||
const { session, tenantConfig, patchSession, logEvent } = useEstimateWizard();
|
||||
const model = buildReportModel(session, tenantConfig);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [booked, setBooked] = useState(!!session.report?.appointmentBooked);
|
||||
|
||||
// Fire CRM + nurture handoff once (spec §13 / §17 E2).
|
||||
useEffect(() => {
|
||||
if (session.report) return;
|
||||
const reportMeta = { reportId: model.reportId, version: 1, generatedAt: model.generatedAt, deliveryStatus: 'created' };
|
||||
patchSession({ report: reportMeta });
|
||||
submitToCrm(session, tenantConfig, reportMeta);
|
||||
logEvent('report_generated', { reportId: model.reportId });
|
||||
logEvent('crm_handoff', { trigger: 'wizard_completed' });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setBusy('download');
|
||||
try { await downloadReportPdf(session, tenantConfig); logEvent('report_downloaded', { reportId: model.reportId }); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
setBusy('print');
|
||||
try {
|
||||
const { url } = await getReportBlobUrl(session, tenantConfig);
|
||||
const w = window.open(url, '_blank');
|
||||
if (w) w.addEventListener('load', () => w.print());
|
||||
patchSession({ report: { ...session.report, printedAt: new Date().toISOString() } });
|
||||
logEvent('report_printed', { reportId: model.reportId });
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const handleEmail = () => {
|
||||
const subject = encodeURIComponent(`Your Roof Options Report (${model.reportId})`);
|
||||
const body = encodeURIComponent(
|
||||
`Hi ${model.contact.name || ''},\n\nHere is a summary of your roof options report.\n` +
|
||||
`Recommended: ${PKG_LABEL[model.recommended]}\nEstimated total: ~${formatUSD(model.totals.total)}\n\n` +
|
||||
`${tenantConfig.branding.company} · ${tenantConfig.branding.phone}\n\n` +
|
||||
`Note: ${tenantConfig.disclaimer}`
|
||||
);
|
||||
window.location.href = `mailto:${model.contact.email || ''}?subject=${subject}&body=${body}`;
|
||||
patchSession({ report: { ...session.report, emailedAt: new Date().toISOString(), deliveryStatus: 'emailed' } });
|
||||
logEvent('report_emailed', { reportId: model.reportId });
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const summary = `My roof options report (${PKG_LABEL[model.recommended]} recommended) from ${tenantConfig.branding.company}.`;
|
||||
try {
|
||||
if (navigator.share) await navigator.share({ title: 'Roof Options Report', text: summary });
|
||||
else { await navigator.clipboard?.writeText(summary); }
|
||||
logEvent('report_shared', { reportId: model.reportId });
|
||||
} catch { /* user cancelled */ }
|
||||
};
|
||||
|
||||
const handleBook = () => {
|
||||
setBooked(true);
|
||||
patchSession({ report: { ...session.report, appointmentBooked: true, bookedAt: new Date().toISOString() } });
|
||||
logEvent('appointment_booked', { reportId: model.reportId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Report summary card */}
|
||||
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText size={18} className="text-amber-500" />
|
||||
<span className="font-bold text-zinc-900 dark:text-white">Your Roof Options Report</span>
|
||||
<span className="ml-auto text-[11px] text-zinc-400">{model.reportId}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><div className="text-zinc-400 text-xs">Prepared for</div><div className="font-medium text-zinc-800 dark:text-zinc-100">{model.contact.name || '—'}</div></div>
|
||||
<div><div className="text-zinc-400 text-xs">Recommended</div><div className="font-medium text-amber-600 dark:text-amber-400">{PKG_LABEL[model.recommended]}</div></div>
|
||||
<div><div className="text-zinc-400 text-xs">Hail exposure</div><div className="font-medium text-zinc-800 dark:text-zinc-100 capitalize">{model.hail?.exposureTier || '—'}</div></div>
|
||||
<div><div className="text-zinc-400 text-xs">Estimated total</div><div className="font-medium text-zinc-800 dark:text-zinc-100">~{formatUSD(model.totals.total)}</div></div>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-3 leading-relaxed border-t border-zinc-100 dark:border-white/5 pt-2.5">
|
||||
{tenantConfig.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delivery actions */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
<ActionButton icon={Download} label="Download" onClick={handleDownload} busy={busy === 'download'} />
|
||||
<ActionButton icon={Printer} label="Print" onClick={handlePrint} busy={busy === 'print'} />
|
||||
<ActionButton icon={Mail} label="Email" onClick={handleEmail} />
|
||||
<ActionButton icon={Share2} label="Share" onClick={handleShare} />
|
||||
</div>
|
||||
|
||||
{/* Next step CTA */}
|
||||
{booked ? (
|
||||
<div className="rounded-2xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50 dark:bg-emerald-500/10 p-4 flex items-center gap-3">
|
||||
<Check size={20} className="text-emerald-500" />
|
||||
<div className="text-sm text-emerald-700 dark:text-emerald-300">
|
||||
Inspection requested — your rep will reach out via {session.inputs.contactMethod}. Thank you!
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ActionButton icon={CalendarCheck} label="Book my free inspection" onClick={handleBook} primary />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportStep;
|
||||
@@ -174,11 +174,20 @@ export const DISCLAIMER_TEMPLATE =
|
||||
'insurance professional. Historical storm information is not a forecast or guarantee.';
|
||||
|
||||
export const TENANT_CONFIG_VERSION = 'tenant-config-v1';
|
||||
export const DISCLAIMER_VERSION = 'disclaimer-v1';
|
||||
|
||||
export function getTenantConfig() {
|
||||
// Phase 5 admin page will override these from localStorage / mock store.
|
||||
// Admin-editable overrides persist here (spec §14). The wizard merges them over
|
||||
// the defaults so pricing/branding/feature flags are tenant-controlled, not code.
|
||||
const CONFIG_STORAGE_KEY = 'lup_estimate_wizard_config_v1';
|
||||
|
||||
function defaults() {
|
||||
return {
|
||||
version: TENANT_CONFIG_VERSION,
|
||||
disclaimerVersion: DISCLAIMER_VERSION,
|
||||
// Feature flags (spec §18): kill-switch, AI toggle, conservative copy mode.
|
||||
featureEnabled: true,
|
||||
aiExplanationsEnabled: true,
|
||||
conservativeMode: false,
|
||||
packages: DEFAULT_PACKAGES,
|
||||
addons: DEFAULT_ADDONS,
|
||||
scoringWeights: DEFAULT_SCORING_WEIGHTS,
|
||||
@@ -187,6 +196,41 @@ export function getTenantConfig() {
|
||||
discountScenarios: DEFAULT_DISCOUNT_SCENARIOS,
|
||||
branding: DEFAULT_BRANDING,
|
||||
disclaimer: DISCLAIMER_TEMPLATE,
|
||||
conservativeMode: false, // §18: hides customer-facing savings claims when true
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfigOverrides() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export function saveConfigOverrides(overrides) {
|
||||
try { localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(overrides || {})); } catch { /* quota */ }
|
||||
}
|
||||
|
||||
export function resetConfigOverrides() {
|
||||
try { localStorage.removeItem(CONFIG_STORAGE_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function getTenantConfig() {
|
||||
const base = defaults();
|
||||
const o = loadConfigOverrides();
|
||||
|
||||
// Shallow-merge top-level objects so partial admin edits don't drop defaults.
|
||||
return {
|
||||
...base,
|
||||
...o,
|
||||
pricing: { ...base.pricing, ...(o.pricing || {}) },
|
||||
branding: { ...base.branding, ...(o.branding || {}) },
|
||||
scoringWeights: { ...base.scoringWeights, ...(o.scoringWeights || {}) },
|
||||
stormSettings: { ...base.stormSettings, ...(o.stormSettings || {}) },
|
||||
discountScenarios: { ...base.discountScenarios, ...(o.discountScenarios || {}) },
|
||||
// Packages: merge admin price/label edits by type, keep default scope/copy.
|
||||
packages: base.packages.map((p) => {
|
||||
const ov = (o.packages || []).find((x) => x.type === p.type);
|
||||
return ov ? { ...p, ...ov } : p;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* wizardReportExport.js — branded customer report PDF (spec §11, §12 customer_reports).
|
||||
*
|
||||
* Sections (spec §11): cover, property summary, storm/hail snapshot, Good/Better/Best
|
||||
* options, AI recommendation, 10-year comparison, selected add-ons, disclaimers
|
||||
* (verbatim §15 template), next steps. Mirrors the app's jsPDF pattern (estimateExport.js).
|
||||
*
|
||||
* The same computed inputs drive the on-screen preview and the PDF so they match
|
||||
* exactly (spec §18: "report generated by email and print must match the saved version").
|
||||
*/
|
||||
import { pricePackage, addonsTotal, formatUSD } from '../engine/pricing';
|
||||
import { computeTenYearValue } from '../engine/valueModel';
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
function reportId(sessionId) {
|
||||
return `RPT-${(sessionId || '').replace(/[^a-zA-Z0-9]/g, '').slice(-8).toUpperCase() || 'PREVIEW'}`;
|
||||
}
|
||||
|
||||
/** Builds the structured report model — single source of truth for PDF + preview. */
|
||||
export function buildReportModel(session, tenantConfig) {
|
||||
const i = session.inputs;
|
||||
const value = session.recommendation?.value || computeTenYearValue({ inputs: i, tenantConfig });
|
||||
const recommended = session.recommendation?.selectedPackage || session.selectedPackage;
|
||||
const selected = session.selectedPackage || recommended;
|
||||
|
||||
const selectedPkg = tenantConfig.packages.find((p) => p.type === selected);
|
||||
const base = selectedPkg ? pricePackage(selectedPkg, tenantConfig.pricing).base : 0;
|
||||
const addonsSum = addonsTotal(tenantConfig.addons, session.selectedAddons || []);
|
||||
|
||||
return {
|
||||
reportId: reportId(session.id),
|
||||
generatedAt: new Date().toISOString(),
|
||||
branding: tenantConfig.branding,
|
||||
disclaimer: tenantConfig.disclaimer,
|
||||
contact: { name: i.name, address: i.address, phone: i.phone, email: i.email },
|
||||
property: session.property,
|
||||
roof: { ageYears: i.roofAgeYears, ownership: i.ownershipHorizon, issue: i.issueType, premium: i.premiumUnknown ? null : i.premiumAmount },
|
||||
hail: session.hailSnapshot,
|
||||
packages: tenantConfig.packages.map((p) => ({
|
||||
type: p.type, name: p.name, category: p.category, warranty: p.warrantyLabel,
|
||||
hailResistance: p.hailResistance, price: pricePackage(p, tenantConfig.pricing),
|
||||
})),
|
||||
recommended,
|
||||
selected,
|
||||
explanation: session.recommendation?.explanation || null,
|
||||
reasons: session.recommendation?.reasons || [],
|
||||
value,
|
||||
addons: (tenantConfig.addons || []).filter((a) => (session.selectedAddons || []).includes(a.id)),
|
||||
totals: { base, addonsSum, total: base + addonsSum },
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateReportPdf(session, tenantConfig) {
|
||||
const m = buildReportModel(session, tenantConfig);
|
||||
const { default: jsPDF } = await import('jspdf');
|
||||
const { default: autoTable } = await import('jspdf-autotable');
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const W = doc.internal.pageSize.getWidth();
|
||||
const MARGIN = 14;
|
||||
const accent = tenantConfig.branding.primaryColor || '#fda913';
|
||||
let y = 0;
|
||||
|
||||
const text = (s, x, yy, opts) => doc.text(String(s ?? ''), x, yy, opts);
|
||||
const ensure = (needed) => { if (y + needed > 285) { doc.addPage(); y = 18; } };
|
||||
const heading = (label) => {
|
||||
ensure(14);
|
||||
doc.setFont('helvetica', 'bold'); doc.setFontSize(11);
|
||||
doc.setTextColor(20); text(label, MARGIN, y); y += 2;
|
||||
doc.setDrawColor(accent); doc.setLineWidth(0.6); doc.line(MARGIN, y, W - MARGIN, y); y += 6;
|
||||
};
|
||||
|
||||
// ── Cover band ──
|
||||
doc.setFillColor(17, 17, 19); doc.rect(0, 0, W, 34, 'F');
|
||||
doc.setTextColor(255); doc.setFont('helvetica', 'bold'); doc.setFontSize(18);
|
||||
text(m.branding.company, MARGIN, 16);
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(10);
|
||||
text('Your Roof Options Report', MARGIN, 24);
|
||||
doc.setFontSize(8);
|
||||
text(`${m.reportId} · ${new Date(m.generatedAt).toLocaleDateString()}`, MARGIN, 30);
|
||||
y = 44;
|
||||
|
||||
doc.setTextColor(40); doc.setFont('helvetica', 'bold'); doc.setFontSize(12);
|
||||
text(`Prepared for ${m.contact.name || 'Homeowner'}`, MARGIN, y); y += 6;
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90);
|
||||
text(m.contact.address || (m.property?.displayName ?? ''), MARGIN, y); y += 10;
|
||||
|
||||
// ── Property summary ──
|
||||
heading('Property summary');
|
||||
autoTable(doc, {
|
||||
startY: y, theme: 'plain', margin: { left: MARGIN, right: MARGIN },
|
||||
styles: { fontSize: 9, cellPadding: 1.5 },
|
||||
body: [
|
||||
['Roof age (estimated)', `${m.roof.ageYears ?? '—'} years`],
|
||||
['Planned ownership', m.roof.ownership ? `${m.roof.ownership} years` : '—'],
|
||||
['Current concern', m.roof.issue || '—'],
|
||||
['Annual insurance premium', m.roof.premium ? formatUSD(m.roof.premium) : 'Not provided'],
|
||||
],
|
||||
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 60, textColor: 60 }, 1: { textColor: 30 } },
|
||||
});
|
||||
y = doc.lastAutoTable.finalY + 8;
|
||||
|
||||
// ── Storm / hail snapshot ──
|
||||
heading('Storm / hail risk snapshot (historical)');
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(60);
|
||||
if (m.hail) {
|
||||
const p = m.hail.probability10yr;
|
||||
const lines = [
|
||||
m.hail.wording?.probability || '',
|
||||
`Annualized rate: ${m.hail.annualRate}/yr · 10-yr range: ${m.hail.eventCount === 0 ? '—' : `${p.low}–${p.high}%`} · Confidence: ${m.hail.confidence}`,
|
||||
`Source: ${m.hail.source} · ~${m.hail.radiusMiles} mi · ${m.hail.wording?.lastUpdated || ''}`,
|
||||
];
|
||||
lines.forEach((l) => { ensure(6); doc.splitTextToSize(l, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.6; }); });
|
||||
} else {
|
||||
text('Storm history will be reviewed by your rep during inspection.', MARGIN, y); y += 5;
|
||||
}
|
||||
y += 4;
|
||||
|
||||
// ── Good / Better / Best ──
|
||||
heading('Your Good / Better / Best options');
|
||||
autoTable(doc, {
|
||||
startY: y, margin: { left: MARGIN, right: MARGIN },
|
||||
head: [['Option', 'System', 'Warranty', 'Hail', 'Estimated range']],
|
||||
body: m.packages.map((p) => [
|
||||
PKG_LABEL[p.type] + (p.type === m.recommended ? ' ★' : ''),
|
||||
p.name, p.warranty, p.hailResistance, `${formatUSD(p.price.low)}–${formatUSD(p.price.high)}`,
|
||||
]),
|
||||
styles: { fontSize: 8.5, cellPadding: 2 },
|
||||
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
|
||||
alternateRowStyles: { fillColor: [248, 248, 248] },
|
||||
});
|
||||
y = doc.lastAutoTable.finalY + 8;
|
||||
|
||||
// ── AI recommendation ──
|
||||
heading(`Recommendation: ${PKG_LABEL[m.recommended] || '—'}`);
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(50);
|
||||
if (m.explanation?.customerExplanation) {
|
||||
doc.splitTextToSize(m.explanation.customerExplanation, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.8; });
|
||||
y += 2;
|
||||
}
|
||||
m.reasons.forEach((r) => {
|
||||
ensure(6);
|
||||
doc.setTextColor(accent); text('•', MARGIN, y); doc.setTextColor(60);
|
||||
doc.splitTextToSize(r, W - MARGIN * 2 - 4).forEach((ln, idx) => { ensure(5); text(ln, MARGIN + 4, y); if (idx >= 0) y += 4.6; });
|
||||
});
|
||||
y += 4;
|
||||
|
||||
// ── 10-year comparison ──
|
||||
heading('10-year value comparison');
|
||||
autoTable(doc, {
|
||||
startY: y, margin: { left: MARGIN, right: MARGIN },
|
||||
head: [['Option', 'Upfront (base)', 'Est. 10-yr savings', 'Service life']],
|
||||
body: m.value.rows.map((r) => [
|
||||
PKG_LABEL[r.type],
|
||||
formatUSD(r.price.base),
|
||||
r.savings ? `${formatUSD(r.savings.low)}–${formatUSD(r.savings.high)}` : '—',
|
||||
`${r.serviceLifeYears}+ yrs`,
|
||||
]),
|
||||
styles: { fontSize: 8.5, cellPadding: 2 },
|
||||
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
|
||||
});
|
||||
y = doc.lastAutoTable.finalY + 6;
|
||||
doc.setFont('helvetica', 'italic'); doc.setFontSize(7.5); doc.setTextColor(120);
|
||||
text('Savings shown are illustrative and must be confirmed with your insurance carrier.', MARGIN, y); y += 8;
|
||||
|
||||
// ── Selected add-ons ──
|
||||
heading('Selected add-ons');
|
||||
if (m.addons.length) {
|
||||
autoTable(doc, {
|
||||
startY: y, margin: { left: MARGIN, right: MARGIN },
|
||||
body: m.addons.map((a) => [a.name, a.value, `+${formatUSD(a.price)}`]),
|
||||
styles: { fontSize: 8.5, cellPadding: 2 },
|
||||
columnStyles: { 1: { textColor: 90 }, 2: { halign: 'right', fontStyle: 'bold' } },
|
||||
});
|
||||
y = doc.lastAutoTable.finalY + 4;
|
||||
} else {
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90);
|
||||
text('No add-ons selected.', MARGIN, y); y += 6;
|
||||
}
|
||||
ensure(8);
|
||||
doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(20);
|
||||
text(`Estimated total (${PKG_LABEL[m.selected]} + add-ons): ~${formatUSD(m.totals.total)}`, MARGIN, y); y += 10;
|
||||
|
||||
// ── Disclaimers (verbatim) ──
|
||||
heading('Important disclaimers');
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(90);
|
||||
doc.splitTextToSize(m.disclaimer, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.2; });
|
||||
y += 6;
|
||||
|
||||
// ── Next steps ──
|
||||
heading('Next steps');
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(50);
|
||||
['Book your free inspection to verify measurements and finalize pricing.',
|
||||
`Call or text us at ${m.branding.phone}.`,
|
||||
'Have your insurance documents handy if a hail claim may be relevant.'].forEach((s) => {
|
||||
ensure(6); doc.setTextColor(accent); text('→', MARGIN, y); doc.setTextColor(50); text(s, MARGIN + 5, y); y += 5.5;
|
||||
});
|
||||
|
||||
// Footer on every page
|
||||
const pages = doc.internal.getNumberOfPages();
|
||||
for (let pg = 1; pg <= pages; pg++) {
|
||||
doc.setPage(pg);
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(150);
|
||||
text(`${m.branding.company} · ${m.branding.phone} · ${m.reportId}`, MARGIN, 292);
|
||||
text(`Page ${pg} of ${pages}`, W - MARGIN, 292, { align: 'right' });
|
||||
}
|
||||
|
||||
const filename = `Roof-Options-${(m.contact.name || 'Report').replace(/\s+/g, '-')}-${m.reportId}.pdf`;
|
||||
return { doc, filename, model: m };
|
||||
}
|
||||
|
||||
export async function downloadReportPdf(session, tenantConfig) {
|
||||
const { doc, filename, model } = await generateReportPdf(session, tenantConfig);
|
||||
doc.save(filename);
|
||||
return { filename, reportId: model.reportId };
|
||||
}
|
||||
|
||||
export async function getReportBlobUrl(session, tenantConfig) {
|
||||
const { doc, filename, model } = await generateReportPdf(session, tenantConfig);
|
||||
const url = doc.output('bloburl');
|
||||
return { url, filename, reportId: model.reportId };
|
||||
}
|
||||
Reference in New Issue
Block a user