diff --git a/src/components/campaigns/ad-card.tsx b/src/components/campaigns/ad-card.tsx new file mode 100644 index 0000000..5cf841d --- /dev/null +++ b/src/components/campaigns/ad-card.tsx @@ -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 = { + 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 ( +
+ {/* Preview thumbnail */} +
+ {preview.icon} +
+ + {/* Ad info */} +
+
+

+ {ad.adName ?? 'Untitled Ad'} +

+ + {formatBadgeLabel(format)} + +
+

{ad.externalAdId ?? ad.id.slice(0, 12)}

+ {ad.headline && ( +

{ad.headline}

+ )} +
+ + {/* Inline metrics */} +
+ {metrics.map((metric) => ( +
+

{metric.value}

+

{metric.label}

+
+ ))} +
+ + {/* Status badge */} + {ad.adStatus && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/components/campaigns/budget-bar.tsx b/src/components/campaigns/budget-bar.tsx new file mode 100644 index 0000000..eea3ff2 --- /dev/null +++ b/src/components/campaigns/budget-bar.tsx @@ -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 ( +
+
+ Budget + + {spentDisplay} / {budgetDisplay} + +
+
+
+
+
+ ); +}; diff --git a/src/components/campaigns/campaign-card.tsx b/src/components/campaigns/campaign-card.tsx new file mode 100644 index 0000000..b5f79e1 --- /dev/null +++ b/src/components/campaigns/campaign-card.tsx @@ -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 = { + 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>((acc, lead) => { + const source = lead.leadSource ?? 'OTHER'; + acc[source] = (acc[source] ?? 0) + 1; + return acc; + }, {}); + + return ( +
+ {/* Header */} +
+
+
+

{campaign.campaignName ?? 'Untitled Campaign'}

+
+ {campaign.externalCampaignId ?? campaign.id.slice(0, 12)} + · + {ads.length} ad{ads.length !== 1 ? 's' : ''} + {campaign.platform && ( + <> + · + {campaign.platform} + + )} +
+
+
+ + {formatDuration(campaign.startDate, campaign.endDate)} + + {campaign.campaignStatus && } +
+
+
+ + {/* Metrics row */} +
+
+

{leadCount}

+

Leads

+
+
+

{contactedCount}

+

Contacted

+
+
+

{convertedCount}

+

Converted

+
+
+

{cac}

+

CAC

+
+
+ + {/* Budget bar */} +
+ +
+ + {/* Source breakdown */} + {Object.keys(sourceCounts).length > 0 && ( +
+
+ {Object.entries(sourceCounts) + .sort(([, a], [, b]) => b - a) + .map(([source, count]) => ( +
+ + + {sourceLabel(source as LeadSource)} ({count}) + +
+ ))} +
+
+ )} + + {/* Health indicator */} +
+ +
+
+ ); +}; diff --git a/src/components/campaigns/campaign-hero.tsx b/src/components/campaigns/campaign-hero.tsx new file mode 100644 index 0000000..cfbcea9 --- /dev/null +++ b/src/components/campaigns/campaign-hero.tsx @@ -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 ( +
+ {/* Back button */} + + + {/* Title row */} +
+
+

+ {campaign.campaignName ?? 'Untitled Campaign'} +

+ +
+ {campaign.externalCampaignId ?? campaign.id.slice(0, 12)} + · + {formatDateRange(campaign.startDate, campaign.endDate)} +
+ + {/* Badges */} +
+ {campaign.platform && ( + + {campaign.platform} + + )} + {campaign.campaignType && ( + + {campaign.campaignType.replace(/_/g, ' ')} + + )} + {campaign.campaignStatus && } + + {formatDuration(campaign.startDate, campaign.endDate)} + +
+
+ + {/* Actions */} +
+ {campaign.platformUrl && ( + + )} + +
+
+
+ ); +}; diff --git a/src/components/campaigns/conversion-funnel.tsx b/src/components/campaigns/conversion-funnel.tsx new file mode 100644 index 0000000..08e87e6 --- /dev/null +++ b/src/components/campaigns/conversion-funnel.tsx @@ -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 ( +
+

Conversion Funnel

+
+ {steps.map((step) => { + const widthPercent = (step.count / maxCount) * 100; + return ( +
+ {step.label} +
+
+
+
+
+ + {step.count} + +
+ ); + })} +
+
+ ); +}; diff --git a/src/components/campaigns/health-indicator.tsx b/src/components/campaigns/health-indicator.tsx new file mode 100644 index 0000000..c33b060 --- /dev/null +++ b/src/components/campaigns/health-indicator.tsx @@ -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 = { + 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 ( +
+ +

+ {style.label} + {' \u2014 '} + {reason} +

+
+ ); +}; diff --git a/src/components/campaigns/kpi-strip.tsx b/src/components/campaigns/kpi-strip.tsx new file mode 100644 index 0000000..02b06fe --- /dev/null +++ b/src/components/campaigns/kpi-strip.tsx @@ -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 ( +
+ {items.map((item, index) => ( +
+

{item.value}

+

{item.label}

+

{item.subText}

+
+ ))} +
+ ); +}; diff --git a/src/components/campaigns/source-breakdown.tsx b/src/components/campaigns/source-breakdown.tsx new file mode 100644 index 0000000..66477a2 --- /dev/null +++ b/src/components/campaigns/source-breakdown.tsx @@ -0,0 +1,69 @@ +import { cx } from '@/utils/cx'; +import type { Lead } from '@/types/entities'; + +interface SourceBreakdownProps { + leads: Lead[]; +} + +const sourceColors: Record = { + 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>((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 ( +
+

Lead Sources

+
+ {sorted.map(([source, count]) => { + const widthPercent = (count / maxCount) * 100; + return ( +
+ + {sourceLabel(source)} + +
+
+
+
+
+ + {count} + +
+ ); + })} +
+
+ ); +}; diff --git a/src/components/shared/status-badge.tsx b/src/components/shared/status-badge.tsx index 03172cb..dccdd3f 100644 --- a/src/components/shared/status-badge.tsx +++ b/src/components/shared/status-badge.tsx @@ -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) => { ); }; + +type AdStatusColor = 'gray' | 'success' | 'warning' | 'blue'; + +const adStatusColorMap: Record = { + DRAFT: 'gray', + ACTIVE: 'success', + PAUSED: 'warning', + ENDED: 'blue', +}; + +interface AdStatusBadgeProps { + status: AdStatus; +} + +export const AdStatusBadge = ({ status }: AdStatusBadgeProps) => { + const color = adStatusColorMap[status]; + return ( + + {toTitleCase(status)} + + ); +}; diff --git a/src/pages/campaign-detail.tsx b/src/pages/campaign-detail.tsx index f27804b..5808f49 100644 --- a/src/pages/campaign-detail.tsx +++ b/src/pages/campaign-detail.tsx @@ -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 ( -
- + const { id } = useParams<{ id: string }>(); + const [activeTab, setActiveTab] = useState('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 (
-

Campaign Detail — coming soon

+

Campaign not found.

+
+ ); + } + + 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 ( +
+ {/* Hero header */} + + + {/* KPI strip */} + + + {/* Tabs */} +
+ setActiveTab(String(key))}> + + {(item) => } + + + +
+ {/* Left: Ads list */} +
+

+ Ads ({campaignAds.length}) +

+ {campaignAds.map((ad) => ( + + ))} + {campaignAds.length === 0 && ( +

+ No ads for this campaign. +

+ )} +
+ + {/* Right: Details + Funnel + Source */} +
+ {/* Campaign Details card */} +
+

Campaign Details

+
+
+
Type
+
+ {campaign.campaignType?.replace(/_/g, ' ') ?? '--'} +
+
+
+
Platform
+
+ {campaign.platform ?? '--'} +
+
+
+
Start Date
+
+ {formatDateShort(campaign.startDate)} +
+
+
+
End Date
+
+ {formatDateShort(campaign.endDate)} +
+
+
+
Budget
+
+ {campaign.budget + ? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode) + : '--'} +
+
+
+
Impressions
+
+ {campaign.impressionCount?.toLocaleString('en-IN') ?? '--'} +
+
+
+
Clicks
+
+ {campaign.clickCount?.toLocaleString('en-IN') ?? '--'} +
+
+
+ +
+ + +
+
+ + {/* Conversion Funnel */} + + + {/* Source Breakdown */} + +
+
+
+ + +
+
+

+ {campaignLeads.length} lead{campaignLeads.length !== 1 ? 's' : ''} from this campaign +

+

+ View the full leads table filtered by this campaign on the All Leads page. +

+
+ +
+
+
+
+
); diff --git a/src/pages/campaigns.tsx b/src/pages/campaigns.tsx index 25c713f..e6dded7 100644 --- a/src/pages/campaigns.tsx +++ b/src/pages/campaigns.tsx @@ -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('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(); + 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(); + 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 = 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 (
- -
-

Campaigns — coming soon

+ +
+ setActiveTab(String(key))}> + ({ + id: tab.id, + label: tab.label, + badge: tabBadges[tab.id] > 0 ? tabBadges[tab.id] : undefined, + }))} + > + {(item) => ( + + )} + + + {tabs.map((tab) => ( + +
+ {campaigns.map((campaign) => ( + + + + ))} + {campaigns.length === 0 && ( +

+ No campaigns match this filter. +

+ )} +
+
+ ))} +
);