wizard admin feature in existing crm as separate route

This commit is contained in:
Mayur Shinde
2026-06-09 18:01:32 +05:30
parent 0da194b157
commit 075d2dd60f
10 changed files with 695 additions and 9 deletions
@@ -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 well 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;
+2 -2
View File
@@ -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 };
}