mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38: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:
79
src/components/campaigns/ad-card.tsx
Normal file
79
src/components/campaigns/ad-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
src/components/campaigns/budget-bar.tsx
Normal file
47
src/components/campaigns/budget-bar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
102
src/components/campaigns/campaign-hero.tsx
Normal file
102
src/components/campaigns/campaign-hero.tsx
Normal 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">·</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>
|
||||
);
|
||||
};
|
||||
56
src/components/campaigns/conversion-funnel.tsx
Normal file
56
src/components/campaigns/conversion-funnel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
49
src/components/campaigns/health-indicator.tsx
Normal file
49
src/components/campaigns/health-indicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
88
src/components/campaigns/kpi-strip.tsx
Normal file
88
src/components/campaigns/kpi-strip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
69
src/components/campaigns/source-breakdown.tsx
Normal file
69
src/components/campaigns/source-breakdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user