mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
140 lines
6.2 KiB
TypeScript
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">·</span>
|
|
<span>{ads.length} ad{ads.length !== 1 ? 's' : ''}</span>
|
|
{campaign.platform && (
|
|
<>
|
|
<span className="text-quaternary">·</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>
|
|
);
|
|
};
|