diff --git a/app/app/billing/page.tsx b/app/app/billing/page.tsx index e8ae360..5eea9ea 100644 --- a/app/app/billing/page.tsx +++ b/app/app/billing/page.tsx @@ -7,7 +7,6 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { requireBusiness } from '@/lib/auth'; import { getBillingUsageSnapshotForBusiness } from '@/lib/business-access'; import { db } from '@/lib/db'; -import { getManagedTextingNumber } from '@/lib/managed-twilio'; import { getPortfolioDemoBlockedCount, isPortfolioDemoMode } from '@/lib/portfolio-demo'; import { getBusinessBillingAccessState } from '@/lib/subscription'; import { getBillingDisplayLabel } from '@/lib/system-status'; @@ -165,15 +164,16 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec const currentPlanLabel = mapPlanLabel(stripeSnapshot?.stripePlanLabel, business.stripePriceId, process.env); const billingStatusLabel = getBillingDisplayLabel(business.subscriptionStatus, billingAccess.billingActive); const billingNeedsAttention = business.subscriptionStatus === 'PAST_DUE' || business.subscriptionStatus === 'CANCELED' || !subscriptionActive; + const hasBusinessTextingNumber = Boolean(business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber); const summaryItems = [ { label: 'Current plan', value: currentPlanLabel }, { label: 'Next charge date', value: stripeSnapshot ? formatDate(stripeSnapshot.nextChargeDate) : 'Shown in Stripe Billing Portal' }, { label: 'Included usage', value: usage ? `${usage.limit} SMS-qualified conversations / month` : `Unavailable in ${demoModeLabel}` }, - { label: 'Included number', value: getManagedTextingNumber(business) ? 'One business texting number is included' : 'Provisioned during setup' }, - { label: 'Overage policy', value: 'One business texting number and standard setup are included. Automation pauses at the limit until you upgrade.' }, + { label: 'Included number', value: hasBusinessTextingNumber ? 'One business texting number is included' : 'Included once setup is finished' }, + { label: 'Overage policy', value: 'One business texting number and standard setup are included. Automatic follow-up pauses at the limit until you upgrade.' }, { label: 'Payment method', value: stripeSnapshot?.paymentMethodLabel || 'Add or update card in Stripe Billing Portal' }, - { label: 'Billing portal', value: business.stripeCustomerId ? 'Available below' : 'Available after customer setup' }, + { label: 'Billing portal', value: business.stripeCustomerId ? 'Available below' : 'Available after your account is ready' }, ]; const usageCards = [ @@ -323,11 +323,11 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec Starter - Cover missed calls with one managed texting number, standard setup, and a clean owner handoff. + Cover missed calls with one business texting number, standard setup, and a clean owner handoff.

{planPrice(starterPriceId)}

-

Best for smaller service teams that want missed-call coverage live fast without managing line setup.

+

Best for smaller service teams that want missed-call coverage live fast without managing phone setup.

@@ -342,7 +342,7 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec Growth - More follow-up capacity plus managed rollout help for teams with busier inbound traffic. + More follow-up capacity plus guided rollout help for teams with busier inbound traffic.

{planPrice(growthPriceId)}

@@ -364,7 +364,7 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec For operators managing multiple brands, multiple locations, extra numbers, or white-glove launches. -

Talk to us before activation so routing, billing, and onboarding structure match the operating model.

+

Talk to us before going live so routing, billing, and onboarding structure match the operating model.

diff --git a/app/app/call-flow/page.tsx b/app/app/call-flow/page.tsx index b91382f..6833b3e 100644 --- a/app/app/call-flow/page.tsx +++ b/app/app/call-flow/page.tsx @@ -1,243 +1,135 @@ import Link from 'next/link'; -import { - getBusinessPhoneSetupGate, - getBusinessPhoneSetupPathLabel, - getBusinessRoutingNumber, - getPublicBusinessPhone, -} from '@/lib/business-phone-setup'; import { SetupChecklist } from '@/components/setup-checklist'; import { Badge } from '@/components/ui/badge'; import { buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { requireBusiness } from '@/lib/auth'; +import { getBusinessNotificationSettingsForBusiness } from '@/lib/business-access'; +import { getBusinessRoutingNumber, getPublicBusinessPhone } from '@/lib/business-phone-setup'; import { db } from '@/lib/db'; -import { getManagedTextingNumber, getManagedTwilioStatusSummary } from '@/lib/managed-twilio'; import { formatPhoneForDisplay } from '@/lib/phone'; import { isPortfolioDemoMode } from '@/lib/portfolio-demo'; -import { getBusinessBillingAccessState } from '@/lib/subscription'; import { getCustomerSystemStatus } from '@/lib/system-status'; -import { isSmsRecipientOptedOut } from '@/lib/twilio-sms-compliance'; + +function formatMaybePhone(value: string | null | undefined, fallback: string) { + return value ? formatPhoneForDisplay(value) : fallback; +} export default async function CallFlowPage() { const business = await requireBusiness(); const demoMode = isPortfolioDemoMode(); - const billingAccess = getBusinessBillingAccessState(business); - const ownerNotifyPhoneOptedOut = - demoMode || !business.notifyPhone - ? false - : await isSmsRecipientOptedOut({ businessId: business.id, phone: business.notifyPhone }); - const successfulLeadCount = demoMode - ? 1 - : await db.lead.count({ - where: { - businessId: business.id, - OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }], - }, - }); - const managedTextingNumber = getManagedTextingNumber(business); - const managedTwilioSummary = getManagedTwilioStatusSummary(business); - const phoneSetupGate = getBusinessPhoneSetupGate(business); - const publicBusinessPhone = getPublicBusinessPhone(business); - const routingNumber = getBusinessRoutingNumber(business); - const readiness = { - ready: - Boolean(business.forwardingNumber) && - phoneSetupGate.complete && - managedTwilioSummary.messagingReady && - Boolean(business.notifyPhone) && - !ownerNotifyPhoneOptedOut && - billingAccess.billingActive, - blockers: [ - !managedTextingNumber ? { key: 'texting_line', label: 'Texting line', detail: 'CallbackCloser still needs to provision your business texting line.' } : null, - !business.forwardingNumber ? { key: 'routing', label: 'Owner answer number', detail: 'Add the owner or staff number that should receive live forwarded calls.' } : null, - !phoneSetupGate.complete ? { key: 'phone_path', label: getBusinessPhoneSetupPathLabel(business.phoneSetupPath), detail: phoneSetupGate.detail } : null, - !managedTwilioSummary.onboardingReady - ? { key: 'messaging', label: 'Messaging infrastructure', detail: managedTwilioSummary.nextStep } - : null, - !managedTwilioSummary.complianceReady - ? { - key: 'compliance', - label: - managedTwilioSummary.usesSharedPilotMessaging - ? 'Pilot sender setup' - : managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' - ? 'Toll-free verification' - : managedTwilioSummary.complianceTypeUnknown - ? 'Number type' - : 'A2P approval', - detail: managedTwilioSummary.description, - } - : null, - !business.notifyPhone || ownerNotifyPhoneOptedOut - ? { - key: 'owner_alerts', - label: 'Owner alerts', - detail: !business.notifyPhone - ? 'Add the owner mobile number so lead summaries reach the right phone.' - : 'The owner alert number is opted out and needs to reply START before go-live.' - } - : null, - !billingAccess.billingActive - ? { key: 'billing', label: 'Billing', detail: 'Activate billing so auto-texting can stay live on missed calls.' } - : null, - ].filter(Boolean) as Array<{ key: string; label: string; detail: string }>, - }; + const [notificationSettings, successfulLeadCount] = demoMode + ? [null, 1] + : await Promise.all([ + getBusinessNotificationSettingsForBusiness(business.id), + db.lead.count({ + where: { + businessId: business.id, + OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }], + }, + }), + ]); + + const customerFacingNumber = getPublicBusinessPhone(business) || getBusinessRoutingNumber(business); + const forwardingNumber = business.forwardingNumber; + const ownerAlertDestination = notificationSettings?.ownerPhone || business.notifyPhone || notificationSettings?.ownerEmail || null; + const systemStatus = getCustomerSystemStatus(business, successfulLeadCount); + const textRepliesReady = systemStatus.key === 'live'; const setupItems = [ { - key: 'routing', - label: 'Owner answer number ready', - detail: business.forwardingNumber - ? `Live calls ring ${formatPhoneForDisplay(business.forwardingNumber)}` - : 'Add the owner or staff number your team answers.', - complete: Boolean(business.forwardingNumber), - }, - { - key: 'phone_path', - label: getBusinessPhoneSetupPathLabel(business.phoneSetupPath), - detail: phoneSetupGate.detail, - complete: phoneSetupGate.complete, - }, - { - key: 'twilio', - label: 'CallbackCloser routing number assigned', - detail: managedTextingNumber - ? `Your CallbackCloser routing number is ${formatPhoneForDisplay(managedTextingNumber)}.` - : 'CallbackCloser still needs to provision or map your routing number.', - complete: Boolean(managedTextingNumber), + key: 'customer-number', + label: 'Customer calling number', + detail: customerFacingNumber + ? `Customers can call ${formatPhoneForDisplay(customerFacingNumber)}.` + : 'CallbackCloser is finishing the number customers should call.', + complete: Boolean(customerFacingNumber), }, { - key: 'messaging', - label: 'Messaging infrastructure ready', - detail: managedTwilioSummary.onboardingReady - ? 'Managed messaging, number assignment, and webhook sync are ready.' - : managedTwilioSummary.nextStep, - complete: managedTwilioSummary.onboardingReady, - }, - { - key: 'compliance', - label: managedTwilioSummary.complianceReady - ? managedTwilioSummary.usesSharedPilotMessaging - ? 'Pilot sender ready' - : managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' - ? 'Toll-free verification complete' - : 'A2P approved' - : managedTwilioSummary.usesSharedPilotMessaging - ? 'Pilot sender still needed' - : managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' - ? 'Toll-free verification in progress' - : managedTwilioSummary.complianceTypeUnknown - ? 'Number type still needed' - : 'A2P approval in progress', - detail: managedTwilioSummary.description, - complete: managedTwilioSummary.complianceReady, - }, - { - key: 'owner-alert', - label: 'Owner notifications ready', - detail: business.notifyPhone ? 'Owner notify phone is present for handoff texts.' : 'Add the owner mobile number for lead alerts.', - complete: Boolean(business.notifyPhone) && !ownerNotifyPhoneOptedOut, + key: 'answer-number', + label: 'Team answer number', + detail: forwardingNumber + ? `Live calls ring ${formatPhoneForDisplay(forwardingNumber)}.` + : 'Add the owner or staff number your team answers.', + complete: Boolean(forwardingNumber), }, { - key: 'billing', - label: 'Billing active', - detail: billingAccess.billingActive ? 'Automation can text back on live missed calls.' : 'Activate billing before live SMS follow-up resumes.', - complete: billingAccess.billingActive, + key: 'text-replies', + label: 'Missed-call text replies', + detail: textRepliesReady + ? 'Missed callers can receive the follow-up text automatically.' + : 'CallbackCloser is finishing this before automatic text replies go fully live.', + complete: textRepliesReady, }, { - key: 'test', - label: 'Successful test lead', - detail: successfulLeadCount > 0 ? `${successfulLeadCount} lead${successfulLeadCount === 1 ? '' : 's'} reached owner-alert stage.` : 'Run a missed-call test and verify the owner alert.', - complete: successfulLeadCount > 0, + key: 'owner-alerts', + label: 'Owner alerts', + detail: ownerAlertDestination + ? `Lead summaries go to ${ownerAlertDestination.startsWith('+') ? formatPhoneForDisplay(ownerAlertDestination) : ownerAlertDestination}.` + : 'Add the phone or email that should receive lead summaries.', + complete: Boolean(ownerAlertDestination), }, ]; - const allChecklistComplete = setupItems.every((item) => item.complete); - const systemStatus = getCustomerSystemStatus(business, successfulLeadCount); const flowSteps = [ - { - title: 'A caller reaches your connected business number', - detail: - business.phoneSetupPath === 'CURRENT_NUMBER_FORWARDING' - ? publicBusinessPhone && routingNumber - ? `Customers call ${formatPhoneForDisplay(publicBusinessPhone)}, which forwards into ${formatPhoneForDisplay(routingNumber)} so CallbackCloser can catch the missed-call moment.` - : phoneSetupGate.detail - : managedTextingNumber - ? `Calls hit ${formatPhoneForDisplay(managedTextingNumber)} so CallbackCloser can catch the missed-call moment.` - : 'CallbackCloser still needs to provision or map the routing number that will cover missed calls.', + { + title: 'A customer calls', + detail: customerFacingNumber + ? `They call ${formatPhoneForDisplay(customerFacingNumber)}, the number connected to missed-call recovery.` + : 'CallbackCloser will show the connected customer number here once setup is finished.', + }, + { + title: 'Your team gets the live call', + detail: forwardingNumber + ? `The call rings ${formatPhoneForDisplay(forwardingNumber)} so your team can answer normally.` + : 'Add the phone your team answers so live calls reach the right person.', }, { - title: 'CallbackCloser sees the missed call', - detail: - business.forwardedCallAnswerMode === 'PRESS_1_REQUIRED' - ? `Current missed-call timeout is ${business.missedCallSeconds} seconds. Forwarded calls only count as answered after your team presses 1, so voicemail pickups still fall back into recovery.` - : `Current missed-call timeout is ${business.missedCallSeconds} seconds before the recovery flow starts.`, + title: 'Missed callers get a quick text', + detail: 'If the call is missed, CallbackCloser asks what they need, how urgent it is, where they are, and when you should call back.', }, - { - title: 'The caller gets a text right away', - detail: managedTwilioSummary.complianceReady - ? managedTwilioSummary.usesSharedPilotMessaging - ? 'Pilot setup sends the conversation from the approved CallbackCloser messaging number while the business keeps its public number live.' - : 'The conversation collects the service type, urgency, ZIP, callback timing, and optional name without extra admin work.' - : managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' - ? 'The automated SMS handoff stays pending until the managed Twilio setup and toll-free verification are complete.' - : managedTwilioSummary.complianceTypeUnknown - ? 'The automated SMS handoff stays pending until the number type is selected and messaging compliance is recorded.' - : 'The automated SMS handoff stays pending until the managed Twilio setup and A2P approval are complete.', - }, { - title: 'You get the handoff ready to call', - detail: business.notifyPhone - ? `Qualified lead summaries are routed to ${formatPhoneForDisplay(business.notifyPhone)}.` - : 'Add an owner mobile number so the summary can be delivered.', + title: 'You get a lead summary', + detail: ownerAlertDestination + ? `CallbackCloser sends the summary to ${ownerAlertDestination.startsWith('+') ? formatPhoneForDisplay(ownerAlertDestination) : ownerAlertDestination}.` + : 'Add an owner alert destination so qualified lead summaries reach you.', }, ]; return (
- Call Flow -
-

Call flow and activation

-

- This is exactly what happens when a missed call occurs, what is already live, and what still needs attention before a confident first test. -

-
-
- - {allChecklistComplete ? ( - - - 🎉 Your system is live — run a test call now - - Messaging, compliance, and test-lead handoff are all complete. One more test call is the fastest way to confirm everything still feels right. - - - - {managedTextingNumber ? ( - - Run test call - - ) : null} + How it works +
+
+

How missed calls are handled

+

+ A simple view of what callers experience, where live calls ring, and where lead summaries are sent. +

+
+
+ + Edit settings + - Open Recovered Leads + Open leads - - - ) : null} +
+
+
- Current call flow - Business-facing steps from missed call to owner handoff. + Caller experience + From incoming call to ready-to-call lead summary. {flowSteps.map((step, index) => ( @@ -258,38 +150,39 @@ export default async function CallFlowPage() { - Next activation move - {systemStatus.description} + Current numbers + Use these to confirm the owner-facing call path. - - {readiness.ready ? ( -
- Routing, notifications, billing, managed setup, and messaging compliance look ready. Run the missed-call test and confirm the owner alert lands. -
- ) : ( -
-

Action needed before go-live

-

- {readiness.blockers.length} blocker{readiness.blockers.length === 1 ? '' : 's'} still need attention before the system is operationally ready for live customer messaging. -

-
    - {readiness.blockers.map((blocker) => ( -
  • - {blocker.label}: {blocker.detail} -
  • - ))} -
-
- )} + +
+

Number customers call

+

{formatMaybePhone(customerFacingNumber, 'Setup in progress')}

+
+
+

Number your team answers

+

{formatMaybePhone(forwardingNumber, 'Not saved yet')}

+
+
+

Lead summary destination

+

+ {ownerAlertDestination + ? ownerAlertDestination.startsWith('+') + ? formatPhoneForDisplay(ownerAlertDestination) + : ownerAlertDestination + : 'Not saved yet'} +

+
- - Open Business Settings - - - Open Recovered Leads + {customerFacingNumber ? ( + + Place a test call + + ) : null} + + Update business settings - Open Billing + Open billing
diff --git a/app/app/conversations/page.tsx b/app/app/conversations/page.tsx index 3d0275b..9b850b3 100644 --- a/app/app/conversations/page.tsx +++ b/app/app/conversations/page.tsx @@ -1,11 +1,10 @@ import Link from 'next/link'; -import { MessageDirection } from '@prisma/client'; import { Badge } from '@/components/ui/badge'; import { buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { requireBusiness } from '@/lib/auth'; -import { getConversationDetailForBusiness, listConversationsForBusiness } from '@/lib/business-access'; +import { listConversationsForBusiness } from '@/lib/business-access'; import { formatDateTime, getLeadCallbackState, @@ -15,22 +14,15 @@ import { leadStatusLabels, } from '@/lib/lead-presenters'; import { formatPhoneForDisplay } from '@/lib/phone'; -import { getPortfolioDemoLeadDetail, getPortfolioDemoLeads, isPortfolioDemoMode } from '@/lib/portfolio-demo'; +import { getPortfolioDemoLeads, isPortfolioDemoMode } from '@/lib/portfolio-demo'; -export default async function ConversationsPage({ searchParams }: { searchParams?: Record }) { +export default async function ConversationsPage() { const business = await requireBusiness(); const demoMode = isPortfolioDemoMode(); const leads = demoMode ? getPortfolioDemoLeads(null).filter((lead) => lead.lastInboundAt || lead.lastOutboundAt) : await listConversationsForBusiness(business.id); - const selectedLeadId = typeof searchParams?.leadId === 'string' ? searchParams.leadId : leads[0]?.id; - const selectedLead = selectedLeadId - ? demoMode - ? getPortfolioDemoLeadDetail(selectedLeadId) - : await getConversationDetailForBusiness(business.id, selectedLeadId) - : null; - return (
@@ -38,119 +30,69 @@ export default async function ConversationsPage({ searchParams }: { searchParams Conversations

Lead conversations

-

Review the latest replies first, then jump back to the lead that is most likely to close.

+

See the latest caller replies. Open a lead to call back or mark the outcome.

- Open Recovered Leads + Open leads
-
- - - Open conversations - {leads.length} conversation{leads.length === 1 ? '' : 's'} where a missed caller texted back - - - {leads.length === 0 ? ( -
-

No conversations yet

-

Once your system is live, missed callers who text back will appear here automatically.

-
- - Run your first test call - - - Check setup - -
+ + + Latest conversations + {leads.length} conversation{leads.length === 1 ? '' : 's'} where a missed caller texted back + + + {leads.length === 0 ? ( +
+

No conversations yet

+

Once missed callers reply by text, their conversations will appear here.

+
+ + Open leads + + + How missed calls work +
- ) : ( - leads.map((lead) => { - const latestMessage = lead.messages[0]; - const selected = lead.id === selectedLead?.id; - return ( - -
-
-

{formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone)}

-

{lead.callerName || lead.contactName || 'Name pending'}

-
-
- {leadStatusLabels[lead.status]} - - {leadReadinessLabels[lead.readiness]} - -
+
+ ) : ( + leads.map((lead) => { + const latestMessage = lead.messages[0]; + return ( + +
+
+

{lead.callerName || lead.contactName || formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone)}

+

+ {lead.callerName || lead.contactName ? formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone) : 'Name pending'} +

-

- {lead.summary || latestMessage?.body || 'No message preview available.'} -

-
- {lead.serviceType || lead.serviceRequested || getLeadCallbackState(lead)} - {formatDateTime(getLeadLastActivityAt(lead))} +
+ {leadStatusLabels[lead.status]} + + {leadReadinessLabels[lead.readiness]} +
- - ); - }) - )} - - - - - - Conversation detail - - {selectedLead ? formatPhoneForDisplay(selectedLead.callerPhoneNormalized || selectedLead.callerPhone) : 'Select a conversation'} - - - - {!selectedLead ? ( -
- {leads.length === 0 - ? 'Once your system is live, conversations will appear here with the full SMS thread and callback context.' - : 'Pick a conversation on the left to review the full thread.'} -
- ) : ( - <> -
- {leadStatusLabels[selectedLead.status]} - {getLeadCallbackState(selectedLead)} - - {leadReadinessLabels[selectedLead.readiness]} - - - Open lead details - -
-
-

Lead summary

-

{selectedLead.summary || 'CallbackCloser is still gathering the summary for this lead.'}

-
-
- {selectedLead.messages.map((message) => ( -
-
- {message.direction === MessageDirection.OUTBOUND ? 'CallbackCloser' : 'Lead'} - {formatDateTime(message.createdAt)} -
-

{message.body}

-
- ))} -
- - )} -
-
-
+
+

+ {latestMessage?.body || lead.summary || 'No message preview available.'} +

+
+ {lead.serviceType || lead.serviceRequested || getLeadCallbackState(lead)} + Last activity {formatDateTime(getLeadLastActivityAt(lead))} +
+ + ); + }) + )} + +
); } diff --git a/app/app/leads/[leadId]/page.tsx b/app/app/leads/[leadId]/page.tsx index 06af377..8dd94d8 100644 --- a/app/app/leads/[leadId]/page.tsx +++ b/app/app/leads/[leadId]/page.tsx @@ -34,7 +34,7 @@ function resolveSafeReturnPath(value: string | null | undefined) { function getLeadActionSummary(status: LeadStatus) { if (isLeadClosedWonStatus(status)) { - return 'This lead is marked Closed (Won). Keep the details here for reference.'; + return 'This lead is marked booked. Keep the details here for reference.'; } if (isLeadLostStatus(status)) { @@ -45,7 +45,7 @@ function getLeadActionSummary(status: LeadStatus) { return 'You have already made contact. Update the final outcome when the customer decides.'; } - return 'Call this lead back, then mark it Closed (Won) or Lost so your conversion summary stays accurate.'; + return 'Call this lead back, then mark it contacted, booked, or lost.'; } function LeadStatusActionForm({ @@ -113,6 +113,7 @@ export default async function LeadDetailPage({ const recordingHref = lead.call?.recordingUrl ? `/api/leads/${lead.id}/recording` : null; const nextStepLabel = getLeadNextStepLabel(lead.status); const isOpenLead = isLeadOpenStatus(lead.status); + const createdLabel = formatDateTime(lead.createdAt); return (
@@ -173,14 +174,14 @@ export default async function LeadDetailPage({ status="BOOKED" redirectTo={detailRedirectTo} successRedirectTo={successRedirectTo} - label="Mark as Closed (Won)" + label="Mark booked" />
@@ -192,6 +193,7 @@ export default async function LeadDetailPage({

{customerName}

{customerPhone}

Created {formatRelativeTime(lead.createdAt)}.

+

{createdLabel}

Service

@@ -229,7 +231,7 @@ export default async function LeadDetailPage({ className={`rounded-2xl border p-3 text-sm ${message.direction === MessageDirection.OUTBOUND ? 'bg-primary/5' : 'bg-card'}`} >
- {message.direction === MessageDirection.OUTBOUND ? 'CallbackCloser' : 'Lead'} + {message.direction === MessageDirection.OUTBOUND ? 'CallbackCloser' : 'Caller replied'}
{message.status && message.status.toLowerCase() !== 'delivered' ? ( diff --git a/app/app/leads/page.tsx b/app/app/leads/page.tsx index fe9c624..3313f57 100644 --- a/app/app/leads/page.tsx +++ b/app/app/leads/page.tsx @@ -2,13 +2,11 @@ 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 { requireBusiness } from '@/lib/auth'; import { listAllDashboardLeadsForBusiness } from '@/lib/business-access'; -import { getLeadOutcomeSummary } from '@/lib/lead-outcomes'; import { getLeadLastActivityAt, isLeadOpenStatus, leadStatusLabels } from '@/lib/lead-presenters'; import { getPortfolioDemoLeads, isPortfolioDemoMode } from '@/lib/portfolio-demo'; import { cn } from '@/lib/utils'; @@ -112,7 +110,6 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc const view = statusFilter ? 'all' : parseInboxView(typeof searchParams?.view === 'string' ? searchParams.view : undefined); const allLeads = demoMode ? getPortfolioDemoLeads(null) : await listAllDashboardLeadsForBusiness(business.id); const hasLeads = allLeads.length > 0; - const outcomeSummary = getLeadOutcomeSummary(allLeads); const filteredLeads = statusFilter ? allLeads.filter((lead) => lead.status === statusFilter) @@ -141,7 +138,7 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc }, { key: 'booked' as const, - label: 'Closed', + label: 'Booked', href: buildLeadsHref('booked'), count: allLeads.filter((lead) => lead.status === LeadStatus.BOOKED).length, active: !statusFilter && view === 'booked', @@ -158,17 +155,17 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc const listDescription = statusFilter ? `Showing ${leadStatusLabels[statusFilter].toLowerCase()} leads. Open a lead to act on it.` : view === 'attention' - ? 'Showing leads that still need action. Open one to call back or mark the final outcome.' - : 'Open any lead to review the conversation and mark it closed or lost from the detail page.'; + ? 'Showing leads that still need action. Open one to call back or mark the outcome.' + : 'Open any lead to review the conversation and mark it contacted, booked, or lost.'; return (
Lead inbox
-

Scan the list. Open the lead. Take action.

+

Lead inbox

- This page is only for scanning. Click a lead to open the detail page where the real follow-up work happens. + Scan missed-call leads, then open one to call back and mark the outcome.

@@ -176,11 +173,6 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc {error ?
{error}
: null} {saved ?
Lead updated.
: null} - - {!hasLeads ? ( @@ -200,7 +192,7 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc
- Inbox + Leads {listDescription}
diff --git a/app/app/page.tsx b/app/app/page.tsx index f64361a..f70a0b1 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -1,9 +1,8 @@ import { LeadReadiness, LeadStatus } from '@prisma/client'; -import { HomeDashboard, type DashboardLeadCard } from '@/components/home-dashboard'; +import { HomeDashboard, type DashboardLeadCard, type DashboardStatusItem, type DashboardSummaryCard } 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 { formatRelativeTime, getLeadLastActivityAt, isLeadOpenStatus } from '@/lib/lead-presenters'; import { formatPhoneForDisplay } from '@/lib/phone'; @@ -14,10 +13,6 @@ 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; @@ -61,9 +56,9 @@ function getRecommendedNextAction(lead: { readiness: LeadReadiness; lastInboundAt: Date | null; }) { - if (lead.status === LeadStatus.BOOKED) return 'Booked. Keep this lead as proof of recovered revenue.'; + if (lead.status === LeadStatus.BOOKED) return 'Booked.'; 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.status === LeadStatus.CONTACTED) return 'Follow up and mark booked or lost.'; 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.'; @@ -85,38 +80,29 @@ function toLeadCard( summary: string | null; status: LeadStatus; readiness: LeadReadiness; - qualifiedAt: Date | null; - notifiedAt: Date | null; - ownerNotifiedAt: Date | null; createdAt: Date; lastInteractionAt: Date | null; lastInboundAt: Date | null; + lastOutboundAt: Date | null; }, - demoMode: boolean, ): DashboardLeadCard { + const phone = lead.callerPhoneNormalized || lead.callerPhone; + return { id: lead.id, customerName: getCustomerName(lead), + customerPhone: formatPhoneForDisplay(phone), 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), + createdLabel: formatRelativeTime(lead.createdAt), + lastActivityLabel: formatRelativeTime(getLeadLastActivityAt(lead)), summary: lead.summary || - 'CallbackCloser is still collecting the AI summary for this lead. Open the conversation or call back to keep the lead moving.', + 'CallbackCloser is still collecting the summary for this lead. Open the lead or call back to keep it 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), + callHref: `tel:${phone}`, leadHref: buildLeadDetailHref(lead.id), - sourceLabel: demoMode ? 'Demo lead' : null, }; } @@ -129,6 +115,38 @@ function getDateKey(value: Date, timeZone: string) { }).format(value); } +function getStatusItems(input: { + ownerAlertsReady: boolean; + systemStatus: ReturnType; +}): DashboardStatusItem[] { + return [ + { + label: 'Texting', + value: input.systemStatus.key === 'live' ? 'Active' : input.systemStatus.key === 'activating' ? 'Pending' : 'Not live', + detail: + input.systemStatus.key === 'live' + ? 'Missed callers can receive automatic text replies.' + : input.systemStatus.description, + tone: input.systemStatus.key === 'live' ? 'success' : input.systemStatus.key === 'activating' ? 'secondary' : 'outline', + }, + { + label: 'Owner alerts', + value: input.ownerAlertsReady ? 'Active' : 'Needs setup', + detail: input.ownerAlertsReady ? 'Lead summaries can reach the owner.' : 'Add an owner phone or email in settings.', + tone: input.ownerAlertsReady ? 'success' : 'outline', + }, + ]; +} + +function isMissedCallToday( + lead: unknown, + input: { timeZone: string; todayKey: string }, +) { + if (!lead || typeof lead !== 'object' || !('call' in lead)) return false; + const call = (lead as { call?: { missed: boolean; createdAt: Date } | null }).call; + return Boolean(call?.missed && getDateKey(call.createdAt, input.timeZone) === input.todayKey); +} + export default async function AppHomePage({ searchParams, }: { @@ -146,7 +164,11 @@ export default async function AppHomePage({ const successfulLeadCount = allLeads.filter((lead) => lead.ownerNotifiedAt || lead.notifiedAt).length; const systemStatus = getCustomerSystemStatus(business, successfulLeadCount); - const metrics = buildRecoveryMetrics(allLeads, business.averageJobValueCents); + const todayKey = getDateKey(new Date(), business.timezone); + const missedCallsToday = allLeads.filter((lead) => isMissedCallToday(lead, { timeZone: business.timezone, todayKey })).length; + const needsFollowUpCount = allLeads.filter((lead) => isLeadOpenStatus(lead.status)).length; + const newLeadCount = allLeads.filter((lead) => lead.status === LeadStatus.NEW).length; + const bookedLeadCount = allLeads.filter((lead) => lead.status === LeadStatus.BOOKED).length; const attentionLeads = allLeads .filter((lead) => isLeadOpenStatus(lead.status)) @@ -164,21 +186,14 @@ export default async function AppHomePage({ return rightPriority.activityAt - leftPriority.activityAt; }) - .slice(0, 4) - .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) + .slice(0, 6) + .map((lead) => toLeadCard(lead)); + + const recentLeads = [...allLeads] .sort((left, right) => getLeadLastActivityAt(right).getTime() - getLeadLastActivityAt(left).getTime()) .slice(0, 6) - .map((lead) => toLeadCard(lead, demoMode)); + .map((lead) => toLeadCard(lead)); - const phoneLineConnected = Boolean(business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber); const ownerAlertsReady = Boolean( business.notifyPhone || notificationSettings?.ownerPhone || @@ -186,36 +201,30 @@ export default async function AppHomePage({ (notificationSettings?.notifyEmail && notificationSettings.ownerEmail), ); - const setupChecklistItems = [ + const summaryCards: DashboardSummaryCard[] = [ { - key: 'phone-line', - label: 'Phone line connected', - detail: phoneLineConnected - ? 'Business line ready.' - : 'Connect the line that should trigger missed-call recovery.', - state: phoneLineConnected ? ('complete' as const) : ('pending' as const), + label: 'New leads', + value: newLeadCount, + detail: 'Missed callers not worked yet.', + href: '/app/leads?status=new', }, { - key: 'owner-alerts', - label: 'Owner alerts ready', - detail: ownerAlertsReady - ? 'Owner handoff route ready.' - : 'Add an owner phone or email for lead handoffs.', - state: ownerAlertsReady ? ('complete' as const) : ('pending' as const), + label: 'Needs follow-up', + value: needsFollowUpCount, + detail: 'Open leads that still need action.', + href: '/app/leads?view=attention', }, { - key: 'texting-activation', - label: 'Texting activation in progress', - detail: - systemStatus.key === 'live' - ? 'Texting is live.' - : systemStatus.description, - state: - systemStatus.key === 'live' - ? ('complete' as const) - : systemStatus.key === 'activating' - ? ('in_progress' as const) - : ('pending' as const), + label: 'Booked leads', + value: bookedLeadCount, + detail: 'Leads marked booked.', + href: '/app/leads?view=booked', + }, + { + label: 'Missed calls today', + value: missedCallsToday, + detail: 'Missed calls captured today.', + href: '/app/leads', }, ]; @@ -228,14 +237,11 @@ export default async function AppHomePage({ 0} isDemoMode={demoMode} - metrics={metrics} - queueLeads={queueLeads} - setupChecklistItems={setupChecklistItems} - showSetupChecklist={systemStatus.key !== 'live'} + recentLeads={recentLeads} simulatorHref="/simulator" + statusItems={getStatusItems({ ownerAlertsReady, systemStatus })} + summaryCards={summaryCards} /> ); } diff --git a/app/app/settings/page.tsx b/app/app/settings/page.tsx index 34be29a..5e11494 100644 --- a/app/app/settings/page.tsx +++ b/app/app/settings/page.tsx @@ -1,143 +1,63 @@ import Link from 'next/link'; -import { MessagingComplianceFields } from '@/components/messaging-compliance-fields'; -import { setBusinessProvisioningStatusAction } from '@/app/admin/actions'; -import { TwilioSetupChecklist } from '@/components/twilio-setup-checklist'; import { Badge } from '@/components/ui/badge'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { getAdminSession } from '@/lib/admin'; -import { getAdminTestSmsConfidenceState } from '@/lib/admin-dashboard'; -import { getTwilioWebhookSnapshot } from '@/lib/admin-provisioning'; import { requireBusiness } from '@/lib/auth'; +import { averageJobValueCentsToDollars } from '@/lib/business-settings'; import { getBusinessNotificationSettingsForBusiness } from '@/lib/business-access'; -import { - TwilioSetupTone, - buildTwilioSetupFlow, - businessPhonePathOptions, - forwardedCallAnswerOptions, - messagingSetupOptions, - twilioAccountModeOptions, -} from '@/lib/twilio-setup'; +import { getBusinessRoutingNumber, getPublicBusinessPhone } from '@/lib/business-phone-setup'; import { db } from '@/lib/db'; -import { - getManagedTextingNumber, - managedTwilioStatusLabels, - messagingComplianceTypeLabels, - tollFreeVerificationStatusLabels, -} from '@/lib/managed-twilio-status'; import { formatPhoneForDisplay } from '@/lib/phone'; import { isPortfolioDemoMode } from '@/lib/portfolio-demo'; -import { averageJobValueCentsToDollars } from '@/lib/business-settings'; - -import { - buyTwilioNumberAction, - resyncTwilioWebhooksAction, - saveBusinessSettingsAction, - saveBusinessTwilioAdminOverridesAction, - saveBusinessTwilioSetupChoiceAction, - sendBusinessTwilioTestSmsAction, -} from './actions'; +import { getCustomerSystemStatus } from '@/lib/system-status'; -const adminChangedFieldLabels: Record = { - ownerPhone: 'owner alert phone', - twilioAccountMode: 'Twilio account mode', - phoneSetupPath: 'business number path', - forwardedCallAnswerMode: 'forwarded call answer mode', - messagingSetupMode: 'messaging setup mode', - twilioNumberSetupMode: 'routing number mode', - twilioSubaccountSid: 'Twilio subaccount SID', - twilioPhoneNumber: 'Twilio number', - twilioPhoneNumberSid: 'Twilio number SID', - twilioMessagingServiceSid: 'messaging service SID', - forwardingVerificationStatus: 'forwarding verification status', - forwardingVerificationNote: 'forwarding verification note', - portingStatus: 'porting status', - portingNotes: 'porting notes', - messagingComplianceType: 'number type', - a2pCustomerProfileSid: 'A2P customer profile SID', - a2pBrandSid: 'A2P brand SID', - a2pCampaignSid: 'A2P campaign SID', - a2pFailureReason: 'A2P blocker note', - tollFreeVerificationStatus: 'toll-free verification status', - tollFreeVerificationSid: 'toll-free verification SID', - tollFreeVerificationNote: 'toll-free blocker note', - managedTwilioStatus: 'A2P status', -}; +import { saveBusinessSettingsAction } from './actions'; -type BusinessTwilioDefaults = { - twilioAccountMode: string; - phoneSetupPath: string; - forwardedCallAnswerMode: string; - messagingSetupMode: string; - twilioNumberSetupMode: string; - twilioSubaccountSid: string; - twilioPhoneNumber: string; - twilioPhoneNumberSid: string; - twilioMessagingServiceSid: string; - forwardingVerificationStatus: string; - forwardingVerificationNote: string; - portingStatus: string; - portingNotes: string; - messagingComplianceType: string; - a2pCustomerProfileSid: string; - a2pBrandSid: string; - a2pCampaignSid: string; - a2pFailureReason: string; - tollFreeVerificationStatus: string; - tollFreeVerificationSid: string; - tollFreeVerificationNote: string; - managedTwilioStatus: string; - ownerPhone: string; -}; +type StatusTone = 'success' | 'secondary' | 'outline'; -function HiddenBusinessTwilioFields({ - defaults, - exclude = [], -}: { - defaults: BusinessTwilioDefaults; - exclude?: Array; -}) { - return ( - <> - {Object.entries(defaults).map(([name, value]) => { - if (exclude.includes(name as keyof BusinessTwilioDefaults)) return null; - return ; - })} - - ); +function getStatusBadge(complete: boolean, pendingLabel = 'Needs attention') { + return { + label: complete ? 'Ready' : pendingLabel, + variant: complete ? ('success' as const) : ('outline' as const), + }; } -function getBadgeVariant(tone: TwilioSetupTone) { - if (tone === 'success') return 'success' as const; - if (tone === 'attention') return 'destructive' as const; - if (tone === 'pending') return 'outline' as const; - return 'secondary' as const; +function getTextingStatus(systemStatus: ReturnType) { + if (systemStatus.key === 'live') { + return { + label: 'Text replies active', + detail: 'Missed callers can receive the follow-up text automatically.', + variant: 'success' as StatusTone, + }; + } + + if (systemStatus.key === 'activating') { + return { + label: 'Text replies being finished', + detail: 'CallbackCloser is still finishing the behind-the-scenes setup before automatic texting is fully live.', + variant: 'secondary' as StatusTone, + }; + } + + return { + label: 'Text replies not live yet', + detail: 'CallbackCloser will notify you when missed-call texting is ready to test.', + variant: 'outline' as StatusTone, + }; } export default async function SettingsPage({ searchParams }: { searchParams?: Record }) { const business = await requireBusiness(); - const averageJobValue = averageJobValueCentsToDollars(business.averageJobValueCents); const demoMode = isPortfolioDemoMode(); - const adminSession = demoMode ? null : await getAdminSession(); + const averageJobValue = averageJobValueCentsToDollars(business.averageJobValueCents); const error = typeof searchParams?.error === 'string' ? searchParams.error : undefined; const saved = searchParams?.saved === '1'; - const numberBought = searchParams?.numberBought === '1'; - const twilioSynced = searchParams?.twilioSynced === '1'; - const adminTwilioSaved = searchParams?.adminTwilioSaved === '1'; - const twilioTestSms = searchParams?.twilioTestSms === '1'; - const existingNumberIntent = searchParams?.existingNumberIntent === '1'; - const adminChangedRaw = typeof searchParams?.adminChanged === 'string' ? searchParams.adminChanged : ''; - const adminChanged = adminChangedRaw - .split(',') - .map((field) => field.trim()) - .filter(Boolean) - .map((field) => adminChangedFieldLabels[field] || field); - const [notificationSettings, successfulLeadCount, testSmsEvents, webhookSnapshot] = demoMode - ? [null, 1, [], null] + const [notificationSettings, successfulLeadCount] = demoMode + ? [null, 1] : await Promise.all([ getBusinessNotificationSettingsForBusiness(business.id), db.lead.count({ @@ -146,519 +66,66 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }], }, }), - db.businessOperatorEvent.findMany({ - where: { - businessId: business.id, - type: { - startsWith: 'admin.test_sms_', - }, - }, - orderBy: { createdAt: 'desc' }, - take: 20, - select: { - type: true, - status: true, - createdAt: true, - }, - }), - getTwilioWebhookSnapshot(business), ]); - const testSmsState = getAdminTestSmsConfidenceState(testSmsEvents); - const managedTextingNumber = getManagedTextingNumber(business); - const setupFlow = buildTwilioSetupFlow({ - business, - notificationSettings, - ownerConnected: true, - successfulLeadCount, - testSmsState, - webhookSnapshot, - }); - const twilioDefaults: BusinessTwilioDefaults = { - twilioAccountMode: business.twilioAccountMode, - phoneSetupPath: business.phoneSetupPath, - forwardedCallAnswerMode: business.forwardedCallAnswerMode, - messagingSetupMode: business.messagingSetupMode, - twilioNumberSetupMode: business.twilioNumberSetupMode, - twilioSubaccountSid: business.twilioSubaccountSid || '', - twilioPhoneNumber: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '', - twilioPhoneNumberSid: business.twilioPrimaryNumberSid || business.twilioPhoneNumberSid || '', - twilioMessagingServiceSid: business.twilioMessagingServiceSid || '', - forwardingVerificationStatus: business.forwardingVerificationStatus, - forwardingVerificationNote: business.forwardingVerificationNote || '', - portingStatus: business.portingStatus, - portingNotes: business.portingNotes || '', - messagingComplianceType: business.messagingComplianceType, - a2pCustomerProfileSid: business.a2pCustomerProfileSid || '', - a2pBrandSid: business.a2pBrandSid || '', - a2pCampaignSid: business.a2pCampaignSid || '', - a2pFailureReason: business.a2pFailureReason || '', - tollFreeVerificationStatus: business.tollFreeVerificationStatus, - tollFreeVerificationSid: business.tollFreeVerificationSid || '', - tollFreeVerificationNote: business.tollFreeVerificationNote || '', - managedTwilioStatus: business.managedTwilioStatus, - ownerPhone: notificationSettings?.ownerPhone || business.notifyPhone || '', - }; - - const bannerAction = - setupFlow.banner.stepKey === 'voice_webhook_synced' || - setupFlow.banner.stepKey === 'sms_webhook_synced' || - setupFlow.banner.stepKey === 'status_callback_synced' ? ( - - - - ) : setupFlow.banner.stepKey === 'safe_to_mark_live' && adminSession?.isAdmin && setupFlow.safeToMarkLive && business.provisioningStatus !== 'LIVE' ? ( -
- - - -
- ) : ( - - {setupFlow.banner.stepKey === 'missed_call_validated' ? 'Open call flow' : 'Review step'} - - ); - - const setupSteps = setupFlow.steps.map((step) => { - if (step.key === 'owner_connected') { - return { - ...step, - body: ( -
- Connected - Your signed-in business account is already attached to this workspace. -
- ), - }; - } + const publicBusinessPhone = getPublicBusinessPhone(business); + const connectedNumber = getBusinessRoutingNumber(business); + const ownerAlertPhone = notificationSettings?.ownerPhone || business.notifyPhone || null; + const ownerAlertEmail = notificationSettings?.ownerEmail || null; + const systemStatus = getCustomerSystemStatus(business, successfulLeadCount); + const textingStatus = getTextingStatus(systemStatus); + const businessNumberStatus = getStatusBadge(Boolean(publicBusinessPhone || connectedNumber), 'Not connected yet'); + const ownerAlertStatus = getStatusBadge(Boolean(ownerAlertPhone || ownerAlertEmail), 'Add destination'); - if (step.key === 'account_mode') { - return { - ...step, - body: ( -
- - - -
- {twilioAccountModeOptions.map((option) => ( - - ))} -
- -
- ), - }; - } - - if (step.key === 'number_path') { - return { - ...step, - body: ( -
- -
- {businessPhonePathOptions.map((option) => ( - - ))} -
-
-

Forwarded call answer confirmation

- {forwardedCallAnswerOptions.map((option) => ( - - ))} -
-
-

Messaging setup mode

- {messagingSetupOptions.map((option) => ( - - ))} -
- {setupFlow.phoneSetupPath === 'PORT_EXISTING_NUMBER' ? ( -

{setupFlow.existingNumberMessage}

- ) : null} - -
- ), - }; - } - - if (step.key === 'account_ready') { - return { - ...step, - body: adminSession?.isAdmin ? ( -
- - {setupFlow.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE' ? ( -
- Pilot setup uses the approved CallbackCloser sender, so a dedicated subaccount is optional during founder-operated onboarding. -
- ) : setupFlow.accountMode === 'BUSINESS_SUBACCOUNT' ? ( -
- - -

Paste a known subaccount SID to reuse it, or leave this blank and let provisioning create one.

-
- ) : ( -
- Main account mode is active. CallbackCloser will use the parent Twilio account directly for this business. -
- )} - - - ) : ( -

- {setupFlow.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE' - ? 'Pilot setup is founder-operated, so a dedicated business subaccount is optional while SMS uses the approved CallbackCloser sender.' - : setupFlow.accountMode === 'BUSINESS_SUBACCOUNT' - ? 'CallbackCloser will create or reuse a dedicated Twilio subaccount for this business during setup.' - : 'CallbackCloser will keep this business on the parent Twilio account.'} -

- ), - }; - } - - if (step.key === 'messaging_service_ready') { - return { - ...step, - body: adminSession?.isAdmin ? ( -
- - {setupFlow.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE' ? ( -
- Pilot setup: current number forwards to CallbackCloser; SMS sends from the approved CallbackCloser messaging number. -
- ) : null} -
- - -
- - - ) : ( -

- {setupFlow.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE' - ? `Pilot sender: ${business.twilioMessagingServiceSid || 'Approved CallbackCloser Messaging Service not recorded yet.'}` - : `Current value: ${business.twilioMessagingServiceSid || 'Not recorded yet.'}`} -

- ), - }; - } - - if (step.key === 'number_assigned') { - return { - ...step, - body: ( -
-
-
-

Current number

-

- {managedTextingNumber ? formatPhoneForDisplay(managedTextingNumber) : 'No business number recorded yet'} -

-
-
-

Number path

-

{setupFlow.phoneSetupPathLabel}

-
-
- - {setupFlow.phoneSetupPath !== 'PORT_EXISTING_NUMBER' ? ( -
-
- - -
- -
- ) : ( -
-

{setupFlow.existingNumberMessage}

-

- CallbackCloser does not automate porting in this step. Once the number is live in Twilio, an operator can save the routing number mapping. -

-
- )} - - {adminSession?.isAdmin ? ( -
- -
- - -
-
- - -
-
- -
- - ) : null} -
- ), - }; - } - - if (step.key === 'forwarding_verified') { - return { - ...step, - body: ( -
- {setupFlow.phoneSetupPath === 'CURRENT_NUMBER_FORWARDING' ? ( - <> -

- Forward your current public business number into the CallbackCloser routing number shown above. CallbackCloser marks this step complete after a fresh inbound test call reaches the routing number or an operator confirms it manually. -

-

- Public business number: {business.publicBusinessPhone ? formatPhoneForDisplay(business.publicBusinessPhone) : 'Not saved yet'} -

- - ) : setupFlow.phoneSetupPath === 'PORT_EXISTING_NUMBER' ? ( -

Porting stays admin-assisted for now. Keep the porting status updated, then save the Twilio routing number after the port completes.

- ) : ( -

The new CallbackCloser number becomes your connected business line once it is assigned and working.

- )} -
- ), - }; - } - - if (step.key === 'voice_webhook_synced') { - return { - ...step, - body: ( -
- - Voice, SMS, and status callback sync all happen together from this page. -
- ), - }; - } - - if (step.key === 'a2p_status_recorded') { - return { - ...step, - body: adminSession?.isAdmin ? ( -
- - ({ value, label }))} - managedTwilioStatusOptions={Object.entries(managedTwilioStatusLabels).map(([value, label]) => ({ value, label }))} - tollFreeVerificationStatusOptions={Object.entries(tollFreeVerificationStatusLabels).map(([value, label]) => ({ - value, - label, - }))} - showSubmitButton - submitButtonVariant="outline" - /> - - ) : ( -
- {step.stateLabel} - CallbackCloser keeps this launch status truthful even while review is pending. -
- ), - }; - } - - if (step.key === 'test_sms_delivered') { - return { - ...step, - body: ( -
-
- - -

Send a real test text from the current business line before you treat this setup as launch-ready.

-
- -
- ), - }; - } - - if (step.key === 'missed_call_validated') { - return { - ...step, - body: ( -
- - Open call flow - - - Open recovered leads - -
- ), - }; - } - - if (step.key === 'safe_to_mark_live') { - return { - ...step, - body: adminSession?.isAdmin ? ( -
-
- - - -
-
- - - -
-
- ) : ( -

Launch state is managed after the setup checklist, test SMS, and missed-call validation all clear.

- ), - }; - } - - return step; - }); + const statusCards = [ + { + key: 'business-number', + title: 'Business number', + badge: businessNumberStatus, + detail: publicBusinessPhone + ? `Customers can keep calling ${formatPhoneForDisplay(publicBusinessPhone)}.` + : connectedNumber + ? `Use ${formatPhoneForDisplay(connectedNumber)} for missed-call coverage.` + : 'CallbackCloser is still finishing the number setup for this account.', + }, + { + key: 'text-replies', + title: 'Text replies', + badge: { + label: textingStatus.variant === 'success' ? 'Ready' : textingStatus.variant === 'secondary' ? 'In progress' : 'Not live yet', + variant: textingStatus.variant, + }, + detail: textingStatus.detail, + }, + { + key: 'owner-alerts', + title: 'Owner alerts', + badge: ownerAlertStatus, + detail: ownerAlertPhone + ? `Lead summaries go to ${formatPhoneForDisplay(ownerAlertPhone)}.` + : ownerAlertEmail + ? `Lead summaries go to ${ownerAlertEmail}.` + : 'Add the phone or email that should receive lead summaries.', + }, + ]; return (
- Twilio Setup + Settings
-

Connect your business number

+

Business settings

- One guided place to choose the Twilio account mode, connect the right business-number path, and keep launch status honest without digging through admin screens. + Keep the business details and owner alert destinations current. CallbackCloser handles the phone and messaging setup in the background.

- Open call flow + How missed calls work - Open recovered leads + Open leads
@@ -666,34 +133,27 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re {error ?
{error}
: null} {saved ?
Business settings saved.
: null} - {numberBought ?
A new business number was provisioned for the selected Twilio account mode.
: null} - {existingNumberIntent ? ( -
- Porting path saved. CallbackCloser will keep this business on the admin-assisted porting workflow until the number is active and mapped into Twilio. -
- ) : null} - {twilioSynced ?
Webhook sync completed for the current business number.
: null} - {twilioTestSms ?
Test SMS requested. Wait for delivery before you treat the setup as launch-ready.
: null} - {adminTwilioSaved ? ( -
- Internal setup state saved{adminChanged.length > 0 ? `: ${adminChanged.join(', ')}.` : '.'} -
- ) : null} -
- -
+
+ {statusCards.map((item) => ( + + +
+ {item.title} + {item.badge.label} +
+
+ +

{item.detail}

+
+
+ ))} +
- Business basics - Keep the public business number, live call path, and owner routing current while setup moves forward. + Business details + These details shape lead summaries, revenue estimates, and owner notifications.
@@ -709,23 +169,23 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re
- +
- +
- - + +
- +
@@ -757,7 +217,7 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re
diff --git a/components/customer-lead-row.tsx b/components/customer-lead-row.tsx index ff4d48c..a365496 100644 --- a/components/customer-lead-row.tsx +++ b/components/customer-lead-row.tsx @@ -72,6 +72,8 @@ export function CustomerLeadRow({ const isOpen = isLeadOpenStatus(lead.status); const nextStep = getLeadNextStepLabel(lead.status); const activityAt = lead.lastInteractionAt || lead.createdAt; + const createdLabel = formatRelativeTime(lead.createdAt); + const activityLabel = formatRelativeTime(activityAt); return ( {getServiceLabel(lead)} {getUrgencyLabel(lead)} {getLocationLabel(lead)} - {formatRelativeTime(activityAt)} + Created {createdLabel} + Last activity {activityLabel}
@@ -108,6 +111,9 @@ export function CustomerLeadRow({
{leadStatusLabels[lead.status]} {leadReadinessLabels[lead.readiness]} + + Open lead +
diff --git a/components/home-dashboard.tsx b/components/home-dashboard.tsx index 0619695..238b00f 100644 --- a/components/home-dashboard.tsx +++ b/components/home-dashboard.tsx @@ -1,334 +1,165 @@ -'use client'; - import Link from 'next/link'; -import { useMemo, useState } from 'react'; import { LeadStatus } from '@prisma/client'; import { updateLeadStatusAction } from '@/app/app/leads/actions'; import { Badge } from '@/components/ui/badge'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { DEFAULT_AVERAGE_JOB_VALUE } from '@/lib/business-settings'; -import { estimateRevenueSaved, formatCurrency, type RecoveryMetrics } from '@/lib/dashboard-home'; import { getLeadStatusBadgeVariant, leadStatusLabels } from '@/lib/lead-presenters'; import { cn } from '@/lib/utils'; -type SetupChecklistItem = { - key: string; +type DashboardFeedback = { + error?: string | null; + saved?: boolean; +}; + +export type DashboardSummaryCard = { label: string; + value: number; detail: string; - state: 'complete' | 'in_progress' | 'pending'; + href?: string; +}; + +export type DashboardStatusItem = { + label: string; + value: string; + detail: string; + tone: 'success' | 'secondary' | 'outline'; }; export type DashboardLeadCard = { id: string; customerName: string; + customerPhone: string; serviceNeeded: string; urgencyLabel: string; - locationLabel: string; - timeSinceMissedCall: string; + createdLabel: string; + lastActivityLabel: string; summary: string; recommendedNextAction: string; status: LeadStatus; - countsAsRecovered: boolean; - callHref: string | null; - sendTextHref: string | null; - leadHref: string | null; - sourceLabel: 'Sample lead' | 'Demo lead' | null; + callHref: string; + leadHref: string; }; -type DashboardFeedback = { - error?: string | null; - saved?: boolean; -}; - -const sampleLeadSeed: DashboardLeadCard[] = [ - { - id: 'sample-sarah', - customerName: 'Sarah M.', - serviceNeeded: 'Kitchen sink leak', - urgencyLabel: 'Urgent', - locationLabel: 'Knoxville', - timeSinceMissedCall: '4 min ago', - summary: 'Customer missed the call and replied by text. They need help today with a leaking sink and are ready for a callback.', - recommendedNextAction: 'Call now.', - status: LeadStatus.QUALIFIED, - countsAsRecovered: true, - callHref: null, - sendTextHref: null, - leadHref: null, - sourceLabel: 'Sample lead', - }, - { - id: 'sample-james', - customerName: 'James R.', - serviceNeeded: 'Roof estimate', - urgencyLabel: 'This week', - locationLabel: 'Oak Ridge', - timeSinceMissedCall: '18 min ago', - summary: 'Customer wants a roof repair estimate and asked about availability this week.', - recommendedNextAction: 'Call now.', - status: LeadStatus.QUALIFIED, - countsAsRecovered: true, - callHref: null, - sendTextHref: null, - leadHref: null, - sourceLabel: 'Sample lead', - }, -]; - -function buildSampleMetrics(leads: DashboardLeadCard[]): RecoveryMetrics { - const missedCallsCaptured = leads.length; - const recoveredLeads = leads.filter((lead) => lead.countsAsRecovered).length; - const bookedJobs = leads.filter((lead) => lead.status === LeadStatus.BOOKED).length; - - return { - missedCallsCaptured, - recoveredLeads, - bookedJobs, - estimatedRevenueSaved: estimateRevenueSaved({ - bookedJobs, - recoveredLeads, - averageJobValue: DEFAULT_AVERAGE_JOB_VALUE, - }), - averageJobValue: DEFAULT_AVERAGE_JOB_VALUE, - usesDefaultAverageJobValue: true, - }; -} - -function getUrgencyVariant(label: string) { - const normalized = label.trim().toLowerCase(); - if (normalized.includes('urgent') || normalized.includes('today') || normalized.includes('emergency')) return 'destructive'; - if (normalized.includes('week') || normalized.includes('estimate')) return 'secondary'; - return 'outline'; -} - -function getSetupItemBadge(item: SetupChecklistItem) { - if (item.state === 'complete') return { label: 'Complete', variant: 'success' as const }; - if (item.state === 'in_progress') return { label: 'In progress', variant: 'secondary' as const }; - return { label: 'Pending', variant: 'outline' as const }; -} - -function getProgressText(items: SetupChecklistItem[]) { - const completeCount = items.filter((item) => item.state === 'complete').length; - return `${completeCount} of ${items.length} steps complete`; -} - -function getMetricBadgeLabel(input: { isDemoMode: boolean; showingSampleLeads: boolean; hasRealLeadData: boolean }) { - if (input.isDemoMode) return 'Demo data'; - if (input.showingSampleLeads) return 'Sample data'; - if (!input.hasRealLeadData) return 'No real lead data yet'; - return 'Live data'; -} - -function LeadAttentionCard({ - lead, +function LeadStatusActionForm({ + leadId, + status, redirectTo, - onDemoAction, + label, + variant, }: { - lead: DashboardLeadCard; + leadId: string; + status: 'CONTACTED' | 'BOOKED' | 'LOST'; redirectTo: string; - onDemoAction: (leadId: string, action: 'call' | 'text' | 'booked') => void; + label: string; + variant?: 'default' | 'outline' | 'destructive'; }) { return ( -
-
-
-
- {lead.sourceLabel ? {lead.sourceLabel} : null} - {lead.urgencyLabel} - {leadStatusLabels[lead.status]} -
+
+ + + + + +
+ ); +} + +function LeadAttentionCard({ lead }: { lead: DashboardLeadCard }) { + return ( + + +
-

{lead.customerName}

-

- {lead.serviceNeeded} · {lead.locationLabel} · {lead.timeSinceMissedCall} -

+ {lead.customerName} + {lead.customerPhone} +
+
+ {leadStatusLabels[lead.status]} + {lead.urgencyLabel}
- {lead.leadHref ? ( - - Open lead - - ) : null} -
- -
-
-

AI Summary

-

{lead.summary}

+
+

{lead.recommendedNextAction}

+

{lead.serviceNeeded}

-
-

Recommended next action

-

{lead.recommendedNextAction}

+ + +

{lead.summary}

+
+ Created {lead.createdLabel} + Last activity {lead.lastActivityLabel}
-
- -
- {lead.callHref ? ( +
- Call Now + Call now - ) : ( - - )} - - {lead.sendTextHref ? ( - - Send Text + + Open lead - ) : ( - - )} - - {lead.sourceLabel ? ( - - ) : ( -
- - - - -
- )} -
- - {lead.sourceLabel ?

Demo actions stay on this page and do not touch production data.

: null} -
+ + +
+ + ); } -function RecoveryQueueRow({ - lead, - onTestRecoveryFlow, -}: { - lead?: DashboardLeadCard; - onTestRecoveryFlow: () => void; -}) { - if (!lead) { - return ( -
-

Your recovered leads will appear here after the first missed call.

- -
- ); - } - +function RecentLeadRow({ lead }: { lead: DashboardLeadCard }) { return ( -
-
- {lead.sourceLabel ? {lead.sourceLabel} : null} - {leadStatusLabels[lead.status]} -
-
+ +

{lead.customerName}

-

- {lead.serviceNeeded} · {lead.locationLabel} · {lead.timeSinceMissedCall} +

{lead.serviceNeeded}

+

+ Created {lead.createdLabel} · Last activity {lead.lastActivityLabel}

-

{lead.summary}

- {lead.leadHref ? ( - - Open - - ) : null} +
+ {leadStatusLabels[lead.status]} + {lead.urgencyLabel} +
-
+ ); } export function HomeDashboard({ attentionLeads, - queueLeads, - metrics, + recentLeads, feedback, isDemoMode, - hasRealLeadData, - showSetupChecklist, - setupChecklistItems, - finishActivationHref, + statusItems, + summaryCards, simulatorHref, }: { attentionLeads: DashboardLeadCard[]; - queueLeads: DashboardLeadCard[]; - metrics: RecoveryMetrics; + recentLeads: DashboardLeadCard[]; feedback: DashboardFeedback; isDemoMode: boolean; - hasRealLeadData: boolean; - showSetupChecklist: boolean; - setupChecklistItems: SetupChecklistItem[]; - finishActivationHref: string; + statusItems: DashboardStatusItem[]; + summaryCards: DashboardSummaryCard[]; simulatorHref: string; }) { - const [showSampleLeads, setShowSampleLeads] = useState(!hasRealLeadData && isDemoMode); - const [sampleLeads, setSampleLeads] = useState(sampleLeadSeed); - const [demoNotice, setDemoNotice] = useState(null); - - const showingSampleLeads = showSampleLeads && !hasRealLeadData; - const displayedAttentionLeads = showingSampleLeads ? sampleLeads : attentionLeads; - const displayedQueueLeads = showingSampleLeads ? sampleLeads : queueLeads; - const displayedMetrics = useMemo( - () => (showingSampleLeads ? buildSampleMetrics(sampleLeads) : metrics), - [metrics, sampleLeads, showingSampleLeads], - ); - const queueIsEmpty = displayedQueueLeads.length === 0; - const attentionIsEmpty = displayedAttentionLeads.length === 0; - - function testRecoveryFlow() { - setShowSampleLeads(true); - setDemoNotice('Sample leads are now visible on this dashboard. These cards stay in the browser only and do not affect production data.'); - } - - function handleDemoAction(leadId: string, action: 'call' | 'text' | 'booked') { - setSampleLeads((currentLeads) => - currentLeads.map((lead) => { - if (lead.id !== leadId) return lead; - if (action === 'booked') { - return { - ...lead, - status: LeadStatus.BOOKED, - recommendedNextAction: 'Booked. Keep this one for proof of recovered revenue.', - }; - } - return { - ...lead, - status: LeadStatus.CONTACTED, - recommendedNextAction: action === 'call' ? 'Follow up by text if they do not answer.' : 'Wait for the reply, then mark the job outcome.', - }; - }), - ); - - setDemoNotice( - action === 'booked' - ? 'Sample lead marked booked. The revenue estimate updated on this page only.' - : 'Sample lead updated. This is a frontend-only demo action and does not send live outreach.', - ); - } - return (
{feedback.error ?
{feedback.error}
: null} {feedback.saved ?
Lead updated.
: null} - {demoNotice ?
{demoNotice}
: null}
- Lead recovery command center +
+ Owner dashboard + {isDemoMode ? Demo data : null} +
-

Missed-call leads that need action

+

Missed-call leads

- CallbackCloser follows up instantly, qualifies the customer, and shows you who to call back first. + See who needs a callback, open the lead, and mark the outcome after you talk to them.

@@ -342,28 +173,54 @@ export function HomeDashboard({
-
+
+ {summaryCards.map((card) => { + const content = ( + + + {card.label} + {card.value} + + +

{card.detail}

+
+
+ ); + + return card.href ? ( + + {content} + + ) : ( +
{content}
+ ); + })} +
+ +
- - Leads needing attention first - These missed callers are the clearest path to recovered jobs right now. + +
+ Leads needing attention + Call these missed callers first. +
+ + View inbox +
- {attentionIsEmpty ? ( + {attentionLeads.length === 0 ? (
-

No real leads need action yet.

+

No leads need follow-up right now.

- Once CallbackCloser captures a missed call, the lead will appear here with a summary, urgency level, and one-click follow-up actions. + When a missed caller replies or needs a callback, they will appear here first.

-
) : ( -
- {displayedAttentionLeads.map((lead) => ( - +
+ {attentionLeads.map((lead) => ( + ))}
)} @@ -373,101 +230,52 @@ export function HomeDashboard({
- Today's recovery queue - Every missed-call lead captured today. + Recent leads + The latest missed-call leads captured for your business.
View all leads
- {queueIsEmpty ? ( - + {recentLeads.length === 0 ? ( +
+

No leads yet.

+

Your first missed-call lead will appear here automatically.

+
) : ( - displayedQueueLeads.map((lead) => ) + recentLeads.map((lead) => ) )}
-
- {showSetupChecklist ? ( - - -
-
- Finish setup - {getProgressText(setupChecklistItems)} -
- {getProgressText(setupChecklistItems)} -
-
- - {setupChecklistItems.map((item) => { - const badge = getSetupItemBadge(item); - return ( -
-
-

{item.label}

- {badge.label} -
-

{item.detail}

-
- ); - })} - - Finish activation - -
-
- ) : null} - +
- -
-
- Recovery numbers - Keep the value of missed-call follow-up obvious without opening another report. -
- - {getMetricBadgeLabel({ isDemoMode, showingSampleLeads, hasRealLeadData })} - -
+ + System status + Simple setup signals only. - -
-
-

Missed calls captured

-

{displayedMetrics.missedCallsCaptured}

-
-
-

Recovered leads

-

{displayedMetrics.recoveredLeads}

-
-
-

Booked jobs

-

{displayedMetrics.bookedJobs}

-
-
-

Estimated revenue saved

-

{formatCurrency(displayedMetrics.estimatedRevenueSaved)}

+ + {statusItems.map((item) => ( +
+
+

{item.label}

+ {item.value} +
+

{item.detail}

-
-

Revenue saved is estimated from booked/recovered leads and your average job value.

- {displayedMetrics.usesDefaultAverageJobValue ? ( -

- Using the default {formatCurrency(displayedMetrics.averageJobValue)} average job value until a business-specific average is configured. -

- ) : ( -

Using your configured {formatCurrency(displayedMetrics.averageJobValue)} average job value.

- )} + ))} + + Review settings + - - Test recovery flow - Send yourself a sample missed-call lead so you can see the text flow, owner alert, and dashboard update. + + Want to test it? + Run the public simulator to see a sample missed-call flow. diff --git a/lib/system-status.ts b/lib/system-status.ts index a43cb9a..85c60a8 100644 --- a/lib/system-status.ts +++ b/lib/system-status.ts @@ -55,16 +55,16 @@ export function getCustomerSystemStatus(business: StatusBusiness, successfulLead key: 'live' as const, label: 'Live', badgeVariant: 'success' as const, - description: 'Missed-call recovery is compliant, synced, and ready for another test call.', + description: 'Missed-call recovery is live and ready for another test call.', }; } if (managedSummary.onboardingReady || managedSummary.complianceStarted || phoneSetupGate.complete || hasSuccessfulTestLead) { return { key: 'activating' as const, - label: 'Activating', + label: 'Setup pending', badgeVariant: 'secondary' as const, - description: 'Setup is underway. Finish the remaining activation steps to go live.', + description: 'CallbackCloser is finishing the remaining setup before missed-call recovery goes live.', }; } diff --git a/tests/customer-home-dashboard.test.ts b/tests/customer-home-dashboard.test.ts index f5d269c..9562cc1 100644 --- a/tests/customer-home-dashboard.test.ts +++ b/tests/customer-home-dashboard.test.ts @@ -94,23 +94,33 @@ test('business settings schema accepts a blank average job value and rejects inv assert.equal(invalid.success, false); }); -test('customer home dashboard keeps setup state compact and demo actions frontend-only', () => { +test('customer home dashboard focuses on owner lead follow-up', () => { const appHomePage = read('app/app/page.tsx'); const homeDashboard = read('components/home-dashboard.tsx'); - assert.match(appHomePage, /buildRecoveryMetrics\(allLeads, business\.averageJobValueCents\)/); - assert.match(appHomePage, /showSetupChecklist=\{systemStatus\.key !== 'live'\}/); - assert.match(appHomePage, /label: 'Phone line connected'/); - assert.match(homeDashboard, /Finish setup/); + assert.match(appHomePage, /listAllDashboardLeadsForBusiness\(business\.id\)/); + assert.match(appHomePage, /label: 'New leads'/); + assert.match(appHomePage, /label: 'Needs follow-up'/); + assert.match(appHomePage, /label: 'Booked leads'/); + assert.match(appHomePage, /label: 'Missed calls today'/); + assert.match(appHomePage, /statusItems=\{getStatusItems\(\{ ownerAlertsReady, systemStatus \}\)\}/); + assert.match(homeDashboard, /Missed-call leads/); + assert.match(homeDashboard, /Leads needing attention/); + assert.match(homeDashboard, /Recent leads/); + assert.match(homeDashboard, /System status/); + assert.match(homeDashboard, /Call now/); + assert.match(homeDashboard, /Mark contacted/); + assert.match(homeDashboard, /Mark booked/); assert.match(homeDashboard, /Test recovery flow/); - assert.match(homeDashboard, /lg:grid-cols-\[minmax\(0,1fr\)_360px\]/); - assert.match(homeDashboard, /lg:sticky lg:top-6 lg:self-start/); + assert.match(homeDashboard, /xl:grid-cols-\[minmax\(0,1fr\)_340px\]/); + assert.match(homeDashboard, /xl:sticky xl:top-6 xl:self-start/); assert.match(homeDashboard, /variant="outline"/); - assert.match(homeDashboard, /frontend-only demo action/); - assert.match(homeDashboard, /do not affect production data/); assert.doesNotMatch(homeDashboard, /Run test missed call/); assert.doesNotMatch(homeDashboard, /Run demo lead/); assert.doesNotMatch(homeDashboard, /Test demo flow/); + assert.doesNotMatch(homeDashboard, /Finish activation/); + assert.doesNotMatch(homeDashboard, /Recovery numbers|AI Summary|command center|Estimated revenue saved/); + assert.doesNotMatch(appHomePage, /twilio-setup-flow|Texting activation/); }); test('business settings UI and action wire average job value persistence into the dashboard', () => { diff --git a/tests/leads-workspace-routing.test.ts b/tests/leads-workspace-routing.test.ts index ac3ec09..029d0c0 100644 --- a/tests/leads-workspace-routing.test.ts +++ b/tests/leads-workspace-routing.test.ts @@ -15,32 +15,35 @@ test('lead inbox stays list-only while lead detail is the main action workspace' const conversationsPage = read('app/app/conversations/page.tsx'); assert.match(appHomePage, /HomeDashboard/); - assert.match(appHomePage, /buildRecoveryMetrics/); + assert.match(appHomePage, /label: 'New leads'/); + assert.match(appHomePage, /label: 'Needs follow-up'/); + assert.match(appHomePage, /label: 'Booked leads'/); + assert.match(appHomePage, /label: 'Missed calls today'/); assert.match(appHomePage, /return `\/app\/leads\/\$\{leadId\}\?from=%2Fapp`/); - assert.match(homeDashboard, /Missed-call leads that need action/); - assert.match(homeDashboard, /Leads needing attention first/); - assert.match(homeDashboard, /Today's recovery queue/); + assert.match(homeDashboard, /Missed-call leads/); + assert.match(homeDashboard, /Leads needing attention/); + assert.match(homeDashboard, /Recent leads/); + assert.match(homeDashboard, /System status/); assert.match(homeDashboard, /Test recovery flow/); assert.doesNotMatch(homeDashboard, /Run test missed call/); assert.doesNotMatch(homeDashboard, /Run demo lead/); assert.doesNotMatch(homeDashboard, /Test demo flow/); assert.match(leadsPage, /Lead inbox/); - assert.match(leadsPage, /LeadConversionSummaryCard/); - assert.match(leadsPage, /getLeadOutcomeSummary/); - assert.match(leadsPage, /This page is only for scanning/i); + assert.match(leadsPage, /Scan missed-call leads/i); assert.match(leadsPage, /Needs follow-up/); - assert.match(leadsPage, /Closed/); + assert.match(leadsPage, /Booked/); assert.doesNotMatch(leadsPage, /selectedLeadId/); assert.doesNotMatch(leadsPage, /Lead detail panel/); assert.doesNotMatch(leadsPage, /Quick actions/); assert.doesNotMatch(leadsPage, /Open Conversations/); + assert.doesNotMatch(leadsPage, /LeadConversionSummaryCard|getLeadOutcomeSummary/); assert.match(leadDetailPage, /Lead details/); assert.match(leadDetailPage, /Call now/); assert.match(leadDetailPage, /Mark contacted/); - assert.match(leadDetailPage, /Mark as Closed \(Won\)/); - assert.match(leadDetailPage, /Mark as Lost/); + assert.match(leadDetailPage, /Mark booked/); + assert.match(leadDetailPage, /Mark lost/); assert.match(leadDetailPage, /Did this lead turn into a real job\?/); assert.match(leadDetailPage, /Conversation history/); assert.match(leadDetailPage, /Qualification info/); @@ -51,7 +54,9 @@ test('lead inbox stays list-only while lead detail is the main action workspace' assert.match(leadDetailPage, /No preferred time yet/); assert.match(leadDetailPage, /const error = typeof searchParams\?\.error === 'string' \? searchParams\.error : undefined;/); assert.match(leadDetailPage, /border-destructive\/30 bg-destructive\/5 p-3 text-sm text-destructive/); + assert.doesNotMatch(leadDetailPage, /Mark as Closed \(Won\)|Mark as Lost/); - assert.match(conversationsPage, /href=\{`\/app\/leads\/\$\{selectedLead\.id\}\?from=%2Fapp%2Fconversations`\}/); - assert.match(conversationsPage, /Open lead details/); + assert.match(conversationsPage, /Latest conversations/); + assert.match(conversationsPage, /href=\{`\/app\/leads\/\$\{lead\.id\}\?from=%2Fapp%2Fconversations`\}/); + assert.doesNotMatch(conversationsPage, /selectedLeadId|getConversationDetailForBusiness|Conversation detail|Open lead details/); }); diff --git a/tests/pilot-safety.test.ts b/tests/pilot-safety.test.ts index db62316..af75ad6 100644 --- a/tests/pilot-safety.test.ts +++ b/tests/pilot-safety.test.ts @@ -9,23 +9,32 @@ function read(relativePath: string) { return readFileSync(path.join(process.cwd(), relativePath), 'utf8'); } -test('settings page no longer exposes shared Twilio number inventory', () => { +test('customer setup pages keep operator setup details out of owner view', () => { const settingsPage = read('app/app/settings/page.tsx'); + const callFlowPage = read('app/app/call-flow/page.tsx'); + const homeDashboard = read('components/home-dashboard.tsx'); + const leadsPage = read('app/app/leads/page.tsx'); + const leadDetailPage = read('app/app/leads/[leadId]/page.tsx'); + const conversationsPage = read('app/app/conversations/page.tsx'); + const billingPage = read('app/app/billing/page.tsx'); + const forbiddenOwnerTerms = + /\bTwilio\b|\bA2P\b|toll-free|webhook|subaccount|\bSID\b|Provision routing number|Sync all webhooks|Test SMS|Messaging Service|account mode|admin-assisted|pilot sender|Finish activation|twilio-setup-flow|operator event|status callback|activation/i; - assert.doesNotMatch(settingsPage, /incomingPhoneNumbers\.list/); - assert.match(settingsPage, /account mode/); - assert.match(settingsPage, /Existing-number support stays honest/i); -}); - -test('existing-number selection reports a truthful setup state instead of a fake error', () => { - const settingsPage = read('app/app/settings/page.tsx'); - const settingsAction = read('app/app/settings/actions.ts'); - - assert.match(settingsAction, /redirect\('\/app\/settings\?existingNumberIntent=1'\)/); - assert.doesNotMatch(settingsAction, /Keeping an existing number is still an admin-assisted launch step/); - assert.match(settingsPage, /const existingNumberIntent = searchParams\?\.existingNumberIntent === '1';/); - assert.match(settingsPage, /Porting path saved\./); - assert.match(settingsPage, /admin-assisted porting workflow/i); + assert.match(settingsPage, /Business settings/); + assert.match(settingsPage, /Text replies/); + assert.match(settingsPage, /Owner alerts/); + assert.match(settingsPage, /Need setup help/); + assert.match(callFlowPage, /How missed calls are handled/); + assert.match(callFlowPage, /A customer calls/); + assert.match(callFlowPage, /You get a lead summary/); + assert.match(homeDashboard, /Review settings/); + assert.doesNotMatch(settingsPage, forbiddenOwnerTerms); + assert.doesNotMatch(callFlowPage, forbiddenOwnerTerms); + assert.doesNotMatch(homeDashboard, forbiddenOwnerTerms); + assert.doesNotMatch(leadsPage, forbiddenOwnerTerms); + assert.doesNotMatch(leadDetailPage, forbiddenOwnerTerms); + assert.doesNotMatch(conversationsPage, forbiddenOwnerTerms); + assert.doesNotMatch(billingPage, forbiddenOwnerTerms); }); test('managed setup handoff replaces the old self-serve onboarding route', () => { diff --git a/tests/tenant-isolation-wiring.test.ts b/tests/tenant-isolation-wiring.test.ts index 2b7a2c0..fc25718 100644 --- a/tests/tenant-isolation-wiring.test.ts +++ b/tests/tenant-isolation-wiring.test.ts @@ -26,9 +26,11 @@ test('protected app surfaces use shared tenant-scoped access helpers', () => { assert.match(leadsPage, /buildLeadDetailHref/); assert.doesNotMatch(leadsPage, /selectedLeadId/); assert.match(leadDetailPage, /getLeadDetailForBusiness/); + assert.match(leadDetailPage, /notFound\(\)/); assert.match(conversationsPage, /listConversationsForBusiness/); - assert.match(conversationsPage, /getConversationDetailForBusiness/); + assert.doesNotMatch(conversationsPage, /getConversationDetailForBusiness/); assert.match(leadActions, /updateLeadStatusForBusiness/); + assert.match(leadActions, /requireBusiness/); assert.match(settingsPage, /getBusinessNotificationSettingsForBusiness/); assert.match(settingsActions, /requireBusiness/); assert.match(billingPage, /getBillingUsageSnapshotForBusiness/);