This repository has been archived on 2026-08-11. You can view files and clone it, but cannot push or open issues or pull requests.
Gishen-Admin/src/pages/marketing/CampaignDetailPage.tsx
kirukib fce0149b1a Ship full Admin console polish and update backend spec.
Add medication Items catalogue, counter POS/QR purchase, profile/login,
detail pages, loyalty ledger, Cmd+K nav, KPI strips/charts, avatars, and
i18n; document the new APIs and open questions for the shared backend team.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:03:21 +03:00

381 lines
14 KiB
TypeScript

import { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import {
Area,
AreaChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Copy, Download, MousePointerClick, PauseCircle, Send, Sparkles, Users } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { ChartCard } from '@/components/charts/ChartCard'
import { StatCard } from '@/components/dashboard/StatCard'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { campaignRecipients, campaigns } from '@/mocks/data'
import { campaignChannelBreakdown, campaignDailySends } from '@/mocks/charts'
import { formatEtb } from '@/lib/utils'
import type { Campaign, CampaignRecipient } from '@/mocks/data'
import type { TimelineEvent } from '@/components/detail/ActivityTimeline'
const statusTone: Record<Campaign['status'], 'success' | 'default' | 'warning' | 'muted'> = {
sent: 'success',
sending: 'default',
scheduled: 'warning',
draft: 'muted',
}
const recipientTone: Record<CampaignRecipient['status'], 'success' | 'default' | 'warning' | 'danger'> = {
redeemed: 'success',
clicked: 'default',
opened: 'default',
delivered: 'warning',
bounced: 'danger',
}
export function CampaignDetailPage() {
const { id } = useParams()
const campaign = campaigns.find((c) => c.id === id)
const recipients = useMemo(() => (id ? (campaignRecipients[id] ?? []) : []), [id])
const breakdown = useMemo(() => {
if (!campaign) return []
const preset = id ? campaignChannelBreakdown[id] : undefined
if (preset) return preset
return [
{ name: 'Delivered', value: campaign.delivered, fill: '#A8C73A' },
{ name: 'Bounced', value: campaign.bounced, fill: '#E28A1A' },
].filter((slice) => slice.value > 0)
}, [campaign, id])
const daily = useMemo(() => (id ? (campaignDailySends[id] ?? []) : []), [id])
const funnel = useMemo(() => {
if (!campaign) return null
const pct = (value: number, base: number) => (base > 0 ? Math.round((value / base) * 100) : 0)
return {
deliveryRate: pct(campaign.delivered, campaign.recipients),
openRate: pct(campaign.opened, campaign.delivered),
clickRate: pct(campaign.clicked, campaign.delivered),
redeemRate: pct(campaign.redeemed, campaign.delivered),
}
}, [campaign])
const timeline = useMemo<TimelineEvent[]>(() => {
if (!campaign) return []
const events: TimelineEvent[] = [
{
id: 'created',
at: campaign.createdAt,
title: 'Campaign created',
detail: `${campaign.createdBy} drafted this ${campaign.channel} campaign`,
},
]
if (campaign.scheduledFor) {
events.push({
id: 'scheduled',
at: campaign.scheduledFor,
title: 'Scheduled to send',
detail: `${campaign.recipients.toLocaleString()} recipients queued`,
tone: 'warning',
})
}
if (campaign.sentAt) {
events.push({
id: 'sent',
at: campaign.sentAt,
title: campaign.status === 'sending' ? 'Sending started' : 'Campaign sent',
detail: `${campaign.delivered.toLocaleString()} delivered · ${campaign.bounced.toLocaleString()} bounced`,
tone: campaign.bounced > 0 ? 'warning' : 'success',
})
if (campaign.opened > 0) {
events.push({
id: 'opened',
at: new Date(new Date(campaign.sentAt).getTime() + 45 * 60 * 1000).toISOString(),
title: 'First opens recorded',
detail: `${campaign.opened.toLocaleString()} opens · ${campaign.clicked.toLocaleString()} clicks`,
})
}
if (campaign.redeemed > 0) {
events.push({
id: 'redeemed',
at: new Date(new Date(campaign.sentAt).getTime() + 6 * 60 * 60 * 1000).toISOString(),
title: 'Redemptions attributed',
detail: `${campaign.redeemed.toLocaleString()} orders traced back to this campaign`,
tone: 'success',
})
}
}
return events.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
}, [campaign])
if (!campaign || !funnel) {
return (
<DetailPageLayout backTo="/campaigns" backLabel="Back to campaigns" title="Campaign not found">
<Button asChild>
<Link to="/campaigns">Back to campaigns</Link>
</Button>
</DetailPageLayout>
)
}
const canSend = campaign.status === 'draft' || campaign.status === 'scheduled'
return (
<DetailPageLayout
backTo="/campaigns"
backLabel="Back to campaigns"
title={campaign.name}
subtitle={`${campaign.channel} · ${campaign.segmentName} · created by ${campaign.createdBy}`}
badges={<Badge variant={statusTone[campaign.status]}>{campaign.status}</Badge>}
actions={
<>
{canSend ? (
<Button
size="sm"
className="h-9 gap-1.5"
onClick={() => toast.success(`“${campaign.name}” queued to send now`)}
>
<Send className="h-3.5 w-3.5" />
Send now
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
disabled={campaign.status === 'sent'}
onClick={() => toast.success(`“${campaign.name}” paused`)}
>
<PauseCircle className="h-3.5 w-3.5" />
Pause
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success(`Duplicated “${campaign.name}” as a draft`)}
>
<Copy className="h-3.5 w-3.5" />
Duplicate
</Button>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success(`Exporting report for “${campaign.name}”`)}
>
<Download className="h-3.5 w-3.5" />
Export report
</Button>
</>
}
sidebar={
<DetailSection title="Lifecycle" description="Derived campaign activity">
<ActivityTimeline events={timeline} empty="Nothing has happened yet" />
</DetailSection>
}
>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<StatCard
label="Recipients"
value={campaign.recipients.toLocaleString()}
hint="Segment size at send"
icon={Users}
tone="brand"
/>
<StatCard
label="Delivered"
value={campaign.delivered.toLocaleString()}
hint={`${funnel.deliveryRate}% of recipients`}
icon={Send}
tone="forest"
/>
<StatCard
label="Opened"
value={campaign.opened.toLocaleString()}
hint={`${funnel.openRate}% of delivered`}
icon={Sparkles}
tone="orange"
/>
<StatCard
label="Clicked"
value={campaign.clicked.toLocaleString()}
hint={`${funnel.clickRate}% of delivered`}
icon={MousePointerClick}
tone="muted"
/>
<StatCard
label="Redeemed"
value={campaign.redeemed.toLocaleString()}
hint={`${funnel.redeemRate}% of delivered`}
icon={Sparkles}
tone="brand"
/>
</div>
<DetailSection title="Campaign configuration" description="What was sent, to whom, and when">
<KeyValueGrid
columns={3}
items={[
{ label: 'Channel', value: <Badge variant="outline">{campaign.channel}</Badge> },
{
label: 'Segment',
value: (
<Link to={`/marketing/${campaign.segmentId}`} className="text-brand-700 hover:underline">
{campaign.segmentName}
</Link>
),
},
{ label: 'Status', value: <Badge variant={statusTone[campaign.status]}>{campaign.status}</Badge> },
{ label: 'Recipients', value: campaign.recipients.toLocaleString() },
{ label: 'Budget', value: formatEtb(campaign.budgetEtb) },
{ label: 'Created by', value: campaign.createdBy },
{ label: 'Created at', value: new Date(campaign.createdAt).toLocaleString() },
{
label: campaign.sentAt ? 'Sent at' : 'Scheduled for',
value: campaign.sentAt
? new Date(campaign.sentAt).toLocaleString()
: campaign.scheduledFor
? new Date(campaign.scheduledFor).toLocaleString()
: '—',
},
{ label: 'Bounced', value: campaign.bounced.toLocaleString() },
]}
/>
<div className="mt-5 rounded-xl border bg-muted/30 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Message</p>
<p className="mt-1 text-sm text-foreground">{campaign.message}</p>
</div>
</DetailSection>
<div className={daily.length > 0 ? 'grid gap-4 xl:grid-cols-2' : 'grid gap-4'}>
<ChartCard title="Delivery breakdown" description="Delivered versus bounced">
{breakdown.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No delivery data yet
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={breakdown}
dataKey="value"
nameKey="name"
innerRadius={45}
outerRadius={75}
paddingAngle={3}
>
{breakdown.map((slice) => (
<Cell key={slice.name} fill={slice.fill} />
))}
</Pie>
<Tooltip formatter={(v) => Number(v).toLocaleString()} />
<Legend verticalAlign="bottom" height={24} iconType="circle" />
</PieChart>
</ResponsiveContainer>
)}
</ChartCard>
{daily.length > 0 ? (
<ChartCard title="Daily sends" description="Delivered, opened and clicked per day">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={daily} margin={{ left: 4, right: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(v) => Number(v).toLocaleString()} />
<Legend verticalAlign="top" height={24} iconType="circle" />
<Area
type="monotone"
dataKey="delivered"
name="Delivered"
stroke="#A8C73A"
fill="#A8C73A"
fillOpacity={0.2}
/>
<Area
type="monotone"
dataKey="opened"
name="Opened"
stroke="#E28A1A"
fill="#E28A1A"
fillOpacity={0.2}
/>
<Area
type="monotone"
dataKey="clicked"
name="Clicked"
stroke="#141414"
fill="#141414"
fillOpacity={0.1}
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
) : null}
</div>
<DetailSection
title="Recipients"
description={`${recipients.length} sampled from ${campaign.recipients.toLocaleString()} sends`}
>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Phone</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden md:table-cell">Sent at</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recipients.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
No recipient sample available for this campaign
</TableCell>
</TableRow>
) : (
recipients.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
{r.customerId ? (
<Link to={`/crm/${r.customerId}`} className="text-brand-700 hover:underline">
{r.name}
</Link>
) : (
r.name
)}
</TableCell>
<TableCell className="hidden whitespace-nowrap sm:table-cell">{r.phone}</TableCell>
<TableCell>
<Badge variant={recipientTone[r.status]}>{r.status}</Badge>
</TableCell>
<TableCell className="hidden whitespace-nowrap md:table-cell">
{new Date(r.sentAt).toLocaleString()}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
</div>
</DetailPageLayout>
)
}