wizard admin feature in existing crm as separate route
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user