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>
381 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|