|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { useEffect, useState } from 'react' |
| 4 | +import { CheckCircle, Loader2, XCircle, Clock, Radio } from 'lucide-react' |
| 5 | + |
| 6 | +interface Campaign { |
| 7 | + id: string |
| 8 | + advertiser_name: string |
| 9 | + advertiser_email: string |
| 10 | + advertiser_url: string | null |
| 11 | + contact_handle: string | null |
| 12 | + title: string |
| 13 | + description: string | null |
| 14 | + category: string |
| 15 | + slot_type: string |
| 16 | + scheduled_slots: number |
| 17 | + broadcasts_done: number |
| 18 | + starts_at: string | null |
| 19 | + ends_at: string | null |
| 20 | + amount_pence: number | null |
| 21 | + status: string |
| 22 | + admin_notes: string | null |
| 23 | + playback_id: string | null |
| 24 | + mux_upload_id: string | null |
| 25 | + created_at: string |
| 26 | +} |
| 27 | + |
| 28 | +const STATUS_COLORS: Record<string, string> = { |
| 29 | + pending_payment: 'text-zinc-500', |
| 30 | + paid: 'text-amber-400', |
| 31 | + approved: 'text-blue-400', |
| 32 | + live: 'text-green-400', |
| 33 | + complete: 'text-zinc-500', |
| 34 | + rejected: 'text-red-400', |
| 35 | + refunded: 'text-zinc-500', |
| 36 | +} |
| 37 | + |
| 38 | +function fmt(pence: number | null) { |
| 39 | + if (!pence) return '—' |
| 40 | + return `£${(pence / 100).toFixed(2)}` |
| 41 | +} |
| 42 | + |
| 43 | +function fmtDate(iso: string | null) { |
| 44 | + if (!iso) return '—' |
| 45 | + return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) |
| 46 | +} |
| 47 | + |
| 48 | +function CampaignRow({ c, onAction }: { c: Campaign; onAction: () => void }) { |
| 49 | + const [loading, setLoading] = useState(false) |
| 50 | + const [scheduleDate, setScheduleDate] = useState('') |
| 51 | + const [notes, setNotes] = useState(c.admin_notes || '') |
| 52 | + const [expanded, setExpanded] = useState(false) |
| 53 | + |
| 54 | + async function act(action: string, extra?: Record<string, unknown>) { |
| 55 | + setLoading(true) |
| 56 | + await fetch(`/api/ads/campaigns/${c.id}`, { |
| 57 | + method: 'PATCH', |
| 58 | + headers: { 'Content-Type': 'application/json' }, |
| 59 | + body: JSON.stringify({ action, notes, ...extra }), |
| 60 | + }) |
| 61 | + onAction() |
| 62 | + setLoading(false) |
| 63 | + } |
| 64 | + |
| 65 | + return ( |
| 66 | + <div className="rounded-[20px] border border-zinc-800 bg-zinc-950/80"> |
| 67 | + <button |
| 68 | + className="w-full px-5 py-4 text-left" |
| 69 | + onClick={() => setExpanded((v) => !v)} |
| 70 | + > |
| 71 | + <div className="flex items-start justify-between gap-3"> |
| 72 | + <div className="min-w-0"> |
| 73 | + <p className="text-sm font-bold text-white truncate">{c.title}</p> |
| 74 | + <p className="text-xs text-zinc-500 mt-0.5">{c.advertiser_name} · {c.advertiser_email}</p> |
| 75 | + </div> |
| 76 | + <div className="flex shrink-0 items-center gap-3"> |
| 77 | + <span className={`text-[10px] font-bold uppercase tracking-[0.14em] ${STATUS_COLORS[c.status] || 'text-zinc-400'}`}> |
| 78 | + {c.status.replace('_', ' ')} |
| 79 | + </span> |
| 80 | + <span className="text-xs text-zinc-600">{fmt(c.amount_pence)}</span> |
| 81 | + </div> |
| 82 | + </div> |
| 83 | + <div className="mt-2 flex flex-wrap gap-3 text-[10px] text-zinc-600"> |
| 84 | + <span className="uppercase tracking-[0.14em]">{c.slot_type}</span> |
| 85 | + <span>{c.scheduled_slots} slots · {c.broadcasts_done} done</span> |
| 86 | + <span className="capitalize">{c.category}</span> |
| 87 | + <span>{fmtDate(c.created_at)}</span> |
| 88 | + </div> |
| 89 | + </button> |
| 90 | + |
| 91 | + {expanded && ( |
| 92 | + <div className="border-t border-zinc-800 px-5 pb-5 pt-4 space-y-4"> |
| 93 | + {c.description && ( |
| 94 | + <p className="text-xs text-zinc-400">{c.description}</p> |
| 95 | + )} |
| 96 | + |
| 97 | + <div className="grid gap-3 sm:grid-cols-2 text-xs text-zinc-500"> |
| 98 | + {c.advertiser_url && <span>URL: <a href={c.advertiser_url} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline">{c.advertiser_url}</a></span>} |
| 99 | + {c.contact_handle && <span>Handle: {c.contact_handle}</span>} |
| 100 | + {c.playback_id && <span>Playback: <a href={`https://stream.mux.com/${c.playback_id}.m3u8`} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline">Play</a></span>} |
| 101 | + {c.mux_upload_id && <span className="font-mono">Upload: {c.mux_upload_id}</span>} |
| 102 | + <span>Starts: {fmtDate(c.starts_at)}</span> |
| 103 | + <span>Ends: {fmtDate(c.ends_at)}</span> |
| 104 | + </div> |
| 105 | + |
| 106 | + <div> |
| 107 | + <label className="block text-[10px] uppercase tracking-[0.16em] text-zinc-500 mb-1">Admin Notes</label> |
| 108 | + <textarea |
| 109 | + value={notes} |
| 110 | + onChange={(e) => setNotes(e.target.value)} |
| 111 | + rows={2} |
| 112 | + className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-3 py-2 text-xs text-white resize-none focus:border-zinc-600 focus:outline-none" |
| 113 | + /> |
| 114 | + </div> |
| 115 | + |
| 116 | + {c.status === 'paid' && ( |
| 117 | + <div className="flex flex-wrap gap-3"> |
| 118 | + <div> |
| 119 | + <label className="block text-[10px] uppercase tracking-[0.16em] text-zinc-500 mb-1">Broadcast Start Date</label> |
| 120 | + <input |
| 121 | + type="datetime-local" |
| 122 | + value={scheduleDate} |
| 123 | + onChange={(e) => setScheduleDate(e.target.value)} |
| 124 | + className="rounded-xl border border-zinc-800 bg-zinc-900 px-3 py-2 text-xs text-white focus:border-zinc-600 focus:outline-none" |
| 125 | + /> |
| 126 | + </div> |
| 127 | + <div className="flex items-end gap-2"> |
| 128 | + <button |
| 129 | + onClick={() => act('approve', scheduleDate ? { startsAt: scheduleDate } : {})} |
| 130 | + disabled={loading} |
| 131 | + className="flex items-center gap-1.5 rounded-xl border border-green-500/30 bg-green-500/10 px-3 py-2 text-xs text-green-300 transition-colors hover:border-green-400/60 disabled:opacity-50" |
| 132 | + > |
| 133 | + {loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle className="h-3 w-3" />} |
| 134 | + Approve |
| 135 | + </button> |
| 136 | + <button |
| 137 | + onClick={() => act('reject')} |
| 138 | + disabled={loading} |
| 139 | + className="flex items-center gap-1.5 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300 transition-colors hover:border-red-400/60 disabled:opacity-50" |
| 140 | + > |
| 141 | + <XCircle className="h-3 w-3" /> |
| 142 | + Reject |
| 143 | + </button> |
| 144 | + </div> |
| 145 | + </div> |
| 146 | + )} |
| 147 | + |
| 148 | + {c.status === 'live' && ( |
| 149 | + <button |
| 150 | + onClick={() => act('complete')} |
| 151 | + disabled={loading} |
| 152 | + className="flex items-center gap-1.5 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-300 transition-colors hover:border-zinc-600" |
| 153 | + > |
| 154 | + <CheckCircle className="h-3 w-3" /> |
| 155 | + Mark Complete |
| 156 | + </button> |
| 157 | + )} |
| 158 | + </div> |
| 159 | + )} |
| 160 | + </div> |
| 161 | + ) |
| 162 | +} |
| 163 | + |
| 164 | +export default function AdminAdsPage() { |
| 165 | + const [campaigns, setCampaigns] = useState<Campaign[]>([]) |
| 166 | + const [loading, setLoading] = useState(true) |
| 167 | + const [error, setError] = useState('') |
| 168 | + const [filter, setFilter] = useState('all') |
| 169 | + |
| 170 | + async function load() { |
| 171 | + setLoading(true) |
| 172 | + const res = await fetch('/api/ads/campaigns') |
| 173 | + if (res.status === 403) { setError('Admin access required'); setLoading(false); return } |
| 174 | + if (!res.ok) { setError('Failed to load campaigns'); setLoading(false); return } |
| 175 | + const data = await res.json() |
| 176 | + setCampaigns(data.campaigns || []) |
| 177 | + setLoading(false) |
| 178 | + } |
| 179 | + |
| 180 | + useEffect(() => { load() }, []) |
| 181 | + |
| 182 | + const statuses = ['all', 'pending_payment', 'paid', 'approved', 'live', 'complete', 'rejected'] |
| 183 | + const visible = filter === 'all' ? campaigns : campaigns.filter((c) => c.status === filter) |
| 184 | + |
| 185 | + const stats = { |
| 186 | + paid: campaigns.filter((c) => c.status === 'paid').length, |
| 187 | + approved: campaigns.filter((c) => c.status === 'approved').length, |
| 188 | + live: campaigns.filter((c) => c.status === 'live').length, |
| 189 | + revenue: campaigns.filter((c) => !['pending_payment', 'rejected'].includes(c.status)) |
| 190 | + .reduce((sum, c) => sum + (c.amount_pence ?? 0), 0), |
| 191 | + } |
| 192 | + |
| 193 | + return ( |
| 194 | + <main className="min-h-screen bg-black px-4 py-12 sm:px-8"> |
| 195 | + <div className="mx-auto max-w-4xl space-y-8"> |
| 196 | + |
| 197 | + <div className="flex items-center justify-between gap-4"> |
| 198 | + <div> |
| 199 | + <p className="text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-600">Admin</p> |
| 200 | + <h1 className="text-2xl font-bold tracking-tight text-white mt-1">Ad Campaigns</h1> |
| 201 | + </div> |
| 202 | + <div className="flex items-center gap-2"> |
| 203 | + <Radio className="h-4 w-4 text-zinc-600" /> |
| 204 | + <span className="text-xs text-zinc-500">{campaigns.length} total</span> |
| 205 | + </div> |
| 206 | + </div> |
| 207 | + |
| 208 | + {/* Stats */} |
| 209 | + <div className="grid gap-4 sm:grid-cols-4"> |
| 210 | + {[ |
| 211 | + { label: 'Awaiting Review', value: stats.paid, color: 'text-amber-400' }, |
| 212 | + { label: 'Approved', value: stats.approved, color: 'text-blue-400' }, |
| 213 | + { label: 'Live Now', value: stats.live, color: 'text-green-400' }, |
| 214 | + { label: 'Revenue', value: fmt(stats.revenue), color: 'text-white' }, |
| 215 | + ].map(({ label, value, color }) => ( |
| 216 | + <div key={label} className="rounded-[20px] border border-zinc-800 bg-zinc-950/80 p-4"> |
| 217 | + <p className="text-[10px] uppercase tracking-[0.16em] text-zinc-600">{label}</p> |
| 218 | + <p className={`mt-2 text-xl font-bold ${color}`}>{value}</p> |
| 219 | + </div> |
| 220 | + ))} |
| 221 | + </div> |
| 222 | + |
| 223 | + {/* Filter */} |
| 224 | + <div className="flex flex-wrap gap-2"> |
| 225 | + {statuses.map((s) => ( |
| 226 | + <button |
| 227 | + key={s} |
| 228 | + onClick={() => setFilter(s)} |
| 229 | + className={`rounded-full border px-3 py-1.5 text-[10px] font-bold uppercase tracking-[0.14em] transition-colors ${ |
| 230 | + filter === s |
| 231 | + ? 'border-white bg-white text-black' |
| 232 | + : 'border-zinc-800 text-zinc-500 hover:border-zinc-700 hover:text-zinc-300' |
| 233 | + }`} |
| 234 | + > |
| 235 | + {s === 'all' ? 'All' : s.replace('_', ' ')} |
| 236 | + </button> |
| 237 | + ))} |
| 238 | + </div> |
| 239 | + |
| 240 | + {loading ? ( |
| 241 | + <div className="flex items-center gap-2 text-sm text-zinc-500"> |
| 242 | + <Loader2 className="h-4 w-4 animate-spin" /> Loading… |
| 243 | + </div> |
| 244 | + ) : error ? ( |
| 245 | + <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> |
| 246 | + ) : visible.length === 0 ? ( |
| 247 | + <div className="rounded-[24px] border border-dashed border-zinc-800 px-6 py-12 text-center"> |
| 248 | + <Clock className="mx-auto h-7 w-7 text-zinc-700 mb-3" /> |
| 249 | + <p className="text-sm text-zinc-500">No campaigns in this state.</p> |
| 250 | + </div> |
| 251 | + ) : ( |
| 252 | + <div className="space-y-3"> |
| 253 | + {visible.map((c) => ( |
| 254 | + <CampaignRow key={c.id} c={c} onAction={load} /> |
| 255 | + ))} |
| 256 | + </div> |
| 257 | + )} |
| 258 | + |
| 259 | + </div> |
| 260 | + </main> |
| 261 | + ) |
| 262 | +} |
0 commit comments