mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
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:
139
src/components/campaigns/campaign-card.tsx
Normal file
139
src/components/campaigns/campaign-card.tsx
Normal 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">·</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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user