Files
helix-engage/src/components/campaigns/campaign-card.tsx
2026-03-31 15:02:11 +05:30

140 lines
6.2 KiB
TypeScript

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(
'flex flex-col rounded-2xl border border-secondary bg-primary overflow-hidden transition hover:shadow-lg cursor-pointer h-full',
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>
);
};