diff --git a/app/app/leads/[leadId]/page.tsx b/app/app/leads/[leadId]/page.tsx index 940d19a..06af377 100644 --- a/app/app/leads/[leadId]/page.tsx +++ b/app/app/leads/[leadId]/page.tsx @@ -100,9 +100,13 @@ export default async function LeadDetailPage({ ownerNotifications.find((notification) => notification.channel === OwnerNotificationChannel.EMAIL) ?? null; const latestInAppNotification = ownerNotifications.find((notification) => notification.channel === OwnerNotificationChannel.IN_APP) ?? null; - const primaryLabel = lead.callerName || lead.contactName || formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone); - const secondaryLabel = - lead.callerName || lead.contactName ? formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone) : 'Caller name not captured yet'; + const customerName = lead.callerName || lead.contactName || 'Name not captured yet'; + const customerPhone = formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone); + const primaryLabel = lead.callerName || lead.contactName || customerPhone; + const secondaryLabel = lead.callerName || lead.contactName ? customerPhone : 'Caller name not captured yet'; + const serviceLabel = lead.serviceType || lead.serviceRequested || 'Still being captured'; + const locationLabel = lead.location || lead.zipCode || 'Still being captured'; + const callbackLabel = lead.bestTime || (lead.callbackRequested === false ? 'Text only' : 'No preferred time yet'); const detailRedirectTo = returnPath === '/app/leads' ? `/app/leads/${lead.id}` : `/app/leads/${lead.id}?from=${encodeURIComponent(returnPath)}`; const successRedirectTo = '/app/leads'; @@ -184,25 +188,26 @@ export default async function LeadDetailPage({
-

Phone

-

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

+

Customer

+

{customerName}

+

{customerPhone}

Created {formatRelativeTime(lead.createdAt)}.

Service

-

{lead.serviceType || lead.serviceRequested || 'Still being captured'}

+

{serviceLabel}

Urgency: {lead.urgency || 'Pending reply'}

Callback timing

-

{lead.bestTime || 'No preferred time yet'}

+

{callbackLabel}

Callback requested: {typeof lead.callbackRequested === 'boolean' ? (lead.callbackRequested ? 'Yes' : 'No') : 'Not answered'}

Location

-

{lead.location || lead.zipCode || 'Still being captured'}

+

{locationLabel}

{isOpenLead ? 'Still needs an outcome update.' : 'Outcome already captured.'}

@@ -248,18 +253,34 @@ export default async function LeadDetailPage({ The notes CallbackCloser collected before you call back. +
+ Customer name + {customerName} +
+
+ Phone number + {customerPhone} +
Summary {lead.summary || 'Summary will update as the intake progresses.'}
- Service requested - {lead.serviceType || lead.serviceRequested || 'Still being captured'} + Service needed + {serviceLabel}
Urgency {lead.urgency || 'Pending reply'}
+
+ Location / address + {locationLabel} +
+
+ Preferred callback time + {callbackLabel} +
Qualified at {formatDateTime(lead.qualifiedAt)} diff --git a/components/customer-lead-row.tsx b/components/customer-lead-row.tsx index b316f22..ff4d48c 100644 --- a/components/customer-lead-row.tsx +++ b/components/customer-lead-row.tsx @@ -22,6 +22,8 @@ type CustomerLeadRowLead = { serviceType: string | null; serviceRequested: string | null; urgency: string | null; + location: string | null; + zipCode: string | null; status: LeadStatus; readiness: LeadReadiness; createdAt: Date; @@ -48,6 +50,10 @@ function getUrgencyLabel(lead: CustomerLeadRowLead) { return lead.urgency || 'Urgency pending'; } +function getLocationLabel(lead: CustomerLeadRowLead) { + return lead.location || lead.zipCode || 'Location pending'; +} + function getReadinessVariant(readiness: LeadReadiness) { if (readiness === LeadReadiness.URGENT) return 'destructive'; if (readiness === LeadReadiness.QUALIFIED) return 'secondary'; @@ -87,6 +93,7 @@ export function CustomerLeadRow({
{getServiceLabel(lead)} {getUrgencyLabel(lead)} + {getLocationLabel(lead)} {formatRelativeTime(activityAt)}
diff --git a/lib/lead-presenters.ts b/lib/lead-presenters.ts index 068c8d8..646f4f4 100644 --- a/lib/lead-presenters.ts +++ b/lib/lead-presenters.ts @@ -30,8 +30,8 @@ export const smsStateLabels: Record = { NOT_STARTED: 'Not started', AWAITING_SERVICE: 'Awaiting service', AWAITING_URGENCY: 'Awaiting urgency', - AWAITING_ZIP: 'Awaiting ZIP', - AWAITING_BEST_TIME: 'Awaiting best time', + AWAITING_ZIP: 'Awaiting name + location', + AWAITING_BEST_TIME: 'Awaiting callback time', AWAITING_NAME: 'Awaiting name', COMPLETED: 'Completed', }; diff --git a/lib/lead-qualification.ts b/lib/lead-qualification.ts index 7a3dcad..4d13097 100644 --- a/lib/lead-qualification.ts +++ b/lib/lead-qualification.ts @@ -10,6 +10,7 @@ type LeadQualificationFields = Pick< | 'callbackRequested' | 'callerName' | 'contactName' + | 'bestTime' | 'callerPhoneNormalized' >; @@ -25,23 +26,38 @@ export function getLeadCallerName(lead: Pick) { + return lead.location || lead.zipCode || null; +} + +export function getLeadPreferredCallbackTime(lead: Pick) { + if (lead.bestTime) return lead.bestTime; + if (lead.callbackRequested === false) return 'Text only'; + return null; +} + export function isUrgentLead(lead: Pick) { const urgency = lead.urgency?.trim().toLowerCase(); if (!urgency) return false; return urgency.includes('emergency') || urgency.includes('urgent') || urgency.includes('today') || urgency.includes('asap'); } -export function isLeadQualified(lead: Pick) { - return hasValue(getLeadServiceType(lead)) && (hasValue(lead.urgency) || typeof lead.callbackRequested === 'boolean'); +export function isLeadQualified( + lead: Pick +) { + return hasValue(getLeadServiceType(lead)) && hasValue(lead.urgency) && (hasValue(lead.bestTime) || typeof lead.callbackRequested === 'boolean'); } -export function getLeadReadiness(lead: Pick) { +export function getLeadReadiness( + lead: Pick +) { if (!isLeadQualified(lead)) return LeadReadiness.PENDING; return isUrgentLead(lead) ? LeadReadiness.URGENT : LeadReadiness.QUALIFIED; } export function getQualifiedLeadStatus( - lead: Pick & Pick + lead: Pick & + Pick ) { if (lead.status === LeadStatus.CONTACTED || lead.status === LeadStatus.BOOKED || lead.status === LeadStatus.LOST) { return lead.status; @@ -56,13 +72,14 @@ export function buildLeadSummary(lead: LeadQualificationFields) { const summaryParts = [ getLeadServiceType(lead) ? `Service: ${getLeadServiceType(lead)}` : null, lead.urgency ? `Urgency: ${lead.urgency}` : null, - lead.location ? `Location: ${lead.location}` : lead.zipCode ? `Location: ${lead.zipCode}` : null, + getLeadLocation(lead) ? `Location: ${getLeadLocation(lead)}` : null, + getLeadPreferredCallbackTime(lead) ? `Callback: ${getLeadPreferredCallbackTime(lead)}` : null, typeof lead.callbackRequested === 'boolean' ? lead.callbackRequested ? 'Callback requested' : 'No callback requested' : null, - getLeadCallerName(lead) ? `Caller: ${getLeadCallerName(lead)}` : null, + getLeadCallerName(lead) ? `Name: ${getLeadCallerName(lead)}` : null, lead.callerPhoneNormalized ? `Phone: ${lead.callerPhoneNormalized}` : null, ].filter(Boolean); diff --git a/lib/missed-call-flow.ts b/lib/missed-call-flow.ts index 9636533..a7781a0 100644 --- a/lib/missed-call-flow.ts +++ b/lib/missed-call-flow.ts @@ -120,6 +120,7 @@ function buildLeadLifecycleUpdate( | 'location' | 'zipCode' | 'callbackRequested' + | 'bestTime' | 'callerName' | 'contactName' | 'callerPhoneNormalized' diff --git a/lib/owner-notifications.ts b/lib/owner-notifications.ts index 261161d..fe8e6b6 100644 --- a/lib/owner-notifications.ts +++ b/lib/owner-notifications.ts @@ -5,7 +5,14 @@ import { isBusinessAutomationPaused } from '@/lib/admin-dashboard'; import { getEffectiveBusinessNotificationSettings } from '@/lib/business-notification-settings'; import { db } from '@/lib/db'; import { sendTransactionalEmail } from '@/lib/email'; -import { buildLeadSummary, getLeadServiceType, isLeadQualified } from '@/lib/lead-qualification'; +import { + buildLeadSummary, + getLeadCallerName, + getLeadLocation, + getLeadPreferredCallbackTime, + getLeadServiceType, + isLeadQualified, +} from '@/lib/lead-qualification'; import { formatPhoneDetail, recordBusinessOperatorEvent } from '@/lib/operator-events'; import { formatPhoneForDisplay } from '@/lib/phone'; import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; @@ -44,29 +51,52 @@ function buildLeadUrl(leadId: string) { } function buildOwnerSmsBody(lead: NotificationLeadRecord) { - const serviceType = getLeadServiceType(lead) || 'Service request'; - const readinessLabel = - lead.readiness === LeadReadiness.URGENT ? 'Urgent' : lead.readiness === LeadReadiness.QUALIFIED ? 'Qualified' : 'Pending'; + const header = lead.readiness === LeadReadiness.URGENT ? '🔥 Hot missed-call lead' : 'New missed-call lead'; const leadUrl = buildLeadUrl(lead.id); + const customerName = getLeadCallerName(lead) || 'Not captured'; + const serviceType = getLeadServiceType(lead) || 'Not captured'; + const location = getLeadLocation(lead) || 'Not captured'; + const callbackTime = getLeadPreferredCallbackTime(lead) || 'Not captured'; + const callerPhone = lead.callerPhoneNormalized || lead.callerPhone; return compactSummary( - `CallbackCloser lead for ${lead.business.name}: ${serviceType}. ${lead.urgency ? `Urgency: ${lead.urgency}. ` : ''}${ - lead.location || lead.zipCode ? `Location: ${lead.location || lead.zipCode}. ` : '' - }${lead.callbackRequested === false ? 'Callback not requested. ' : ''}Readiness: ${readinessLabel}. Open lead: ${leadUrl}` + [ + header, + '', + `Name: ${customerName}`, + `Service: ${serviceType}`, + `Urgency: ${lead.urgency || 'Not captured'}`, + `Location: ${location}`, + `Callback: ${callbackTime}`, + '', + `Call now: ${callerPhone}`, + `View lead: ${leadUrl}`, + ].join('\n'), + 640 ); } function buildOwnerEmailContent(lead: NotificationLeadRecord) { const leadUrl = buildLeadUrl(lead.id); const summary = buildLeadSummary(lead); + const customerName = getLeadCallerName(lead) || 'Not captured'; + const serviceType = getLeadServiceType(lead) || 'Missed call follow-up'; + const location = getLeadLocation(lead) || 'Not captured'; + const callbackTime = getLeadPreferredCallbackTime(lead) || 'Not captured'; const subject = `CallbackCloser lead: ${getLeadServiceType(lead) || 'Missed call follow-up'} for ${lead.business.name}`; const text = [ `CallbackCloser qualified a missed-call lead for ${lead.business.name}.`, '', - summary, - '', + `Name: ${customerName}`, + `Service: ${serviceType}`, + `Urgency: ${lead.urgency || 'Not captured'}`, + `Location: ${location}`, + `Callback: ${callbackTime}`, `Caller: ${formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone)}`, `Lead status: ${lead.status}`, + '', + summary, + '', `Open lead: ${leadUrl}`, ].join('\n'); @@ -74,9 +104,14 @@ function buildOwnerEmailContent(lead: NotificationLeadRecord) {

CallbackCloser qualified a missed-call lead

Business: ${lead.business.name}

-

Summary: ${summary}

+

Name: ${customerName}

+

Service: ${serviceType}

+

Urgency: ${lead.urgency || 'Not captured'}

+

Location: ${location}

+

Callback: ${callbackTime}

Caller: ${formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone)}

Lead status: ${lead.status}

+

Summary: ${summary}

Open lead in CallbackCloser

`; diff --git a/lib/portfolio-demo.ts b/lib/portfolio-demo.ts index 68bb7e2..92107c9 100644 --- a/lib/portfolio-demo.ts +++ b/lib/portfolio-demo.ts @@ -218,7 +218,7 @@ const leadA = makeLead({ contactName: 'Pat Morgan', callerName: 'Pat Morgan', callbackRequested: true, - summary: 'Service: Water heater repair | Urgency: Today | Location: 78704 | Callback requested | Caller: Pat Morgan | Phone: +15125550177', + summary: 'Service: Water heater repair | Urgency: Today | Location: 78704 | Callback: Afternoon | Callback requested | Name: Pat Morgan | Phone: +15125550177', qualifiedAt: new Date('2026-02-24T14:09:20.000Z'), notifiedAt: new Date('2026-02-24T14:10:12.000Z'), ownerNotifiedAt: new Date('2026-02-24T14:10:12.000Z'), @@ -240,7 +240,7 @@ const leadAMessages: Message[] = [ participant: MessageParticipant.OWNER, fromPhone: '+15125550123', toPhone: '+15125550177', - body: 'CallbackCloser: We missed your call. What service do you need? Reply 1 Repair, 2 Install, 3 Maintenance, or reply with a short description. Reply STOP to opt out or HELP for help. Msg freq varies. Msg & data rates may apply.', + body: 'Hey, sorry we missed your call. What can we help you with today?\n\nYou can reply with something like:\nRepair, estimate, installation, emergency, or anything else.\nReply STOP to opt out.', twilioSid: 'SMaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', createdAt: new Date('2026-02-24T14:08:46.000Z'), updatedAt: new Date('2026-02-24T14:08:46.000Z'), @@ -266,7 +266,7 @@ const leadAMessages: Message[] = [ participant: MessageParticipant.OWNER, fromPhone: '+15125550123', toPhone: '+15125550177', - body: 'How urgent is it? Reply 1 Emergency, 2 Today, 3 This week, 4 Quote.', + body: 'Got it — how soon do you need help?\n\nReply:\n1 Emergency\n2 Today\n3 This week\n4 Just getting a quote', twilioSid: 'SMcccccccccccccccccccccccccccccccc', createdAt: new Date('2026-02-24T14:09:05.000Z'), updatedAt: new Date('2026-02-24T14:09:05.000Z'), @@ -292,7 +292,7 @@ const leadAMessages: Message[] = [ participant: MessageParticipant.OWNER, fromPhone: '+15125550123', toPhone: '+15125550177', - body: 'What ZIP code or service area is the job in?', + body: 'Thanks. What name should we put on the request, and what city/ZIP or service address is this for?', twilioSid: 'SMeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', createdAt: new Date('2026-02-24T14:09:21.000Z'), updatedAt: new Date('2026-02-24T14:09:21.000Z'), @@ -305,7 +305,7 @@ const leadAMessages: Message[] = [ participant: MessageParticipant.LEAD, fromPhone: '+15125550177', toPhone: '+15125550123', - body: '78704', + body: 'Pat Morgan, 78704', twilioSid: 'SMffffffffffffffffffffffffffffffff', createdAt: new Date('2026-02-24T14:10:11.000Z'), updatedAt: new Date('2026-02-24T14:10:11.000Z'), @@ -318,7 +318,7 @@ const leadAMessages: Message[] = [ participant: MessageParticipant.OWNER, fromPhone: '+15125550123', toPhone: '+15125550177', - body: 'Would you like a callback today? Reply yes or no.', + body: 'What’s the best time for someone to call you back?\n\nReply:\n1 ASAP\n2 Morning\n3 Afternoon\n4 Evening', twilioSid: 'SM11111111111111111111111111111111', createdAt: new Date('2026-02-24T14:10:13.000Z'), updatedAt: new Date('2026-02-24T14:10:13.000Z'), @@ -331,37 +331,11 @@ const leadAMessages: Message[] = [ participant: MessageParticipant.LEAD, fromPhone: '+15125550177', toPhone: '+15125550123', - body: 'Yes', + body: '3', twilioSid: 'SM22222222222222222222222222222222', createdAt: new Date('2026-02-24T14:10:48.000Z'), updatedAt: new Date('2026-02-24T14:10:48.000Z'), }), - makeMessage({ - id: 'msg_demo_009', - businessId: DEMO_BUSINESS_ID, - leadId: leadA.id, - direction: MessageDirection.OUTBOUND, - participant: MessageParticipant.OWNER, - fromPhone: '+15125550123', - toPhone: '+15125550177', - body: 'What name should we attach to this request? Reply with your name or type skip.', - twilioSid: 'SM33333333333333333333333333333333', - createdAt: new Date('2026-02-24T14:10:50.000Z'), - updatedAt: new Date('2026-02-24T14:10:50.000Z'), - }), - makeMessage({ - id: 'msg_demo_010', - businessId: DEMO_BUSINESS_ID, - leadId: leadA.id, - direction: MessageDirection.INBOUND, - participant: MessageParticipant.LEAD, - fromPhone: '+15125550177', - toPhone: '+15125550123', - body: 'Pat Morgan', - twilioSid: 'SM44444444444444444444444444444444', - createdAt: new Date('2026-02-24T14:11:44.000Z'), - updatedAt: new Date('2026-02-24T14:11:44.000Z'), - }), makeMessage({ id: 'msg_demo_011', businessId: DEMO_BUSINESS_ID, @@ -370,7 +344,7 @@ const leadAMessages: Message[] = [ participant: MessageParticipant.OWNER, fromPhone: '+15125550123', toPhone: '+15125550177', - body: 'Thanks - we have your details and will reach out shortly.', + body: 'Thanks, Pat Morgan — we have your request. Someone will reach out as soon as possible.\n\nIf there’s anything important we should know, you can reply here.', twilioSid: 'SM55555555555555555555555555555555', createdAt: new Date('2026-02-24T14:12:01.000Z'), updatedAt: new Date('2026-02-24T14:12:01.000Z'), @@ -384,7 +358,7 @@ const leadB = makeLead({ callerPhoneNormalized: '+15125550222', status: LeadStatus.NOTIFIED, billingRequired: false, - smsState: SmsConversationState.AWAITING_NAME, + smsState: SmsConversationState.AWAITING_BEST_TIME, readiness: LeadReadiness.URGENT, serviceRequested: 'AC not cooling', serviceType: 'AC not cooling', @@ -393,7 +367,7 @@ const leadB = makeLead({ zipCode: '78660', location: '78660', callbackRequested: true, - summary: 'Service: AC not cooling | Urgency: Today | Location: 78660 | Callback requested | Phone: +15125550222', + summary: 'Service: AC not cooling | Urgency: Today | Location: 78660 | Phone: +15125550222', qualifiedAt: new Date('2026-02-24T13:35:53.000Z'), notifiedAt: new Date('2026-02-24T13:36:02.000Z'), ownerNotifiedAt: new Date('2026-02-24T13:36:02.000Z'), @@ -414,7 +388,7 @@ const leadBMessages: Message[] = [ participant: MessageParticipant.OWNER, fromPhone: '+15125550123', toPhone: '+15125550222', - body: 'CallbackCloser: We missed your call. What service do you need? Reply 1 Repair, 2 Install, 3 Maintenance, or reply with a short description. Reply STOP to opt out or HELP for help. Msg freq varies. Msg & data rates may apply.', + body: 'Hey, sorry we missed your call. What can we help you with today?\n\nYou can reply with something like:\nRepair, estimate, installation, emergency, or anything else.\nReply STOP to opt out.', createdAt: new Date('2026-02-24T13:33:21.000Z'), updatedAt: new Date('2026-02-24T13:33:21.000Z'), }), @@ -455,7 +429,7 @@ const leadAOwnerNotifications: OwnerNotification[] = [ channel: OwnerNotificationChannel.SMS, status: OwnerNotificationStatus.SENT, destination: '+15125550199', - body: 'CallbackCloser lead for Northside HVAC & Plumbing (Demo): Water heater repair. Urgency: Today. Location: 78704. Readiness: Urgent.', + body: '🔥 Hot missed-call lead\n\nName: Pat Morgan\nService: Water heater repair\nUrgency: Today\nLocation: 78704\nCallback: Afternoon\n\nCall now: +1 (512) 555-0177\nView lead: https://app.callbackcloser.com/app/leads/lead_demo_001', }), makeOwnerNotification({ id: 'notify_demo_002', @@ -485,7 +459,7 @@ const leadBOwnerNotifications: OwnerNotification[] = [ channel: OwnerNotificationChannel.SMS, status: OwnerNotificationStatus.SENT, destination: '+15125550199', - body: 'CallbackCloser lead for Northside HVAC & Plumbing (Demo): AC not cooling. Urgency: Today. Location: 78660. Readiness: Urgent.', + body: '🔥 Hot missed-call lead\n\nName: Not captured\nService: AC not cooling\nUrgency: Today\nLocation: 78660\nCallback: Not captured\n\nCall now: +1 (512) 555-0222\nView lead: https://app.callbackcloser.com/app/leads/lead_demo_002', createdAt: new Date('2026-02-24T13:36:02.000Z'), updatedAt: new Date('2026-02-24T13:36:02.000Z'), sentAt: new Date('2026-02-24T13:36:02.000Z'), diff --git a/lib/sms-state-machine.ts b/lib/sms-state-machine.ts index aef5adb..36e8c61 100644 --- a/lib/sms-state-machine.ts +++ b/lib/sms-state-machine.ts @@ -34,32 +34,77 @@ function normalizeText(text: string) { return text.trim(); } +function normalizeWhitespace(text: string) { + return text.replace(/\s+/g, ' ').trim(); +} + function lower(text: string) { return normalizeText(text).toLowerCase(); } -export function getServicePrompt(business: BusinessPromptConfig) { - return `CallbackCloser: We missed your call. What service do you need? Reply 1 for ${business.serviceLabel1}, 2 for ${business.serviceLabel2}, 3 for ${business.serviceLabel3}, or reply with a short description. Reply STOP to opt out or HELP for help. Msg freq varies. Msg & data rates may apply.`; +function cleanSegment(value: string) { + return value.replace(/^[\s,;:.-]+|[\s,;:.-]+$/g, '').trim(); +} + +function looksLikeName(value: string) { + const trimmed = normalizeWhitespace(value); + if (!trimmed || /\d/.test(trimmed)) return false; + + const tokens = trimmed.split(' '); + if (tokens.length < 1 || tokens.length > 4) return false; + + return tokens.every((token) => /^[A-Za-z][A-Za-z'.-]*$/.test(token)); +} + +function looksLikeLocation(value: string) { + const trimmed = normalizeWhitespace(value); + if (!trimmed) return false; + + if (/\d{5}(?:-\d{4})?/.test(trimmed) || /\d/.test(trimmed)) return true; + if (/\b(st|street|ave|avenue|rd|road|dr|drive|ln|lane|blvd|boulevard|way|hwy|highway|suite|ste|apt|unit)\b/i.test(trimmed)) { + return true; + } + + return trimmed.length >= 3; +} + +export function getServicePrompt(_business: BusinessPromptConfig) { + return `Hey, sorry we missed your call. What can we help you with today? + +You can reply with something like: +Repair, estimate, installation, emergency, or anything else. +Reply STOP to opt out.`; } export function getUrgencyPrompt() { - return 'How urgent is it? Reply emergency, today, this week, or quote.'; + return `Got it — how soon do you need help? + +Reply: +1 Emergency +2 Today +3 This week +4 Just getting a quote`; } -export function getZipPrompt() { - return 'What ZIP code or service area should the tech know about?'; +export function getContactLocationPrompt() { + return 'Thanks. What name should we put on the request, and what city/ZIP or service address is this for?'; } export function getBestTimePrompt() { - return 'Would you like a callback today? Reply yes, no, or tell us a preferred time.'; -} + return `What’s the best time for someone to call you back? -export function getNamePrompt() { - return 'What name should we ask for? Reply with your name or type skip.'; +Reply: +1 ASAP +2 Morning +3 Afternoon +4 Evening`; } -export function getCompletionPrompt() { - return 'Thanks. CallbackCloser has your details and will pass them along for follow-up shortly.'; +export function getCompletionPrompt(customerName?: string | null) { + const greeting = customerName ? `Thanks, ${customerName} —` : 'Thanks —'; + return `${greeting} we have your request. Someone will reach out as soon as possible. + +If there’s anything important we should know, you can reply here.`; } function parseService(input: string, business: BusinessPromptConfig) { @@ -86,10 +131,12 @@ function parseUrgency(input: string) { '3': 'This week', week: 'This week', 'this week': 'This week', - '4': 'Quote', - quote: 'Quote', - estimate: 'Quote', + '4': 'Just getting a quote', + quote: 'Just getting a quote', + estimate: 'Just getting a quote', + 'just getting a quote': 'Just getting a quote', }; + return map[value] ?? null; } @@ -101,24 +148,108 @@ function parseZip(input: string) { return null; } +function parseContactLocation(input: string) { + const text = normalizeWhitespace(input); + if (!text) { + return { + callerName: null, + contactName: null, + location: null, + zipCode: null, + }; + } + + const delimitedMatch = text.match(/^(.+?)(?:\s*(?:,|;|-|–|—)\s*)(.+)$/); + if (delimitedMatch) { + const candidateName = cleanSegment(delimitedMatch[1] || ''); + const candidateLocation = cleanSegment(delimitedMatch[2] || ''); + + if (looksLikeName(candidateName) && looksLikeLocation(candidateLocation)) { + return { + callerName: candidateName, + contactName: candidateName, + location: candidateLocation, + zipCode: parseZip(candidateLocation), + }; + } + } + + const inMatch = text.match(/^(.+?)\s+\bin\b\s+(.+)$/i); + if (inMatch) { + const candidateName = cleanSegment(inMatch[1] || ''); + const candidateLocation = cleanSegment(inMatch[2] || ''); + + if (looksLikeName(candidateName) && looksLikeLocation(candidateLocation)) { + return { + callerName: candidateName, + contactName: candidateName, + location: candidateLocation, + zipCode: parseZip(candidateLocation), + }; + } + } + + if (looksLikeName(text)) { + return { + callerName: text, + contactName: text, + location: null, + zipCode: null, + }; + } + + return { + callerName: null, + contactName: null, + location: text, + zipCode: parseZip(text), + }; +} + function parseCallbackPreference(input: string) { const value = lower(input); if (!value) return null; - if (['no', 'no callback', 'text only'].includes(value)) { - return { callbackRequested: false, bestTime: null }; - } - if (['yes', 'call', 'call me', 'please call', 'today'].includes(value)) { - return { callbackRequested: true, bestTime: 'Today' }; + + const map: Record = { + '1': { callbackRequested: true, bestTime: 'ASAP' }, + asap: { callbackRequested: true, bestTime: 'ASAP' }, + urgent: { callbackRequested: true, bestTime: 'ASAP' }, + now: { callbackRequested: true, bestTime: 'ASAP' }, + soon: { callbackRequested: true, bestTime: 'ASAP' }, + anytime: { callbackRequested: true, bestTime: 'ASAP' }, + '2': { callbackRequested: true, bestTime: 'Morning' }, + morning: { callbackRequested: true, bestTime: 'Morning' }, + am: { callbackRequested: true, bestTime: 'Morning' }, + '3': { callbackRequested: true, bestTime: 'Afternoon' }, + afternoon: { callbackRequested: true, bestTime: 'Afternoon' }, + '4': { callbackRequested: true, bestTime: 'Evening' }, + evening: { callbackRequested: true, bestTime: 'Evening' }, + tonight: { callbackRequested: true, bestTime: 'Evening' }, + yes: { callbackRequested: true, bestTime: 'ASAP' }, + call: { callbackRequested: true, bestTime: 'ASAP' }, + 'call me': { callbackRequested: true, bestTime: 'ASAP' }, + 'please call': { callbackRequested: true, bestTime: 'ASAP' }, + no: { callbackRequested: false, bestTime: 'Text only' }, + 'no callback': { callbackRequested: false, bestTime: 'Text only' }, + 'text only': { callbackRequested: false, bestTime: 'Text only' }, + }; + + if (map[value]) { + return map[value]; } - if (value.length >= 2 && value.length <= 40) { + if (value.length >= 2 && value.length <= 80) { return { callbackRequested: true, bestTime: normalizeText(input) }; } return null; } -export function advanceLeadConversation(lead: Pick, body: string, business: BusinessPromptConfig): TransitionResult { +export function advanceLeadConversation( + lead: Pick, + body: string, + business: BusinessPromptConfig +): TransitionResult { const state = lead.smsState; const text = normalizeText(body); @@ -135,9 +266,12 @@ export function advanceLeadConversation(lead: Pick, body: stri if (!service) { return { ok: false, - responseText: `Please reply 1, 2, or 3, or send a short service description. ${getServicePrompt(business)}`, + responseText: `Please tell us what you need help with in a few words. + +${getServicePrompt(business)}`, }; } + return { ok: true, nextState: SmsConversationState.AWAITING_URGENCY, @@ -151,31 +285,31 @@ export function advanceLeadConversation(lead: Pick, body: stri if (!urgency) { return { ok: false, - responseText: 'Please reply 1, 2, 3, or 4 for urgency. ' + getUrgencyPrompt(), + responseText: 'Please reply 1, 2, 3, or 4 for urgency.\n\n' + getUrgencyPrompt(), }; } + return { ok: true, nextState: SmsConversationState.AWAITING_ZIP, leadUpdates: { urgency }, - responseText: getZipPrompt(), + responseText: getContactLocationPrompt(), markQualified: true, }; } case SmsConversationState.AWAITING_ZIP: { - const location = normalizeText(text); - if (!location || location.length < 3 || location.length > 80) { + if (!text || text.length < 2 || text.length > 140) { return { ok: false, - responseText: 'Please reply with a ZIP code or short service area description.', + responseText: 'Please reply with your name and the city, ZIP, or service address if you can.', }; } - const zipCode = parseZip(text); + return { ok: true, nextState: SmsConversationState.AWAITING_BEST_TIME, - leadUpdates: { location, zipCode }, + leadUpdates: parseContactLocation(text), responseText: getBestTimePrompt(), }; } @@ -185,15 +319,18 @@ export function advanceLeadConversation(lead: Pick, body: stri if (!callback) { return { ok: false, - responseText: 'Please reply yes, no, or tell us the best callback time.', + responseText: 'Please reply 1, 2, 3, 4, or tell us the best callback time.', }; } + return { ok: true, - nextState: SmsConversationState.AWAITING_NAME, + nextState: SmsConversationState.COMPLETED, leadUpdates: { callbackRequested: callback.callbackRequested, bestTime: callback.bestTime }, - responseText: getNamePrompt(), + responseText: getCompletionPrompt(lead.callerName || lead.contactName), markQualified: true, + notifyOwner: true, + completed: true, }; } @@ -204,7 +341,7 @@ export function advanceLeadConversation(lead: Pick, body: stri ok: true, nextState: SmsConversationState.COMPLETED, leadUpdates: { contactName, callerName: contactName }, - responseText: getCompletionPrompt(), + responseText: getCompletionPrompt(contactName), notifyOwner: true, completed: true, }; diff --git a/lib/twilio-messaging.ts b/lib/twilio-messaging.ts index 942e77d..14fbe6c 100644 --- a/lib/twilio-messaging.ts +++ b/lib/twilio-messaging.ts @@ -254,19 +254,21 @@ export function buildOwnerNotificationMessage(params: { businessName: string; leadId: string; callerPhone: string; + customerName?: string | null; serviceRequested?: string | null; urgency?: string | null; - zipCode?: string | null; + location?: string | null; bestTime?: string | null; leadUrl: string; }) { const parts = [ - `[CallbackCloser] ${params.businessName} missed-call lead`, - `Caller: ${params.callerPhone}`, + `🔥 ${params.businessName} missed-call lead`, + `Name: ${params.customerName || 'Not captured'}`, `Service: ${params.serviceRequested || 'Unknown'}`, `Urgency: ${params.urgency || 'Unknown'}`, - `ZIP: ${params.zipCode || 'Unknown'}`, - `Best time: ${params.bestTime || 'Unknown'}`, + `Location: ${params.location || 'Unknown'}`, + `Callback: ${params.bestTime || 'Unknown'}`, + `Call now: ${params.callerPhone}`, `Lead: ${params.leadUrl}`, ]; diff --git a/tests/lead-qualification.test.ts b/tests/lead-qualification.test.ts index 77efc6a..e0f0c83 100644 --- a/tests/lead-qualification.test.ts +++ b/tests/lead-qualification.test.ts @@ -11,16 +11,17 @@ import { isUrgentLead, } from '../lib/lead-qualification.ts'; -test('lead qualifies once service type and urgency are known', () => { +test('lead qualifies once service type, urgency, and callback timing are known', () => { const lead = { serviceType: 'Water heater repair', serviceRequested: null, urgency: 'Today', - callbackRequested: null, + callbackRequested: true, location: '78704', zipCode: null, callerName: 'Pat Morgan', contactName: null, + bestTime: 'Afternoon', callerPhoneNormalized: '+15125550177', status: LeadStatus.NEW, notifiedAt: null, @@ -34,16 +35,17 @@ test('lead qualifies once service type and urgency are known', () => { assert.match(buildLeadSummary(lead), /78704/); }); -test('lead can qualify from callback intent even without urgency', () => { +test('lead can qualify from callback intent when urgency is already known', () => { const lead = { serviceType: 'Drain cleaning', serviceRequested: null, - urgency: null, + urgency: 'This week', callbackRequested: true, location: null, zipCode: '78660', callerName: null, contactName: null, + bestTime: 'ASAP', callerPhoneNormalized: '+15125550222', status: LeadStatus.NEW, notifiedAt: null, @@ -56,14 +58,15 @@ test('lead can qualify from callback intent even without urgency', () => { test('lead stays new until required qualification fields are present', () => { const lead = { - serviceType: null, - serviceRequested: null, + serviceType: 'Water heater repair', + serviceRequested: 'Water heater repair', urgency: 'Today', callbackRequested: null, location: null, zipCode: null, callerName: null, contactName: null, + bestTime: null, callerPhoneNormalized: '+15125550333', status: LeadStatus.NEW, notifiedAt: null, @@ -73,3 +76,23 @@ test('lead stays new until required qualification fields are present', () => { assert.equal(getLeadReadiness(lead), LeadReadiness.PENDING); assert.equal(getQualifiedLeadStatus(lead), LeadStatus.NEW); }); + +test('lead summary includes callback timing and customer name when captured', () => { + const lead = { + serviceType: 'Panel upgrade', + serviceRequested: null, + urgency: 'Emergency', + callbackRequested: true, + location: 'Knoxville', + zipCode: null, + callerName: 'Jordan', + contactName: null, + bestTime: 'ASAP', + callerPhoneNormalized: '+18655550111', + }; + + const summary = buildLeadSummary(lead); + + assert.match(summary, /Callback: ASAP/); + assert.match(summary, /Name: Jordan/); +}); diff --git a/tests/leads-workspace-routing.test.ts b/tests/leads-workspace-routing.test.ts index ac32f76..ac3ec09 100644 --- a/tests/leads-workspace-routing.test.ts +++ b/tests/leads-workspace-routing.test.ts @@ -45,6 +45,10 @@ test('lead inbox stays list-only while lead detail is the main action workspace' assert.match(leadDetailPage, /Conversation history/); assert.match(leadDetailPage, /Qualification info/); assert.match(leadDetailPage, /Missed call details/); + assert.match(leadDetailPage, /Customer name/); + assert.match(leadDetailPage, /Preferred callback time/); + assert.match(leadDetailPage, /Location \/ address/); + 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/); diff --git a/tests/missed-call-flow.test.ts b/tests/missed-call-flow.test.ts new file mode 100644 index 0000000..614d053 --- /dev/null +++ b/tests/missed-call-flow.test.ts @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +import { SmsConversationState } from '@prisma/client'; + +import { db } from '../lib/db.ts'; +import { cleanupTenantFixtures, seedTenantFixtures } from './tenant-fixtures.ts'; + +async function loadMissedCallModules() { + const require = createRequire(import.meta.url); + const serverOnlyPath = require.resolve('server-only'); + require.cache[serverOnlyPath] = { + id: serverOnlyPath, + filename: serverOnlyPath, + loaded: true, + exports: {}, + } as NodeJS.Module; + + const [{ startMissedCallRecovery, processLeadInboundReply }, { advanceLeadConversation }] = await Promise.all([ + import('../lib/missed-call-flow.ts'), + import('../lib/sms-state-machine.ts'), + ]); + + return { startMissedCallRecovery, processLeadInboundReply, advanceLeadConversation }; +} + +test('missed-call recovery collects richer lead details and formats the owner alert', async () => { + const fixtures = await seedTenantFixtures(); + const callerPhone = '+18655550999'; + + try { + const { startMissedCallRecovery, processLeadInboundReply } = await loadMissedCallModules(); + const recovery = await startMissedCallRecovery({ + business: fixtures.businessA, + callerPhone, + isSimulator: true, + transport: 'simulated', + }); + + assert.equal(recovery.started, true); + + const introMessage = await db.message.findFirst({ + where: { + leadId: recovery.lead.id, + direction: 'OUTBOUND', + }, + orderBy: { createdAt: 'asc' }, + select: { body: true }, + }); + + assert.match(introMessage?.body ?? '', /What can we help you with today/i); + assert.match(introMessage?.body ?? '', /Reply STOP to opt out/i); + + const serviceReply = await processLeadInboundReply({ + business: fixtures.businessA, + leadId: recovery.lead.id, + body: 'Repair', + fromPhone: callerPhone, + toPhone: fixtures.businessA.twilioPrimaryPhoneNumber!, + transport: 'simulated', + }); + + assert.equal(serviceReply.transition?.nextState, SmsConversationState.AWAITING_URGENCY); + assert.match(serviceReply.transition?.responseText ?? '', /how soon do you need help/i); + + const urgencyReply = await processLeadInboundReply({ + business: fixtures.businessA, + leadId: recovery.lead.id, + body: '2', + fromPhone: callerPhone, + toPhone: fixtures.businessA.twilioPrimaryPhoneNumber!, + transport: 'simulated', + }); + + assert.equal(urgencyReply.transition?.nextState, SmsConversationState.AWAITING_ZIP); + assert.match(urgencyReply.transition?.responseText ?? '', /what name should we put on the request/i); + + const contactLocationReply = await processLeadInboundReply({ + business: fixtures.businessA, + leadId: recovery.lead.id, + body: 'Sarah Miller - 123 Main St, Oak Ridge', + fromPhone: callerPhone, + toPhone: fixtures.businessA.twilioPrimaryPhoneNumber!, + transport: 'simulated', + }); + + assert.equal(contactLocationReply.transition?.nextState, SmsConversationState.AWAITING_BEST_TIME); + assert.equal(contactLocationReply.lead.callerName, 'Sarah Miller'); + assert.equal(contactLocationReply.lead.location, '123 Main St, Oak Ridge'); + + const callbackReply = await processLeadInboundReply({ + business: fixtures.businessA, + leadId: recovery.lead.id, + body: '1', + fromPhone: callerPhone, + toPhone: fixtures.businessA.twilioPrimaryPhoneNumber!, + transport: 'simulated', + }); + + assert.equal(callbackReply.transition?.nextState, SmsConversationState.COMPLETED); + assert.equal(callbackReply.lead.bestTime, 'ASAP'); + assert.equal(callbackReply.lead.smsState, SmsConversationState.COMPLETED); + assert.match(callbackReply.transition?.responseText ?? '', /Thanks, Sarah Miller/i); + + const updatedLead = await db.lead.findUniqueOrThrow({ + where: { id: recovery.lead.id }, + select: { + callerName: true, + location: true, + bestTime: true, + urgency: true, + serviceType: true, + summary: true, + notifiedAt: true, + }, + }); + + assert.equal(updatedLead.callerName, 'Sarah Miller'); + assert.equal(updatedLead.location, '123 Main St, Oak Ridge'); + assert.equal(updatedLead.bestTime, 'ASAP'); + assert.match(updatedLead.summary ?? '', /Callback: ASAP/); + assert.ok(updatedLead.notifiedAt); + + const ownerAlert = await db.ownerNotification.findUniqueOrThrow({ + where: { + leadId_channel: { + leadId: recovery.lead.id, + channel: 'SMS', + }, + }, + select: { body: true, status: true }, + }); + + assert.equal(ownerAlert.status, 'SENT'); + assert.match(ownerAlert.body, /Name: Sarah Miller/); + assert.match(ownerAlert.body, /Service: Repair/); + assert.match(ownerAlert.body, /Urgency: Today/); + assert.match(ownerAlert.body, /Location: 123 Main St, Oak Ridge/); + assert.match(ownerAlert.body, /Callback: ASAP/); + assert.match(ownerAlert.body, /Call now: /); + assert.match(ownerAlert.body, /View lead: .*\/app\/leads\//); + } finally { + await cleanupTenantFixtures({ + businessAId: fixtures.businessA.id, + businessBId: fixtures.businessB.id, + }); + } +}); + +test('contact/location parsing and free-text callback fallback do not break the flow', async () => { + const businessPromptConfig = { + serviceLabel1: 'Repair', + serviceLabel2: 'Install', + serviceLabel3: 'Tune-up', + }; + const { advanceLeadConversation } = await loadMissedCallModules(); + + const contactOnlyTransition = advanceLeadConversation( + { + smsState: SmsConversationState.AWAITING_ZIP, + callerName: null, + contactName: null, + }, + 'Mike in Clinton', + businessPromptConfig + ); + + assert.equal(contactOnlyTransition.nextState, SmsConversationState.AWAITING_BEST_TIME); + assert.equal(contactOnlyTransition.leadUpdates?.callerName, 'Mike'); + assert.equal(contactOnlyTransition.leadUpdates?.location, 'Clinton'); + + const uncertainReplyTransition = advanceLeadConversation( + { + smsState: SmsConversationState.AWAITING_ZIP, + callerName: null, + contactName: null, + }, + 'Caleb', + businessPromptConfig + ); + + assert.equal(uncertainReplyTransition.nextState, SmsConversationState.AWAITING_BEST_TIME); + assert.equal(uncertainReplyTransition.leadUpdates?.callerName, 'Caleb'); + assert.equal(uncertainReplyTransition.leadUpdates?.location ?? null, null); + + const callbackTransition = advanceLeadConversation( + { + smsState: SmsConversationState.AWAITING_BEST_TIME, + callerName: 'Caleb', + contactName: null, + }, + 'after 5pm', + businessPromptConfig + ); + + assert.equal(callbackTransition.nextState, SmsConversationState.COMPLETED); + assert.equal(callbackTransition.leadUpdates?.bestTime, 'after 5pm'); + assert.match(callbackTransition.responseText, /Thanks, Caleb/); +});