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
18 changes: 12 additions & 6 deletions app/app/leads/[leadId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Button, buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { requireBusiness } from '@/lib/auth';
import { getLeadDetailForBusiness } from '@/lib/business-access';
import { isLeadClosedWonStatus, isLeadLostStatus } from '@/lib/lead-outcomes';
import {
formatDateTime,
formatRelativeTime,
Expand All @@ -32,19 +33,19 @@ function resolveSafeReturnPath(value: string | null | undefined) {
}

function getLeadActionSummary(status: LeadStatus) {
if (status === LeadStatus.BOOKED) {
return 'This lead is marked booked. Keep the details here for reference.';
if (isLeadClosedWonStatus(status)) {
return 'This lead is marked Closed (Won). Keep the details here for reference.';
}

if (status === LeadStatus.LOST) {
if (isLeadLostStatus(status)) {
return 'This lead is marked lost. You can still review the call and message history here.';
}

if (status === LeadStatus.CONTACTED) {
return 'You have already made contact. Update the final outcome when the customer decides.';
}

return 'Call this lead back, then update the outcome so your inbox stays clear.';
return 'Call this lead back, then mark it Closed (Won) or Lost so your conversion summary stays accurate.';
}

function LeadStatusActionForm({
Expand Down Expand Up @@ -137,15 +138,20 @@ export default async function LeadDetailPage({
{lead.notifiedAt || lead.ownerNotifiedAt ? <Badge variant="secondary">Owner alerted</Badge> : null}
</div>
<p className="max-w-2xl text-sm text-muted-foreground">{getLeadActionSummary(lead.status)}</p>
{isOpenLead ? (
<div className="rounded-2xl border bg-background/90 px-4 py-3 text-sm text-muted-foreground">
Did this lead turn into a real job? Mark the outcome here after the call so CallbackCloser can show the value clearly.
</div>
) : null}
</div>

<div className="grid w-full gap-3 xl:max-w-2xl xl:grid-cols-[minmax(0,1.35fr)_repeat(3,minmax(0,1fr))]">
<Link className={buttonVariants({ className: 'w-full' })} href={`tel:${lead.callerPhoneNormalized || lead.callerPhone}`}>
Call now
</Link>
<LeadStatusActionForm leadId={lead.id} status="CONTACTED" redirectTo={redirectTo} label="Mark contacted" variant="outline" />
<LeadStatusActionForm leadId={lead.id} status="BOOKED" redirectTo={redirectTo} label="Mark booked" />
<LeadStatusActionForm leadId={lead.id} status="LOST" redirectTo={redirectTo} label="Mark lost" variant="destructive" />
<LeadStatusActionForm leadId={lead.id} status="BOOKED" redirectTo={redirectTo} label="Mark as Closed (Won)" />
<LeadStatusActionForm leadId={lead.id} status="LOST" redirectTo={redirectTo} label="Mark as Lost" variant="destructive" />
</div>
</div>
</CardHeader>
Expand Down
1 change: 1 addition & 0 deletions app/app/leads/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export async function updateLeadStatusAction(formData: FormData) {
}

revalidatePath('/app/leads');
revalidatePath('/app');
revalidatePath('/app/conversations');
revalidatePath(`/app/leads/${lead.id}`);
redirect(`${redirectTo}${redirectTo.includes('?') ? '&' : '?'}saved=1`);
Expand Down
14 changes: 11 additions & 3 deletions app/app/leads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import Link from 'next/link';
import { LeadReadiness, LeadStatus } from '@prisma/client';

import { CustomerLeadRow } from '@/components/customer-lead-row';
import { LeadConversionSummaryCard } from '@/components/lead-conversion-summary-card';
import { Badge } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { requireBusiness } from '@/lib/auth';
import { listAllDashboardLeadsForBusiness } from '@/lib/business-access';
import { getLeadOutcomeSummary } from '@/lib/lead-outcomes';
import { getLeadLastActivityAt, isLeadOpenStatus, leadStatusLabels } from '@/lib/lead-presenters';
import { getPortfolioDemoLeads, isPortfolioDemoMode } from '@/lib/portfolio-demo';
import { cn } from '@/lib/utils';
Expand Down Expand Up @@ -110,6 +112,7 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc
const view = statusFilter ? 'all' : parseInboxView(typeof searchParams?.view === 'string' ? searchParams.view : undefined);
const allLeads = demoMode ? getPortfolioDemoLeads(null) : await listAllDashboardLeadsForBusiness(business.id);
const hasLeads = allLeads.length > 0;
const outcomeSummary = getLeadOutcomeSummary(allLeads);

const filteredLeads = statusFilter
? allLeads.filter((lead) => lead.status === statusFilter)
Expand Down Expand Up @@ -138,7 +141,7 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc
},
{
key: 'booked' as const,
label: 'Booked',
label: 'Closed',
href: buildLeadsHref('booked'),
count: allLeads.filter((lead) => lead.status === LeadStatus.BOOKED).length,
active: !statusFilter && view === 'booked',
Expand All @@ -155,8 +158,8 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc
const listDescription = statusFilter
? `Showing ${leadStatusLabels[statusFilter].toLowerCase()} leads. Open a lead to act on it.`
: view === 'attention'
? 'Showing leads that still need action. Open one to call back or update the outcome.'
: 'Open any lead to review the conversation and update the outcome from the detail page.';
? 'Showing leads that still need action. Open one to call back or mark the final outcome.'
: 'Open any lead to review the conversation and mark it closed or lost from the detail page.';

return (
<div className="space-y-6">
Expand All @@ -173,6 +176,11 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc
{error ? <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{error}</div> : null}
{saved ? <div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">Lead updated.</div> : null}

<LeadConversionSummaryCard
description="Keep the win/loss numbers obvious so you can see whether missed calls are turning into booked jobs."
summary={outcomeSummary}
/>

{!hasLeads ? (
<Card className="border-primary/20 bg-primary/5">
<CardHeader>
Expand Down
106 changes: 8 additions & 98 deletions app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import Link from 'next/link';
import { LeadReadiness, LeadStatus } from '@prisma/client';

import { CustomerLeadRow } from '@/components/customer-lead-row';
import { LeadConversionSummaryCard } from '@/components/lead-conversion-summary-card';
import { Badge } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { requireBusiness } from '@/lib/auth';
import { listAllDashboardLeadsForBusiness } from '@/lib/business-access';
import { db } from '@/lib/db';
import { getLeadOutcomeSummary } from '@/lib/lead-outcomes';
import { getLeadLastActivityAt, isLeadOpenStatus } from '@/lib/lead-presenters';
import { getPortfolioDemoBusiness, getPortfolioDemoLeads, isPortfolioDemoMode } from '@/lib/portfolio-demo';
import { getCustomerSystemStatus } from '@/lib/system-status';
Expand All @@ -16,60 +18,6 @@ function buildLeadDetailHref(leadId: string) {
return `/app/leads/${leadId}?from=%2Fapp`;
}

function parseTimeZoneOffsetMinutes(value: string) {
const match = value.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
if (!match) return 0;

const sign = match[1] === '+' ? 1 : -1;
const hours = Number(match[2]);
const minutes = Number(match[3] || '0');
return sign * (hours * 60 + minutes);
}

function getTimeZoneOffsetMinutes(date: Date, timeZone: string) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
timeZoneName: 'shortOffset',
hour: '2-digit',
minute: '2-digit',
}).formatToParts(date);

const label = parts.find((part) => part.type === 'timeZoneName')?.value || 'GMT';
return parseTimeZoneOffsetMinutes(label);
}

function getTodayRangeForTimeZone(timeZone: string) {
try {
const now = new Date();
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now);

const year = Number(parts.find((part) => part.type === 'year')?.value || now.getUTCFullYear());
const month = Number(parts.find((part) => part.type === 'month')?.value || now.getUTCMonth() + 1);
const day = Number(parts.find((part) => part.type === 'day')?.value || now.getUTCDate());

const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
const endGuess = new Date(Date.UTC(year, month - 1, day + 1, 0, 0, 0));

return {
start: new Date(startGuess.getTime() - getTimeZoneOffsetMinutes(startGuess, timeZone) * 60_000),
end: new Date(endGuess.getTime() - getTimeZoneOffsetMinutes(endGuess, timeZone) * 60_000),
};
} catch {
const start = new Date();
start.setHours(0, 0, 0, 0);

const end = new Date(start);
end.setDate(end.getDate() + 1);

return { start, end };
}
}

function getAttentionPriority(lead: {
status: LeadStatus;
readiness: LeadReadiness;
Expand Down Expand Up @@ -102,30 +50,19 @@ function getAttentionPriority(lead: {
export default async function AppHomePage() {
const demoMode = isPortfolioDemoMode();
const business = demoMode ? getPortfolioDemoBusiness() : await requireBusiness();
const { start, end } = getTodayRangeForTimeZone(business.timezone || 'America/New_York');

const [allLeads, notificationSettings, missedCallsToday] = demoMode
const [allLeads, notificationSettings] = demoMode
? [
getPortfolioDemoLeads(null),
null,
getPortfolioDemoLeads(null).filter((lead) => lead.createdAt >= start && lead.createdAt < end).length,
]
: await Promise.all([
listAllDashboardLeadsForBusiness(business.id),
db.businessNotificationSettings.findUnique({ where: { businessId: business.id } }),
db.call.count({
where: {
businessId: business.id,
missed: true,
createdAt: {
gte: start,
lt: end,
},
},
}),
]);

const successfulLeadCount = allLeads.filter((lead) => lead.ownerNotifiedAt || lead.notifiedAt).length;
const outcomeSummary = getLeadOutcomeSummary(allLeads);
const systemStatus = getCustomerSystemStatus(business, successfulLeadCount);
const attentionLeads = allLeads
.filter((lead) => isLeadOpenStatus(lead.status))
Expand Down Expand Up @@ -155,29 +92,6 @@ export default async function AppHomePage() {
(notificationSettings?.notifyEmail && notificationSettings.ownerEmail),
);

const summaryCards = [
{
label: 'New leads',
value: allLeads.filter((lead) => lead.status === LeadStatus.NEW).length,
href: '/app/leads?status=new',
},
{
label: 'Needs follow-up',
value: allLeads.filter((lead) => isLeadOpenStatus(lead.status)).length,
href: '/app/leads?view=attention',
},
{
label: 'Booked',
value: allLeads.filter((lead) => lead.status === LeadStatus.BOOKED).length,
href: '/app/leads?status=booked',
},
{
label: 'Missed calls today',
value: missedCallsToday,
href: '/app/leads?view=attention',
},
];

const healthItems = [
{
label: 'Texting',
Expand Down Expand Up @@ -213,14 +127,10 @@ export default async function AppHomePage() {
</Link>
</section>

<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{summaryCards.map((card) => (
<Link key={card.label} href={card.href} className="rounded-2xl border bg-card p-5 transition-colors hover:bg-muted/20">
<p className="text-sm text-muted-foreground">{card.label}</p>
<p className="mt-3 text-3xl font-semibold tracking-tight">{card.value}</p>
</Link>
))}
</section>
<LeadConversionSummaryCard
description="Keep the win/loss numbers obvious without opening a separate analytics dashboard."
summary={outcomeSummary}
/>

<section className="grid gap-6 xl:grid-cols-[1.4fr_0.85fr]">
<Card className="bg-card/95">
Expand Down
66 changes: 66 additions & 0 deletions components/lead-conversion-summary-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Link from 'next/link';

import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatConversionRate, type LeadOutcomeSummary } from '@/lib/lead-outcomes';
import { cn } from '@/lib/utils';

export function LeadConversionSummaryCard({
summary,
title = 'Conversion summary',
description = 'Only the few numbers that show whether missed calls are turning into real jobs.',
className,
}: {
summary: LeadOutcomeSummary;
title?: string;
description?: string;
className?: string;
}) {
const metrics = [
{
label: 'Leads',
value: String(summary.totalLeads),
href: '/app/leads',
},
{
label: 'Closed',
value: String(summary.closedLeads),
href: '/app/leads?status=booked',
},
{
label: 'Lost',
value: String(summary.lostLeads),
href: '/app/leads?status=lost',
},
{
label: 'Conversion rate',
value: formatConversionRate(summary.conversionRate),
},
];

return (
<Card className={cn('bg-card/95', className)}>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{metrics.map((metric) =>
metric.href ? (
<Link key={metric.label} className="rounded-2xl border bg-background/90 p-4 transition-colors hover:bg-muted/20" href={metric.href}>
<p className="text-sm text-muted-foreground">{metric.label}</p>
<p className="mt-2 text-2xl font-semibold tracking-tight">{metric.value}</p>
</Link>
) : (
<div key={metric.label} className="rounded-2xl border bg-background/90 p-4">
<p className="text-sm text-muted-foreground">{metric.label}</p>
<p className="mt-2 text-2xl font-semibold tracking-tight">{metric.value}</p>
</div>
),
)}
</div>
<p className="text-xs text-muted-foreground">Conversion rate = closed leads divided by total missed-call leads.</p>
</CardContent>
</Card>
);
}
52 changes: 52 additions & 0 deletions lib/lead-outcomes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { LeadStatus } from '@prisma/client';

export type LeadOutcomeSummary = {
totalLeads: number;
closedLeads: number;
lostLeads: number;
openLeads: number;
conversionRate: number;
};

type LeadOutcomeInput = {
status: LeadStatus;
};

export function isLeadClosedWonStatus(status: LeadStatus) {
return status === LeadStatus.BOOKED;
}

export function isLeadLostStatus(status: LeadStatus) {
return status === LeadStatus.LOST;
}

export function getLeadOutcomeSummary(leads: LeadOutcomeInput[]): LeadOutcomeSummary {
const summary = leads.reduce(
(result, lead) => {
if (isLeadClosedWonStatus(lead.status)) {
result.closedLeads += 1;
} else if (isLeadLostStatus(lead.status)) {
result.lostLeads += 1;
} else {
result.openLeads += 1;
}

return result;
},
{
totalLeads: leads.length,
closedLeads: 0,
lostLeads: 0,
openLeads: 0,
conversionRate: 0,
},
);

summary.conversionRate = summary.totalLeads === 0 ? 0 : Math.round((summary.closedLeads / summary.totalLeads) * 100);

return summary;
}

export function formatConversionRate(value: number) {
return `${value}%`;
}
Loading
Loading