feat: build Campaigns list and Campaign Detail pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 15:01:00 +05:30
parent 7970a34434
commit 41eadad0b3
11 changed files with 933 additions and 10 deletions

View File

@@ -0,0 +1,79 @@
import { cx } from '@/utils/cx';
import { formatCurrency, formatCompact } from '@/lib/format';
import { AdStatusBadge } from '@/components/shared/status-badge';
import type { Ad, AdFormat } from '@/types/entities';
interface AdCardProps {
ad: Ad;
}
const formatPreviewStyles: Record<AdFormat, { bg: string; icon: string }> = {
IMAGE: { bg: 'bg-brand-solid', icon: 'IMG' },
VIDEO: { bg: 'bg-fg-brand-secondary', icon: 'VID' },
CAROUSEL: { bg: 'bg-error-solid', icon: 'CAR' },
TEXT: { bg: 'bg-fg-tertiary', icon: 'TXT' },
LEAD_FORM: { bg: 'bg-success-solid', icon: 'FORM' },
};
const formatBadgeLabel = (format: AdFormat): string =>
format.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
export const AdCard = ({ ad }: AdCardProps) => {
const format = ad.adFormat ?? 'IMAGE';
const preview = formatPreviewStyles[format] ?? formatPreviewStyles.IMAGE;
const currencyCode = ad.spend?.currencyCode ?? 'INR';
const metrics = [
{ label: 'Impr.', value: formatCompact(ad.impressions ?? 0) },
{ label: 'Clicks', value: formatCompact(ad.clicks ?? 0) },
{ label: 'Leads', value: String(ad.conversions ?? 0) },
{ label: 'Spend', value: ad.spend ? formatCurrency(ad.spend.amountMicros, currencyCode) : '--' },
];
return (
<div className="flex items-center gap-4 rounded-xl border border-secondary bg-primary p-4 transition hover:shadow-sm">
{/* Preview thumbnail */}
<div
className={cx(
'flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white',
preview.bg,
)}
>
{preview.icon}
</div>
{/* Ad info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="truncate text-sm font-bold text-primary">
{ad.adName ?? 'Untitled Ad'}
</h4>
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-tertiary">
{formatBadgeLabel(format)}
</span>
</div>
<p className="mt-0.5 text-xs text-quaternary">{ad.externalAdId ?? ad.id.slice(0, 12)}</p>
{ad.headline && (
<p className="mt-1 truncate text-xs text-tertiary">{ad.headline}</p>
)}
</div>
{/* Inline metrics */}
<div className="hidden shrink-0 items-center gap-4 md:flex">
{metrics.map((metric) => (
<div key={metric.label} className="text-center">
<p className="text-sm font-bold text-primary">{metric.value}</p>
<p className="text-xs text-quaternary">{metric.label}</p>
</div>
))}
</div>
{/* Status badge */}
{ad.adStatus && (
<div className="hidden shrink-0 sm:block">
<AdStatusBadge status={ad.adStatus} />
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,47 @@
import { cx } from '@/utils/cx';
import { formatCurrency } from '@/lib/format';
type CurrencyAmount = {
amountMicros: number;
currencyCode: string;
};
interface BudgetBarProps {
spent: CurrencyAmount | null;
budget: CurrencyAmount | null;
}
export const BudgetBar = ({ spent, budget }: BudgetBarProps) => {
const spentMicros = spent?.amountMicros ?? 0;
const budgetMicros = budget?.amountMicros ?? 0;
const ratio = budgetMicros > 0 ? spentMicros / budgetMicros : 0;
const percentage = Math.min(ratio * 100, 100);
const fillColor =
ratio > 0.9
? 'bg-error-solid'
: ratio > 0.7
? 'bg-warning-solid'
: 'bg-brand-solid';
const spentDisplay = spent ? formatCurrency(spent.amountMicros, spent.currencyCode) : '--';
const budgetDisplay = budget ? formatCurrency(budget.amountMicros, budget.currencyCode) : '--';
return (
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-xs font-medium text-tertiary">Budget</span>
<span className="text-xs text-tertiary">
{spentDisplay} / {budgetDisplay}
</span>
</div>
<div className="h-1.5 rounded-full bg-tertiary overflow-hidden">
<div
className={cx('h-full rounded-full transition-all duration-300', fillColor)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,139 @@
import { cx } from '@/utils/cx';
import { formatCurrency } from '@/lib/format';
import type { Campaign, Ad, Lead, LeadSource } from '@/types/entities';
import { CampaignStatusBadge } from '@/components/shared/status-badge';
import { BudgetBar } from './budget-bar';
import { HealthIndicator } from './health-indicator';
interface CampaignCardProps {
campaign: Campaign;
ads: Ad[];
leads: Lead[];
}
const sourceColors: Record<string, string> = {
FACEBOOK_AD: 'bg-brand-solid',
GOOGLE_AD: 'bg-success-solid',
INSTAGRAM: 'bg-error-solid',
GOOGLE_MY_BUSINESS: 'bg-warning-solid',
WEBSITE: 'bg-fg-brand-primary',
REFERRAL: 'bg-fg-tertiary',
WHATSAPP: 'bg-success-solid',
WALK_IN: 'bg-fg-quaternary',
PHONE: 'bg-fg-secondary',
OTHER: 'bg-fg-disabled',
};
const sourceLabel = (source: LeadSource): string =>
source.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const formatDuration = (startDate: string | null, endDate: string | null): string => {
if (!startDate) return '--';
const start = new Date(startDate);
const end = endDate ? new Date(endDate) : new Date();
const diffDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
return `${diffDays}d`;
};
export const CampaignCard = ({ campaign, ads, leads }: CampaignCardProps) => {
const isPaused = campaign.campaignStatus === 'PAUSED';
const leadCount = campaign.leadCount ?? 0;
const contactedCount = campaign.contactedCount ?? 0;
const convertedCount = campaign.convertedCount ?? 0;
const cac =
convertedCount > 0 && campaign.amountSpent
? formatCurrency(campaign.amountSpent.amountMicros / convertedCount, campaign.amountSpent.currencyCode)
: '--';
// Count leads per source
const sourceCounts = leads.reduce<Record<string, number>>((acc, lead) => {
const source = lead.leadSource ?? 'OTHER';
acc[source] = (acc[source] ?? 0) + 1;
return acc;
}, {});
return (
<div
className={cx(
'rounded-2xl border border-secondary bg-primary overflow-hidden transition hover:shadow-lg cursor-pointer',
isPaused && 'opacity-60',
)}
>
{/* Header */}
<div className="p-5 pb-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-md font-bold text-primary truncate">{campaign.campaignName ?? 'Untitled Campaign'}</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-tertiary">
<span>{campaign.externalCampaignId ?? campaign.id.slice(0, 12)}</span>
<span className="text-quaternary">&middot;</span>
<span>{ads.length} ad{ads.length !== 1 ? 's' : ''}</span>
{campaign.platform && (
<>
<span className="text-quaternary">&middot;</span>
<span>{campaign.platform}</span>
</>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="rounded-lg bg-brand-primary_alt px-2 py-0.5 text-xs font-medium text-brand-secondary">
{formatDuration(campaign.startDate, campaign.endDate)}
</span>
{campaign.campaignStatus && <CampaignStatusBadge status={campaign.campaignStatus} />}
</div>
</div>
</div>
{/* Metrics row */}
<div className="grid grid-cols-4 gap-2 px-5 pb-4">
<div className="rounded-xl bg-secondary p-3 text-center">
<p className="text-lg font-bold text-brand-secondary">{leadCount}</p>
<p className="text-xs text-quaternary">Leads</p>
</div>
<div className="rounded-xl bg-secondary p-3 text-center">
<p className="text-lg font-bold text-success-primary">{contactedCount}</p>
<p className="text-xs text-quaternary">Contacted</p>
</div>
<div className="rounded-xl bg-secondary p-3 text-center">
<p className="text-lg font-bold text-warning-primary">{convertedCount}</p>
<p className="text-xs text-quaternary">Converted</p>
</div>
<div className="rounded-xl bg-secondary p-3 text-center">
<p className="text-lg font-bold text-primary">{cac}</p>
<p className="text-xs text-quaternary">CAC</p>
</div>
</div>
{/* Budget bar */}
<div className="px-5 pb-4">
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
</div>
{/* Source breakdown */}
{Object.keys(sourceCounts).length > 0 && (
<div className="mx-5 border-t border-tertiary px-0 pb-4 pt-3">
<div className="flex flex-wrap items-center gap-3">
{Object.entries(sourceCounts)
.sort(([, a], [, b]) => b - a)
.map(([source, count]) => (
<div key={source} className="flex items-center gap-1.5">
<span className={cx('h-2 w-2 rounded-full', sourceColors[source] ?? 'bg-fg-disabled')} />
<span className="text-xs text-tertiary">
{sourceLabel(source as LeadSource)} ({count})
</span>
</div>
))}
</div>
</div>
)}
{/* Health indicator */}
<div className="mx-5 border-t border-tertiary px-0 pb-4 pt-3">
<HealthIndicator campaign={campaign} leads={leads} />
</div>
</div>
);
};

View File

@@ -0,0 +1,102 @@
import { useNavigate } from 'react-router';
import { ArrowLeft, LinkExternal01 } from '@untitledui/icons';
import { Button } from '@/components/base/buttons/button';
import { CampaignStatusBadge } from '@/components/shared/status-badge';
import type { Campaign } from '@/types/entities';
interface CampaignHeroProps {
campaign: Campaign;
}
const formatDateRange = (startDate: string | null, endDate: string | null): string => {
const fmt = (d: string) =>
new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(d));
if (!startDate) return '--';
if (!endDate) return `${fmt(startDate)} \u2014 Ongoing`;
return `${fmt(startDate)} \u2014 ${fmt(endDate)}`;
};
const formatDuration = (startDate: string | null, endDate: string | null): string => {
if (!startDate) return '--';
const start = new Date(startDate);
const end = endDate ? new Date(endDate) : new Date();
const diffDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
return `${diffDays} days`;
};
export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
const navigate = useNavigate();
return (
<div className="border-b border-secondary bg-primary px-7 py-6">
{/* Back button */}
<button
onClick={() => navigate('/campaigns')}
className="mb-4 flex items-center gap-1.5 text-sm text-tertiary transition hover:text-secondary cursor-pointer"
>
<ArrowLeft className="size-4" />
<span>Back to Campaigns</span>
</button>
{/* Title row */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h1 className="text-display-xs font-bold text-primary">
{campaign.campaignName ?? 'Untitled Campaign'}
</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-tertiary">
<span>{campaign.externalCampaignId ?? campaign.id.slice(0, 12)}</span>
<span className="text-quaternary">&middot;</span>
<span>{formatDateRange(campaign.startDate, campaign.endDate)}</span>
</div>
{/* Badges */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{campaign.platform && (
<span className="rounded-lg bg-secondary px-2 py-0.5 text-xs font-medium text-secondary">
{campaign.platform}
</span>
)}
{campaign.campaignType && (
<span className="rounded-lg bg-secondary px-2 py-0.5 text-xs font-medium text-secondary">
{campaign.campaignType.replace(/_/g, ' ')}
</span>
)}
{campaign.campaignStatus && <CampaignStatusBadge status={campaign.campaignStatus} />}
<span className="rounded-lg bg-brand-primary_alt px-2 py-0.5 text-xs font-medium text-brand-secondary">
{formatDuration(campaign.startDate, campaign.endDate)}
</span>
</div>
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-2">
{campaign.platformUrl && (
<Button
color="secondary"
size="sm"
iconTrailing={LinkExternal01}
href={campaign.platformUrl}
target="_blank"
rel="noopener noreferrer"
>
View on Platform
</Button>
)}
<Button
color="primary"
size="sm"
href={`/leads`}
>
View Leads
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,56 @@
import { cx } from '@/utils/cx';
import type { Campaign, Lead } from '@/types/entities';
interface ConversionFunnelProps {
campaign: Campaign;
leads: Lead[];
}
type FunnelStep = {
label: string;
count: number;
color: string;
};
export const ConversionFunnel = ({ campaign, leads }: ConversionFunnelProps) => {
const leadCount = campaign.leadCount ?? 0;
const contactedCount = campaign.contactedCount ?? 0;
const appointmentCount = leads.filter((l) => l.leadStatus === 'APPOINTMENT_SET').length;
const convertedCount = campaign.convertedCount ?? 0;
const steps: FunnelStep[] = [
{ label: 'Leads', count: leadCount, color: 'bg-brand-solid' },
{ label: 'Contacted', count: contactedCount, color: 'bg-brand-primary' },
{ label: 'Appointment Set', count: appointmentCount, color: 'bg-brand-primary_alt' },
{ label: 'Converted', count: convertedCount, color: 'bg-success-solid' },
];
const maxCount = Math.max(...steps.map((s) => s.count), 1);
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h4 className="mb-3 text-sm font-bold text-primary">Conversion Funnel</h4>
<div className="space-y-2.5">
{steps.map((step) => {
const widthPercent = (step.count / maxCount) * 100;
return (
<div key={step.label} className="flex items-center gap-3">
<span className="w-24 shrink-0 text-xs text-tertiary">{step.label}</span>
<div className="flex-1">
<div className="h-5 rounded bg-secondary overflow-hidden">
<div
className={cx('h-full rounded transition-all duration-300', step.color)}
style={{ width: `${Math.max(widthPercent, 2)}%` }}
/>
</div>
</div>
<span className="w-10 shrink-0 text-right text-xs font-bold text-primary">
{step.count}
</span>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,49 @@
import { cx } from '@/utils/cx';
import type { Campaign, Lead, HealthStatus } from '@/types/entities';
interface HealthIndicatorProps {
campaign: Campaign;
leads: Lead[];
}
const computeHealth = (campaign: Campaign, _leads: Lead[]): { status: HealthStatus; reason: string } => {
if (campaign.campaignStatus === 'PAUSED') {
return { status: 'UNHEALTHY', reason: 'Campaign is paused' };
}
const leadCount = campaign.leadCount ?? 0;
const convertedCount = campaign.convertedCount ?? 0;
const conversionRate = leadCount > 0 ? (convertedCount / leadCount) * 100 : 0;
if (conversionRate < 5) {
return { status: 'UNHEALTHY', reason: `Low conversion rate (${conversionRate.toFixed(1)}%)` };
}
if (conversionRate < 10) {
return { status: 'WARNING', reason: `Moderate conversion rate (${conversionRate.toFixed(1)}%)` };
}
return { status: 'HEALTHY', reason: `Strong conversion rate (${conversionRate.toFixed(1)}%)` };
};
const statusStyles: Record<HealthStatus, { dot: string; text: string; label: string }> = {
HEALTHY: { dot: 'bg-success-solid', text: 'text-success-primary', label: 'Healthy' },
WARNING: { dot: 'bg-warning-solid', text: 'text-warning-primary', label: 'Warning' },
UNHEALTHY: { dot: 'bg-error-solid', text: 'text-error-primary', label: 'Unhealthy' },
};
export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
const { status, reason } = computeHealth(campaign, leads);
const style = statusStyles[status];
return (
<div className="flex items-center gap-2">
<span className={cx('h-2.5 w-2.5 shrink-0 rounded-full', style.dot)} />
<p className="text-xs text-tertiary">
<span className={cx('font-bold', style.text)}>{style.label}</span>
{' \u2014 '}
{reason}
</p>
</div>
);
};

View File

@@ -0,0 +1,88 @@
import { cx } from '@/utils/cx';
import { formatCurrency } from '@/lib/format';
import type { Campaign } from '@/types/entities';
interface KpiStripProps {
campaign: Campaign;
}
type KpiItem = {
label: string;
value: string;
subText: string;
subColor: string;
};
export const KpiStrip = ({ campaign }: KpiStripProps) => {
const leadCount = campaign.leadCount ?? 0;
const contactedCount = campaign.contactedCount ?? 0;
const convertedCount = campaign.convertedCount ?? 0;
const spentMicros = campaign.amountSpent?.amountMicros ?? 0;
const budgetMicros = campaign.budget?.amountMicros ?? 0;
const currencyCode = campaign.amountSpent?.currencyCode ?? 'INR';
const contactRate = leadCount > 0 ? ((contactedCount / leadCount) * 100).toFixed(1) : '0.0';
const conversionRate = leadCount > 0 ? ((convertedCount / leadCount) * 100).toFixed(1) : '0.0';
const budgetPercent = budgetMicros > 0 ? ((spentMicros / budgetMicros) * 100).toFixed(0) : '--';
const costPerLead = leadCount > 0 ? formatCurrency(spentMicros / leadCount, currencyCode) : '--';
const cac = convertedCount > 0 ? formatCurrency(spentMicros / convertedCount, currencyCode) : '--';
const items: KpiItem[] = [
{
label: 'Total Leads',
value: String(leadCount),
subText: `${campaign.impressionCount ?? 0} impressions`,
subColor: 'text-tertiary',
},
{
label: 'Contacted',
value: String(contactedCount),
subText: `${contactRate}% contact rate`,
subColor: 'text-success-primary',
},
{
label: 'Converted',
value: String(convertedCount),
subText: `${conversionRate}% conversion`,
subColor: 'text-success-primary',
},
{
label: 'Spent',
value: formatCurrency(spentMicros, currencyCode),
subText: `${budgetPercent}% of budget`,
subColor: Number(budgetPercent) > 90 ? 'text-error-primary' : 'text-warning-primary',
},
{
label: 'Cost / Lead',
value: costPerLead,
subText: 'avg per lead',
subColor: 'text-tertiary',
},
{
label: 'CAC',
value: cac,
subText: 'per conversion',
subColor: 'text-tertiary',
},
];
return (
<div className="flex items-stretch border-b border-secondary bg-primary px-7 py-4">
{items.map((item, index) => (
<div
key={item.label}
className={cx(
'flex flex-1 flex-col justify-center px-4',
index === 0 && 'pl-0',
index === items.length - 1 && 'pr-0',
index < items.length - 1 && 'border-r border-tertiary',
)}
>
<p className="text-xl font-bold text-primary">{item.value}</p>
<p className="text-xs font-medium uppercase text-quaternary">{item.label}</p>
<p className={cx('mt-0.5 text-xs', item.subColor)}>{item.subText}</p>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,69 @@
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
interface SourceBreakdownProps {
leads: Lead[];
}
const sourceColors: Record<string, string> = {
FACEBOOK_AD: 'bg-brand-solid',
GOOGLE_AD: 'bg-success-solid',
INSTAGRAM: 'bg-error-solid',
GOOGLE_MY_BUSINESS: 'bg-warning-solid',
WEBSITE: 'bg-fg-brand-primary',
REFERRAL: 'bg-fg-tertiary',
WHATSAPP: 'bg-success-solid',
WALK_IN: 'bg-fg-quaternary',
PHONE: 'bg-fg-secondary',
OTHER: 'bg-fg-disabled',
};
const sourceLabel = (source: string): string =>
source.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
export const SourceBreakdown = ({ leads }: SourceBreakdownProps) => {
const sourceCounts = leads.reduce<Record<string, number>>((acc, lead) => {
const source = lead.leadSource ?? 'OTHER';
acc[source] = (acc[source] ?? 0) + 1;
return acc;
}, {});
const sorted = Object.entries(sourceCounts).sort(([, a], [, b]) => b - a);
const maxCount = sorted.length > 0 ? sorted[0][1] : 1;
if (sorted.length === 0) {
return null;
}
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h4 className="mb-3 text-sm font-bold text-primary">Lead Sources</h4>
<div className="space-y-2">
{sorted.map(([source, count]) => {
const widthPercent = (count / maxCount) * 100;
return (
<div key={source} className="flex items-center gap-3">
<span className="w-28 shrink-0 truncate text-xs text-tertiary">
{sourceLabel(source)}
</span>
<div className="flex-1">
<div className="h-4 rounded bg-secondary overflow-hidden">
<div
className={cx(
'h-full rounded transition-all duration-300',
sourceColors[source] ?? 'bg-fg-disabled',
)}
style={{ width: `${Math.max(widthPercent, 4)}%` }}
/>
</div>
</div>
<span className="w-8 shrink-0 text-right text-xs font-bold text-primary">
{count}
</span>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { BadgeWithDot } from '@/components/base/badges/badges';
import type { CampaignStatus, LeadStatus } from '@/types/entities';
import type { AdStatus, CampaignStatus, LeadStatus } from '@/types/entities';
const toTitleCase = (str: string): string =>
str
@@ -52,3 +52,25 @@ export const CampaignStatusBadge = ({ status }: CampaignStatusBadgeProps) => {
</BadgeWithDot>
);
};
type AdStatusColor = 'gray' | 'success' | 'warning' | 'blue';
const adStatusColorMap: Record<AdStatus, AdStatusColor> = {
DRAFT: 'gray',
ACTIVE: 'success',
PAUSED: 'warning',
ENDED: 'blue',
};
interface AdStatusBadgeProps {
status: AdStatus;
}
export const AdStatusBadge = ({ status }: AdStatusBadgeProps) => {
const color = adStatusColorMap[status];
return (
<BadgeWithDot size="sm" type="pill-color" color={color}>
{toTitleCase(status)}
</BadgeWithDot>
);
};