Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions app/app/leads/[leadId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -184,25 +188,26 @@ export default async function LeadDetailPage({
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border bg-background/90 p-4 text-sm">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Phone</p>
<p className="mt-2 font-medium">{formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone)}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Customer</p>
<p className="mt-2 font-medium">{customerName}</p>
<p className="mt-2 text-muted-foreground">{customerPhone}</p>
<p className="mt-2 text-muted-foreground">Created {formatRelativeTime(lead.createdAt)}.</p>
</div>
<div className="rounded-2xl border bg-background/90 p-4 text-sm">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Service</p>
<p className="mt-2 font-medium">{lead.serviceType || lead.serviceRequested || 'Still being captured'}</p>
<p className="mt-2 font-medium">{serviceLabel}</p>
<p className="mt-2 text-muted-foreground">Urgency: {lead.urgency || 'Pending reply'}</p>
</div>
<div className="rounded-2xl border bg-background/90 p-4 text-sm">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Callback timing</p>
<p className="mt-2 font-medium">{lead.bestTime || 'No preferred time yet'}</p>
<p className="mt-2 font-medium">{callbackLabel}</p>
<p className="mt-2 text-muted-foreground">
Callback requested: {typeof lead.callbackRequested === 'boolean' ? (lead.callbackRequested ? 'Yes' : 'No') : 'Not answered'}
</p>
</div>
<div className="rounded-2xl border bg-background/90 p-4 text-sm">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Location</p>
<p className="mt-2 font-medium">{lead.location || lead.zipCode || 'Still being captured'}</p>
<p className="mt-2 font-medium">{locationLabel}</p>
<p className="mt-2 text-muted-foreground">{isOpenLead ? 'Still needs an outcome update.' : 'Outcome already captured.'}</p>
</div>
</CardContent>
Expand Down Expand Up @@ -248,18 +253,34 @@ export default async function LeadDetailPage({
<CardDescription>The notes CallbackCloser collected before you call back.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Customer name</span>
<span>{customerName}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Phone number</span>
<span>{customerPhone}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Summary</span>
<span>{lead.summary || 'Summary will update as the intake progresses.'}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Service requested</span>
<span>{lead.serviceType || lead.serviceRequested || 'Still being captured'}</span>
<span className="text-muted-foreground">Service needed</span>
<span>{serviceLabel}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Urgency</span>
<span>{lead.urgency || 'Pending reply'}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Location / address</span>
<span>{locationLabel}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Preferred callback time</span>
<span>{callbackLabel}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Qualified at</span>
<span>{formatDateTime(lead.qualifiedAt)}</span>
Expand Down
7 changes: 7 additions & 0 deletions components/customer-lead-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';
Expand Down Expand Up @@ -87,6 +93,7 @@ export function CustomerLeadRow({
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span>{getServiceLabel(lead)}</span>
<span>{getUrgencyLabel(lead)}</span>
<span>{getLocationLabel(lead)}</span>
<span>{formatRelativeTime(activityAt)}</span>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions lib/lead-presenters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export const smsStateLabels: Record<SmsConversationState, string> = {
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',
};
Expand Down
29 changes: 23 additions & 6 deletions lib/lead-qualification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type LeadQualificationFields = Pick<
| 'callbackRequested'
| 'callerName'
| 'contactName'
| 'bestTime'
| 'callerPhoneNormalized'
>;

Expand All @@ -25,23 +26,38 @@ export function getLeadCallerName(lead: Pick<LeadQualificationFields, 'callerNam
return lead.callerName || lead.contactName || null;
}

export function getLeadLocation(lead: Pick<LeadQualificationFields, 'location' | 'zipCode'>) {
return lead.location || lead.zipCode || null;
}

export function getLeadPreferredCallbackTime(lead: Pick<LeadQualificationFields, 'bestTime' | 'callbackRequested'>) {
if (lead.bestTime) return lead.bestTime;
if (lead.callbackRequested === false) return 'Text only';
return null;
}

export function isUrgentLead(lead: Pick<LeadQualificationFields, 'urgency'>) {
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<LeadQualificationFields, 'serviceType' | 'serviceRequested' | 'urgency' | 'callbackRequested'>) {
return hasValue(getLeadServiceType(lead)) && (hasValue(lead.urgency) || typeof lead.callbackRequested === 'boolean');
export function isLeadQualified(
lead: Pick<LeadQualificationFields, 'serviceType' | 'serviceRequested' | 'urgency' | 'callbackRequested' | 'bestTime'>
) {
return hasValue(getLeadServiceType(lead)) && hasValue(lead.urgency) && (hasValue(lead.bestTime) || typeof lead.callbackRequested === 'boolean');
}

export function getLeadReadiness(lead: Pick<LeadQualificationFields, 'serviceType' | 'serviceRequested' | 'urgency' | 'callbackRequested'>) {
export function getLeadReadiness(
lead: Pick<LeadQualificationFields, 'serviceType' | 'serviceRequested' | 'urgency' | 'callbackRequested' | 'bestTime'>
) {
if (!isLeadQualified(lead)) return LeadReadiness.PENDING;
return isUrgentLead(lead) ? LeadReadiness.URGENT : LeadReadiness.QUALIFIED;
}

export function getQualifiedLeadStatus(
lead: Pick<Lead, 'status' | 'notifiedAt'> & Pick<LeadQualificationFields, 'serviceType' | 'serviceRequested' | 'urgency' | 'callbackRequested'>
lead: Pick<Lead, 'status' | 'notifiedAt'> &
Pick<LeadQualificationFields, 'serviceType' | 'serviceRequested' | 'urgency' | 'callbackRequested' | 'bestTime'>
) {
if (lead.status === LeadStatus.CONTACTED || lead.status === LeadStatus.BOOKED || lead.status === LeadStatus.LOST) {
return lead.status;
Expand All @@ -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);

Expand Down
1 change: 1 addition & 0 deletions lib/missed-call-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ function buildLeadLifecycleUpdate(
| 'location'
| 'zipCode'
| 'callbackRequested'
| 'bestTime'
| 'callerName'
| 'contactName'
| 'callerPhoneNormalized'
Expand Down
55 changes: 45 additions & 10 deletions lib/owner-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,39 +51,67 @@ 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');

const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.5;">
<h2>CallbackCloser qualified a missed-call lead</h2>
<p><strong>Business:</strong> ${lead.business.name}</p>
<p><strong>Summary:</strong> ${summary}</p>
<p><strong>Name:</strong> ${customerName}</p>
<p><strong>Service:</strong> ${serviceType}</p>
<p><strong>Urgency:</strong> ${lead.urgency || 'Not captured'}</p>
<p><strong>Location:</strong> ${location}</p>
<p><strong>Callback:</strong> ${callbackTime}</p>
<p><strong>Caller:</strong> ${formatPhoneForDisplay(lead.callerPhoneNormalized || lead.callerPhone)}</p>
<p><strong>Lead status:</strong> ${lead.status}</p>
<p><strong>Summary:</strong> ${summary}</p>
<p><a href="${leadUrl}">Open lead in CallbackCloser</a></p>
</div>
`;
Expand Down
Loading
Loading