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>
);
};

View File

@@ -1,11 +1,172 @@
import { TopBar } from "@/components/layout/top-bar";
import { useMemo, useState } from 'react';
import { useParams } from 'react-router';
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { CampaignHero } from '@/components/campaigns/campaign-hero';
import { KpiStrip } from '@/components/campaigns/kpi-strip';
import { AdCard } from '@/components/campaigns/ad-card';
import { ConversionFunnel } from '@/components/campaigns/conversion-funnel';
import { SourceBreakdown } from '@/components/campaigns/source-breakdown';
import { BudgetBar } from '@/components/campaigns/budget-bar';
import { HealthIndicator } from '@/components/campaigns/health-indicator';
import { Button } from '@/components/base/buttons/button';
import { useCampaigns } from '@/hooks/use-campaigns';
import { useLeads } from '@/hooks/use-leads';
import { formatCurrency } from '@/lib/format';
const detailTabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'leads', label: 'Leads' },
];
export const CampaignDetailPage = () => {
return (
<div className="flex flex-1 flex-col">
<TopBar title="Campaign Detail" />
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<string>('overview');
const { campaigns, ads } = useCampaigns();
const { leads } = useLeads();
const campaign = campaigns.find((c) => c.id === id);
const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]);
const campaignLeads = useMemo(() => leads.filter((lead) => lead.campaignId === id), [leads, id]);
if (!campaign) {
return (
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-tertiary">Campaign Detail coming soon</p>
<p className="text-tertiary">Campaign not found.</p>
</div>
);
}
const formatDateShort = (dateStr: string | null) => {
if (!dateStr) return '--';
return new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', year: 'numeric' }).format(
new Date(dateStr),
);
};
return (
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Hero header */}
<CampaignHero campaign={campaign} />
{/* KPI strip */}
<KpiStrip campaign={campaign} />
{/* Tabs */}
<div className="px-7 pt-5">
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
<TabList
type="underline"
size="sm"
items={detailTabs}
>
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
</TabList>
<TabPanel id="overview">
<div className="mt-5 grid grid-cols-1 gap-5 pb-7 xl:grid-cols-[1fr_340px]">
{/* Left: Ads list */}
<div className="space-y-3">
<h3 className="text-md font-bold text-primary">
Ads ({campaignAds.length})
</h3>
{campaignAds.map((ad) => (
<AdCard key={ad.id} ad={ad} />
))}
{campaignAds.length === 0 && (
<p className="py-8 text-center text-sm text-tertiary">
No ads for this campaign.
</p>
)}
</div>
{/* Right: Details + Funnel + Source */}
<div className="space-y-4">
{/* Campaign Details card */}
<div className="rounded-xl border border-secondary bg-primary p-4">
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
<dl className="space-y-2 text-xs">
<div className="flex justify-between">
<dt className="text-quaternary">Type</dt>
<dd className="font-medium text-secondary">
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Platform</dt>
<dd className="font-medium text-secondary">
{campaign.platform ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Start Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.startDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">End Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.endDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Budget</dt>
<dd className="font-medium text-secondary">
{campaign.budget
? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode)
: '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Impressions</dt>
<dd className="font-medium text-secondary">
{campaign.impressionCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Clicks</dt>
<dd className="font-medium text-secondary">
{campaign.clickCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
</dl>
<div className="mt-4 space-y-3">
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
<HealthIndicator campaign={campaign} leads={campaignLeads} />
</div>
</div>
{/* Conversion Funnel */}
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
{/* Source Breakdown */}
<SourceBreakdown leads={campaignLeads} />
</div>
</div>
</TabPanel>
<TabPanel id="leads">
<div className="mt-5 pb-7">
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary p-12 text-center">
<p className="text-md font-bold text-primary">
{campaignLeads.length} lead{campaignLeads.length !== 1 ? 's' : ''} from this campaign
</p>
<p className="mt-1 text-sm text-tertiary">
View the full leads table filtered by this campaign on the All Leads page.
</p>
<div className="mt-4">
<Button color="primary" size="sm" href="/leads">
Go to All Leads
</Button>
</div>
</div>
</div>
</TabPanel>
</Tabs>
</div>
</div>
);

View File

@@ -1,11 +1,122 @@
import { TopBar } from "@/components/layout/top-bar";
import { useMemo, useState } from 'react';
import { Link } from 'react-router';
import { TopBar } from '@/components/layout/top-bar';
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { CampaignCard } from '@/components/campaigns/campaign-card';
import { useCampaigns } from '@/hooks/use-campaigns';
import { useLeads } from '@/hooks/use-leads';
import { formatCurrency } from '@/lib/format';
import type { CampaignStatus } from '@/types/entities';
type TabConfig = {
id: string;
label: string;
status: CampaignStatus | undefined;
};
const tabs: TabConfig[] = [
{ id: 'all', label: 'All', status: undefined },
{ id: 'active', label: 'Active', status: 'ACTIVE' },
{ id: 'paused', label: 'Paused', status: 'PAUSED' },
{ id: 'completed', label: 'Completed', status: 'COMPLETED' },
{ id: 'draft', label: 'Drafts', status: 'DRAFT' },
];
export const CampaignsPage = () => {
const [activeTab, setActiveTab] = useState<string>('all');
const selectedTab = tabs.find((t) => t.id === activeTab) ?? tabs[0];
const { campaigns, ads } = useCampaigns({ status: selectedTab.status });
const { campaigns: allCampaigns } = useCampaigns();
const { leads } = useLeads();
const activeCount = allCampaigns.filter((c) => c.campaignStatus === 'ACTIVE').length;
const totalSpent = allCampaigns.reduce((sum, c) => sum + (c.amountSpent?.amountMicros ?? 0), 0);
const subtitle = `${allCampaigns.length} campaigns \u00b7 ${activeCount} active \u00b7 ${formatCurrency(totalSpent)} total spend`;
// Index leads by campaignId for fast per-campaign lookups
const leadsByCampaign = useMemo(() => {
const map = new Map<string, typeof leads>();
for (const lead of leads) {
if (lead.campaignId) {
const existing = map.get(lead.campaignId);
if (existing) {
existing.push(lead);
} else {
map.set(lead.campaignId, [lead]);
}
}
}
return map;
}, [leads]);
// Index ads by campaignId
const adsByCampaign = useMemo(() => {
const map = new Map<string, typeof ads>();
for (const ad of ads) {
if (ad.campaignId) {
const existing = map.get(ad.campaignId);
if (existing) {
existing.push(ad);
} else {
map.set(ad.campaignId, [ad]);
}
}
}
return map;
}, [ads]);
// Tab badges
const tabBadges: Record<string, number> = useMemo(() => ({
all: allCampaigns.length,
active: allCampaigns.filter((c) => c.campaignStatus === 'ACTIVE').length,
paused: allCampaigns.filter((c) => c.campaignStatus === 'PAUSED').length,
completed: allCampaigns.filter((c) => c.campaignStatus === 'COMPLETED').length,
draft: allCampaigns.filter((c) => c.campaignStatus === 'DRAFT').length,
}), [allCampaigns]);
return (
<div className="flex flex-1 flex-col">
<TopBar title="Campaigns" />
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-tertiary">Campaigns coming soon</p>
<TopBar title="Campaigns" subtitle={subtitle} />
<div className="flex-1 overflow-y-auto p-7 space-y-5">
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
<TabList
type="underline"
size="sm"
items={tabs.map((tab) => ({
id: tab.id,
label: tab.label,
badge: tabBadges[tab.id] > 0 ? tabBadges[tab.id] : undefined,
}))}
>
{(item) => (
<Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />
)}
</TabList>
{tabs.map((tab) => (
<TabPanel key={tab.id} id={tab.id}>
<div className="mt-5 grid grid-cols-1 gap-4 xl:grid-cols-2">
{campaigns.map((campaign) => (
<Link key={campaign.id} to={`/campaigns/${campaign.id}`} className="no-underline">
<CampaignCard
campaign={campaign}
ads={adsByCampaign.get(campaign.id) ?? []}
leads={leadsByCampaign.get(campaign.id) ?? []}
/>
</Link>
))}
{campaigns.length === 0 && (
<p className="col-span-full py-12 text-center text-sm text-tertiary">
No campaigns match this filter.
</p>
)}
</div>
</TabPanel>
))}
</Tabs>
</div>
</div>
);