-
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({
+
+ 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/);
+});