From fe9411d07e6b9f13732d2ca3812e269f5c404a5b Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Fri, 22 May 2026 20:24:24 -0400 Subject: [PATCH 1/2] Improve customer dashboard for lead recovery demo flow --- app/app/layout.tsx | 24 +- app/app/page.tsx | 260 ++++++++------ components/app-nav.tsx | 7 - components/home-dashboard.tsx | 473 ++++++++++++++++++++++++++ lib/dashboard-home.ts | 68 ++++ lib/portfolio-demo.ts | 6 + tests/dashboard-home.test.ts | 71 ++++ tests/leads-workspace-routing.test.ts | 11 +- 8 files changed, 797 insertions(+), 123 deletions(-) create mode 100644 components/home-dashboard.tsx create mode 100644 lib/dashboard-home.ts create mode 100644 tests/dashboard-home.test.ts diff --git a/app/app/layout.tsx b/app/app/layout.tsx index 30304bb..0c23b95 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -11,17 +11,32 @@ import { getCustomerSystemStatus } from '@/lib/system-status'; export const dynamic = 'force-dynamic'; +function getDashboardStatusPresentation(status: ReturnType) { + if (status.key === 'live') { + return { + label: 'Live: recovering missed calls', + badgeVariant: 'success' as const, + }; + } + + return { + label: 'Setup in progress', + badgeVariant: 'secondary' as const, + }; +} + export default async function AppLayout({ children }: { children: React.ReactNode }) { if (isPortfolioDemoMode()) { const demoBusiness = getPortfolioDemoBusiness(); const demoSystemStatus = getCustomerSystemStatus(demoBusiness, 1); + const dashboardStatus = getDashboardStatusPresentation(demoSystemStatus); return (
{children}
@@ -41,13 +56,14 @@ export default async function AppLayout({ children }: { children: React.ReactNod ? await db.lead.count({ where: { businessId: business.id, OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }] } }) : 0; const systemStatus = business ? getCustomerSystemStatus(business, successfulLeadCount) : null; + const dashboardStatus = systemStatus ? getDashboardStatusPresentation(systemStatus) : null; return (
{adminCustomerContext && business ? (
diff --git a/app/app/page.tsx b/app/app/page.tsx index 224b297..d55a5eb 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -1,16 +1,12 @@ -import Link from 'next/link'; import { LeadReadiness, LeadStatus } from '@prisma/client'; -import { CustomerLeadRow } from '@/components/customer-lead-row'; -import { LeadConversionSummaryCard } from '@/components/lead-conversion-summary-card'; -import { Badge } from '@/components/ui/badge'; -import { buttonVariants } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { HomeDashboard, type DashboardLeadCard } from '@/components/home-dashboard'; import { requireBusiness } from '@/lib/auth'; import { listAllDashboardLeadsForBusiness } from '@/lib/business-access'; +import { buildRecoveryMetrics } from '@/lib/dashboard-home'; import { db } from '@/lib/db'; -import { getLeadOutcomeSummary } from '@/lib/lead-outcomes'; -import { getLeadLastActivityAt, isLeadOpenStatus } from '@/lib/lead-presenters'; +import { formatRelativeTime, getLeadLastActivityAt, isLeadOpenStatus } from '@/lib/lead-presenters'; +import { formatPhoneForDisplay } from '@/lib/phone'; import { getPortfolioDemoBusiness, getPortfolioDemoLeads, isPortfolioDemoMode } from '@/lib/portfolio-demo'; import { getCustomerSystemStatus } from '@/lib/system-status'; @@ -18,6 +14,10 @@ function buildLeadDetailHref(leadId: string) { return `/app/leads/${leadId}?from=%2Fapp`; } +function buildConversationHref(leadId: string) { + return `/app/conversations?leadId=${leadId}`; +} + function getAttentionPriority(lead: { status: LeadStatus; readiness: LeadReadiness; @@ -47,23 +47,107 @@ function getAttentionPriority(lead: { }; } -export default async function AppHomePage() { +function getCustomerName(lead: { + callerName: string | null; + contactName: string | null; + callerPhoneNormalized: string | null; + callerPhone: string; +}) { + return lead.callerName || lead.contactName || formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone); +} + +function getRecommendedNextAction(lead: { + status: LeadStatus; + readiness: LeadReadiness; + lastInboundAt: Date | null; +}) { + if (lead.status === LeadStatus.BOOKED) return 'Booked. Keep this lead as proof of recovered revenue.'; + if (lead.status === LeadStatus.LOST) return 'No further action needed unless the customer comes back.'; + if (lead.status === LeadStatus.CONTACTED) return 'Follow up again and confirm whether the job is booked.'; + if (lead.readiness === LeadReadiness.URGENT) return 'Call now.'; + if (lead.status === LeadStatus.NOTIFIED || lead.status === LeadStatus.QUALIFIED) return 'Call now.'; + if (lead.lastInboundAt) return 'Reply by text, then call if the customer is ready.'; + return 'Call now.'; +} + +function toLeadCard( + lead: { + id: string; + callerName: string | null; + contactName: string | null; + callerPhoneNormalized: string | null; + callerPhone: string; + serviceType: string | null; + serviceRequested: string | null; + urgency: string | null; + location: string | null; + zipCode: string | null; + summary: string | null; + status: LeadStatus; + readiness: LeadReadiness; + qualifiedAt: Date | null; + notifiedAt: Date | null; + ownerNotifiedAt: Date | null; + createdAt: Date; + lastInteractionAt: Date | null; + lastInboundAt: Date | null; + }, + demoMode: boolean, +): DashboardLeadCard { + return { + id: lead.id, + customerName: getCustomerName(lead), + serviceNeeded: lead.serviceType || lead.serviceRequested || 'Service needed not captured yet', + urgencyLabel: lead.urgency || (lead.readiness === LeadReadiness.URGENT ? 'Urgent' : lead.readiness === LeadReadiness.QUALIFIED ? 'Qualified' : 'Needs reply'), + locationLabel: lead.location || lead.zipCode || 'Location pending', + timeSinceMissedCall: formatRelativeTime(lead.createdAt), + summary: + lead.summary || + 'CallbackCloser is still collecting the AI summary for this lead. Open the conversation or call back to keep the lead moving.', + recommendedNextAction: getRecommendedNextAction(lead), + status: lead.status, + countsAsRecovered: + lead.status === LeadStatus.BOOKED || + lead.status === LeadStatus.CONTACTED || + lead.status === LeadStatus.NOTIFIED || + lead.status === LeadStatus.QUALIFIED || + lead.readiness !== LeadReadiness.PENDING || + Boolean(lead.qualifiedAt || lead.notifiedAt || lead.ownerNotifiedAt), + callHref: `tel:${lead.callerPhoneNormalized || lead.callerPhone}`, + sendTextHref: buildConversationHref(lead.id), + leadHref: buildLeadDetailHref(lead.id), + sourceLabel: demoMode ? 'Demo lead' : null, + }; +} + +function getDateKey(value: Date, timeZone: string) { + return new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(value); +} + +export default async function AppHomePage({ + searchParams, +}: { + searchParams?: Record; +}) { const demoMode = isPortfolioDemoMode(); const business = demoMode ? getPortfolioDemoBusiness() : await requireBusiness(); const [allLeads, notificationSettings] = demoMode - ? [ - getPortfolioDemoLeads(null), - null, - ] + ? [getPortfolioDemoLeads(null), null] : await Promise.all([ listAllDashboardLeadsForBusiness(business.id), db.businessNotificationSettings.findUnique({ where: { businessId: business.id } }), ]); const successfulLeadCount = allLeads.filter((lead) => lead.ownerNotifiedAt || lead.notifiedAt).length; - const outcomeSummary = getLeadOutcomeSummary(allLeads); const systemStatus = getCustomerSystemStatus(business, successfulLeadCount); + const metrics = buildRecoveryMetrics(allLeads); + const attentionLeads = allLeads .filter((lead) => isLeadOpenStatus(lead.status)) .sort((left, right) => { @@ -80,11 +164,21 @@ export default async function AppHomePage() { return rightPriority.activityAt - leftPriority.activityAt; }) - .slice(0, 5); - const recentLeads = [...allLeads] + .slice(0, 5) + .map((lead) => toLeadCard(lead, demoMode)); + + const queueAnchor = + demoMode && allLeads.length > 0 + ? new Date(Math.max(...allLeads.map((lead) => lead.createdAt.getTime()))) + : new Date(); + const queueDateKey = getDateKey(queueAnchor, business.timezone); + const queueLeads = [...allLeads] + .filter((lead) => getDateKey(lead.createdAt, business.timezone) === queueDateKey) .sort((left, right) => getLeadLastActivityAt(right).getTime() - getLeadLastActivityAt(left).getTime()) - .slice(0, 6); + .slice(0, 6) + .map((lead) => toLeadCard(lead, demoMode)); + const phoneLineConnected = Boolean(business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber); const ownerAlertsReady = Boolean( business.notifyPhone || notificationSettings?.ownerPhone || @@ -92,104 +186,56 @@ export default async function AppHomePage() { (notificationSettings?.notifyEmail && notificationSettings.ownerEmail), ); - const healthItems = [ + const setupChecklistItems = [ { - label: 'Texting', - value: systemStatus.key === 'live' ? 'Active' : 'Finishing setup', - detail: systemStatus.description, + key: 'phone-line', + label: 'Phone line connected', + detail: phoneLineConnected + ? 'CallbackCloser has a business line ready for missed-call recovery.' + : 'Connect the business line that should trigger missed-call recovery.', + state: phoneLineConnected ? ('complete' as const) : ('pending' as const), }, { - label: 'Phone line', - value: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber ? 'Connected' : 'Needs setup', - detail: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber ? 'Your business line is attached to CallbackCloser.' : 'Finish phone setup in Settings before going live.', + key: 'owner-alerts', + label: 'Owner alerts ready', + detail: ownerAlertsReady + ? 'The owner alert route is ready for qualified lead handoffs.' + : 'Add an owner phone or email so lead handoffs land in the right place.', + state: ownerAlertsReady ? ('complete' as const) : ('pending' as const), }, { - label: 'Owner alerts', - value: ownerAlertsReady ? 'Ready' : 'Needs setup', - detail: ownerAlertsReady ? 'Lead alerts can reach you quickly when a missed call turns into a lead.' : 'Add an owner alert number or email in Settings.', + key: 'texting-activation', + label: 'Texting activation in progress', + detail: + systemStatus.key === 'live' + ? 'Texting is live and recovering missed calls now.' + : systemStatus.description, + state: + systemStatus.key === 'live' + ? ('complete' as const) + : systemStatus.key === 'activating' + ? ('in_progress' as const) + : ('pending' as const), }, ]; + const feedback = { + error: typeof searchParams?.error === 'string' ? searchParams.error : null, + saved: searchParams?.saved === '1', + }; + return ( -
-
-
- Customer dashboard -
-

Who needs follow-up right now?

-

- Open the lead that needs attention, take action, and move on. This page keeps the next calls obvious. -

-
-
- - Open lead inbox - -
- - - -
- - - Leads needing attention first - These are the leads most likely to need a callback or outcome update next. - - - {attentionLeads.length === 0 ? ( -
- No open leads need action right now. -
- ) : ( - attentionLeads.map((lead) => ) - )} -
-
- - - - System health - Only the simple status checks that matter for day-to-day use. - - - {healthItems.map((item) => ( -
-
-

{item.label}

- {item.value} -
-

{item.detail}

-
- ))} -
-
-
- -
- - -
- Recent leads - Everything that came through recently, whether it still needs action or not. -
- - View all leads - -
- - {recentLeads.length === 0 ? ( -
- Leads will show up here as soon as CallbackCloser captures a missed call. -
- ) : ( - recentLeads.map((lead) => ) - )} -
-
-
-
+ 0} + isDemoMode={demoMode} + metrics={metrics} + queueLeads={queueLeads} + setupChecklistItems={setupChecklistItems} + showSetupChecklist={systemStatus.key !== 'live'} + simulatorHref="/simulator" + /> ); } diff --git a/components/app-nav.tsx b/components/app-nav.tsx index 0774ccd..5c8bbac 100644 --- a/components/app-nav.tsx +++ b/components/app-nav.tsx @@ -53,13 +53,6 @@ export function AppNav({ ) : ( Not live yet )} - {business ? ( - - {business.subscriptionStatus.toLowerCase()} - - ) : ( - onboarding - )}