@coursebuilder/analytics 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/dist/api/index.d.ts +158 -0
  3. package/dist/api/index.js +317 -0
  4. package/dist/api/index.js.map +1 -0
  5. package/dist/catalog.d.ts +14 -0
  6. package/dist/catalog.js +209 -0
  7. package/dist/catalog.js.map +1 -0
  8. package/dist/components/index.d.ts +172 -0
  9. package/dist/components/index.js +1258 -0
  10. package/dist/components/index.js.map +1 -0
  11. package/dist/engine.d.ts +20 -0
  12. package/dist/engine.js +350 -0
  13. package/dist/engine.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +353 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/providers/database.d.ts +79 -0
  18. package/dist/providers/database.js +533 -0
  19. package/dist/providers/database.js.map +1 -0
  20. package/dist/providers/derived.d.ts +45 -0
  21. package/dist/providers/derived.js +32 -0
  22. package/dist/providers/derived.js.map +1 -0
  23. package/dist/providers/ga4.d.ts +43 -0
  24. package/dist/providers/ga4.js +220 -0
  25. package/dist/providers/ga4.js.map +1 -0
  26. package/dist/providers/index.d.ts +8 -0
  27. package/dist/providers/index.js +1239 -0
  28. package/dist/providers/index.js.map +1 -0
  29. package/dist/providers/mux.d.ts +103 -0
  30. package/dist/providers/mux.js +241 -0
  31. package/dist/providers/mux.js.map +1 -0
  32. package/dist/providers/survey.d.ts +102 -0
  33. package/dist/providers/survey.js +233 -0
  34. package/dist/providers/survey.js.map +1 -0
  35. package/dist/types.d.ts +303 -0
  36. package/dist/types.js +1 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +101 -0
  39. package/src/api/catalog-handler.ts +321 -0
  40. package/src/api/index.ts +4 -0
  41. package/src/api/token-handler.ts +71 -0
  42. package/src/catalog.ts +223 -0
  43. package/src/components/country-chart.tsx +114 -0
  44. package/src/components/index.ts +5 -0
  45. package/src/components/omnibus-dashboard.tsx +1460 -0
  46. package/src/components/revenue-chart.tsx +251 -0
  47. package/src/components/use-chart-colors.ts +75 -0
  48. package/src/engine.ts +201 -0
  49. package/src/index.ts +7 -0
  50. package/src/providers/database.ts +795 -0
  51. package/src/providers/derived.ts +79 -0
  52. package/src/providers/ga4.ts +173 -0
  53. package/src/providers/index.ts +44 -0
  54. package/src/providers/mux.ts +438 -0
  55. package/src/providers/survey.ts +487 -0
  56. package/src/types.ts +333 -0
@@ -0,0 +1,1460 @@
1
+ 'use client'
2
+
3
+ import React, { useState, useTransition } from 'react'
4
+ import {
5
+ CheckIcon,
6
+ ChevronDownIcon,
7
+ ClipboardIcon,
8
+ ClipboardListIcon,
9
+ ClockIcon,
10
+ DollarSignIcon,
11
+ ExternalLinkIcon,
12
+ FilmIcon,
13
+ GlobeIcon,
14
+ LinkIcon,
15
+ Loader2Icon,
16
+ MousePointerClickIcon,
17
+ PlayIcon,
18
+ ShoppingCartIcon,
19
+ TrendingUpIcon,
20
+ } from 'lucide-react'
21
+ import { parseAsStringLiteral, useQueryState } from 'nuqs'
22
+
23
+ import {
24
+ Card,
25
+ CardContent,
26
+ CardDescription,
27
+ CardHeader,
28
+ CardTitle,
29
+ } from '@coursebuilder/ui'
30
+
31
+ import type { AnalyticsRange } from '../types'
32
+ import { CountryChart } from './country-chart'
33
+ import { RevenueChart } from './revenue-chart'
34
+
35
+ // ─── Types ───────────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Data structure for the Mux video dashboard, inlined here so the shared
39
+ * component does not depend on the app-level mux-data module.
40
+ */
41
+ export interface VideoDashboardData {
42
+ overview: {
43
+ totalViews: number
44
+ uniqueViewers: number
45
+ totalWatchTimeMs: number
46
+ totalPlayingTimeMs: number
47
+ viewerExperienceScore: number
48
+ globalExperienceScore: number | null
49
+ }
50
+ watchTimeSeries: {
51
+ date: string
52
+ watchTimeMs: number
53
+ }[]
54
+ topVideos: {
55
+ title: string
56
+ views: number
57
+ watchTimeMs: number
58
+ playingTimeMs: number
59
+ }[]
60
+ countries: {
61
+ country: string
62
+ views: number
63
+ watchTimeMs: number
64
+ }[]
65
+ }
66
+
67
+ export interface DashboardData {
68
+ summary: {
69
+ totalRevenue: number
70
+ purchaseCount: number
71
+ avgOrderValue: number
72
+ }
73
+ daily: { date: string; revenue: number; count: number }[]
74
+ previousDaily: { date: string; revenue: number; count: number }[]
75
+ byProduct: {
76
+ productId: string
77
+ productName: string
78
+ revenue: number
79
+ count: number
80
+ }[]
81
+ byCountry: { country: string; revenue: number; count: number }[]
82
+ recentPurchases: {
83
+ id: string
84
+ createdAt: Date
85
+ totalAmount: number
86
+ productName: string
87
+ productId: string
88
+ country: string | null
89
+ couponId: string | null
90
+ userName: string | null
91
+ userEmail: string | null
92
+ isTeam: boolean
93
+ seats: number | null
94
+ }[]
95
+ attribution: { type: string; count: number }[]
96
+ shortlinks: {
97
+ shortlinkId: string
98
+ slug: string
99
+ url: string
100
+ clicks: number
101
+ signups: number
102
+ purchases: number
103
+ }[]
104
+ traffic: {
105
+ sessions: number
106
+ totalUsers: number
107
+ newUsers: number
108
+ pageviews: number
109
+ } | null
110
+ attributionCoverage: {
111
+ totalRevenue: number
112
+ attributedRevenue: number
113
+ unattributedRevenue: number
114
+ attributionRate: number
115
+ totalPurchases: number
116
+ } | null
117
+ mux: VideoDashboardData | null
118
+ muxThumbnails: Record<string, string>
119
+ surveySegments:
120
+ | {
121
+ questionId: string
122
+ question: string
123
+ type: string | null
124
+ responses: number
125
+ answerDistribution: { answer: string; count: number }[]
126
+ }[]
127
+ | null
128
+ surveyCorrelation: {
129
+ totalRespondents: number
130
+ respondentsWhoPurchased: number
131
+ overallConversionRate: number
132
+ totalRevenueFromRespondents: number
133
+ prePurchaseRespondents: number
134
+ postPurchaseRespondents: number
135
+ neverPurchasedRespondents: number
136
+ baselineConversionRate: number
137
+ byQuestion: {
138
+ questionId: string
139
+ questionTitle: string | null
140
+ answer: string
141
+ respondents: number
142
+ purchasers: number
143
+ conversionRate: number
144
+ revenue: number
145
+ prePurchaseCount: number
146
+ postPurchaseCount: number
147
+ }[]
148
+ } | null
149
+ }
150
+
151
+ // ─── Formatters ──────────────────────────────────────────────────────────────
152
+
153
+ function fmt$(v: number): string {
154
+ return new Intl.NumberFormat('en-US', {
155
+ style: 'currency',
156
+ currency: 'USD',
157
+ minimumFractionDigits: 0,
158
+ maximumFractionDigits: 0,
159
+ }).format(v)
160
+ }
161
+
162
+ function fmtK(v: number): string {
163
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
164
+ if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`
165
+ return v.toLocaleString()
166
+ }
167
+
168
+ function fmtWatchMs(ms: number): string {
169
+ const hours = ms / 1000 / 60 / 60
170
+ if (hours >= 1000) return `${(hours / 1000).toFixed(1)}k hrs`
171
+ if (hours >= 1) return `${hours.toFixed(0)} hrs`
172
+ return `${(ms / 1000 / 60).toFixed(0)} min`
173
+ }
174
+
175
+ function fmtAgo(date: Date): string {
176
+ const ms = Date.now() - new Date(date).getTime()
177
+ const h = Math.floor(ms / 3_600_000)
178
+ if (h < 1) return 'just now'
179
+ if (h < 24) return `${h}h ago`
180
+ const d = Math.floor(h / 24)
181
+ return d === 1 ? 'yesterday' : `${d}d ago`
182
+ }
183
+
184
+ function countryFlag(code: string | null): string {
185
+ if (!code || code.length !== 2) return '🌍'
186
+ const offset = 0x1f1e6
187
+ const a = code.toUpperCase().charCodeAt(0) - 65 + offset
188
+ const b = code.toUpperCase().charCodeAt(1) - 65 + offset
189
+ return String.fromCodePoint(a, b)
190
+ }
191
+
192
+ // ─── Stat Card ───────────────────────────────────────────────────────────────
193
+
194
+ function Stat({
195
+ label,
196
+ value,
197
+ sub,
198
+ icon: Icon,
199
+ accent,
200
+ }: {
201
+ label: string
202
+ value: string
203
+ sub?: string
204
+ icon: React.ComponentType<{ className?: string }>
205
+ accent?: boolean
206
+ index?: number
207
+ }) {
208
+ return (
209
+ <div
210
+ className={`rounded-xl border p-4 transition-[transform,box-shadow] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97] motion-safe:hover:-translate-y-0.5 motion-safe:hover:shadow-md [@media(hover:hover)]:cursor-default ${accent ? 'border-emerald-500/20 bg-emerald-500/[0.03]' : 'border-border/50 bg-card/50'}`}
211
+ >
212
+ <div className="flex items-center justify-between">
213
+ <span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
214
+ {label}
215
+ </span>
216
+ <Icon className="text-muted-foreground/40 group-hover:text-muted-foreground/70 h-3.5 w-3.5 transition-colors duration-200" />
217
+ </div>
218
+ <div className="mt-1.5">
219
+ <span className="text-foreground text-xl font-bold tabular-nums tracking-tight">
220
+ {value}
221
+ </span>
222
+ </div>
223
+ {sub && (
224
+ <p className="text-muted-foreground mt-0.5 text-[11px] leading-relaxed">
225
+ {sub}
226
+ </p>
227
+ )}
228
+ </div>
229
+ )
230
+ }
231
+
232
+ // ─── Agent API Card ───────────────────────────────────────────────────────────
233
+
234
+ function AgentApiCard({ appName = 'aihero' }: { appName?: string }) {
235
+ const [state, setState] = useState<'idle' | 'generating' | 'copied'>('idle')
236
+ const baseUrl =
237
+ typeof window !== 'undefined'
238
+ ? window.location.origin
239
+ : 'https://www.aihero.dev'
240
+ const endpoint = `${baseUrl}/api/analytics`
241
+
242
+ const handleCopy = async () => {
243
+ setState('generating')
244
+ try {
245
+ // Fetch token and catalog in parallel so prompt matches available surfaces
246
+ const [tokenRes, catalogRes] = await Promise.all([
247
+ fetch('/api/analytics/token', { method: 'POST' }),
248
+ fetch('/api/analytics')
249
+ .then((r) => r.json())
250
+ .catch(() => null),
251
+ ])
252
+ if (!tokenRes.ok) throw new Error('Failed to generate token')
253
+ const { token, ttlLabel, expiresAt } = await tokenRes.json()
254
+
255
+ // Build surface examples from live catalog
256
+ const surfaces: {
257
+ name: string
258
+ description: string
259
+ category: string
260
+ }[] = catalogRes?.surfaces ?? []
261
+ const categories = new Map<string, number>()
262
+ for (const s of surfaces) {
263
+ categories.set(s.category, (categories.get(s.category) ?? 0) + 1)
264
+ }
265
+ const categoryLine = [...categories.entries()]
266
+ .map(([cat, count]) => `${cat} (${count})`)
267
+ .join(', ')
268
+
269
+ // Pick one representative surface per category for the examples
270
+ const picks: { name: string; description: string }[] = [
271
+ { name: '', description: 'surface catalog' },
272
+ ]
273
+ const seen = new Set<string>()
274
+ const preferred = [
275
+ 'summary',
276
+ 'attribution/coverage',
277
+ 'traffic',
278
+ 'youtube/videos',
279
+ 'surveys',
280
+ 'correlation/traffic-revenue',
281
+ 'correlation/youtube-revenue',
282
+ 'attribution/email-campaigns',
283
+ ]
284
+ for (const name of preferred) {
285
+ const s = surfaces.find((x: any) => x.name === name)
286
+ if (s && !seen.has(s.category)) {
287
+ seen.add(s.category)
288
+ picks.push({ name: s.name, description: s.description })
289
+ }
290
+ }
291
+ // Fill any remaining categories
292
+ for (const s of surfaces) {
293
+ if (!seen.has(s.category)) {
294
+ seen.add(s.category)
295
+ picks.push({ name: s.name, description: s.description })
296
+ }
297
+ }
298
+
299
+ const exampleLines = picks
300
+ .map((p) =>
301
+ p.name
302
+ ? `GET ${endpoint}?surface=${p.name}&range=30d → ${p.description}`
303
+ : `GET ${endpoint} → ${p.description}`,
304
+ )
305
+ .join('\n')
306
+
307
+ const hasYouTube = surfaces.some((s: any) => s.category === 'youtube')
308
+ const ytNote = hasYouTube
309
+ ? `\nImportant:
310
+ - YouTube surfaces are useful for correlation and content analysis, not live dashboard ops
311
+ - YouTube Analytics data lags by about 48 hours, so call out the delay when interpreting fresh periods\n`
312
+ : ''
313
+
314
+ const prompt = `# ${appName} Analytics API
315
+ Base: ${endpoint}
316
+ Auth: Bearer ${token}
317
+ Token expires: ${new Date(expiresAt).toLocaleString()} (${ttlLabel})
318
+
319
+ ${exampleLines}
320
+ ${ytNote}
321
+ Example:
322
+ curl -H "Authorization: Bearer ${token}" "${endpoint}?surface=summary&range=30d"
323
+
324
+ Categories: ${categoryLine}
325
+ Every response has contextual next_actions. Errors have codes + fix hints.`
326
+
327
+ await navigator.clipboard.writeText(prompt)
328
+ setState('copied')
329
+ setTimeout(() => setState('idle'), 3000)
330
+ } catch {
331
+ setState('idle')
332
+ }
333
+ }
334
+
335
+ return (
336
+ <div className="border-border/30 flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2 sm:px-4 sm:py-2.5">
337
+ <div className="flex min-w-0 items-center gap-2 text-xs">
338
+ <span className="text-muted-foreground/60 shrink-0">⚡</span>
339
+ <a
340
+ href="/api/analytics"
341
+ target="_blank"
342
+ rel="noopener noreferrer"
343
+ className="text-muted-foreground hover:text-foreground shrink-0 font-medium underline-offset-2 transition-colors hover:underline"
344
+ >
345
+ /api/analytics
346
+ </a>
347
+ <span className="text-muted-foreground/40 hidden sm:inline">·</span>
348
+ <span className="text-muted-foreground/60 hidden sm:inline">
349
+ HATEOAS catalog
350
+ </span>
351
+ </div>
352
+ <button
353
+ onClick={handleCopy}
354
+ disabled={state === 'generating'}
355
+ className={`flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px] font-semibold transition-[transform,background-color,color,box-shadow] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97] disabled:pointer-events-none ${
356
+ state === 'copied'
357
+ ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
358
+ : state === 'generating'
359
+ ? 'bg-muted text-muted-foreground'
360
+ : 'bg-primary/10 text-primary hover:bg-primary/20 shadow-sm'
361
+ }`}
362
+ >
363
+ {state === 'generating' ? (
364
+ <>
365
+ <Loader2Icon className="h-3 w-3 animate-spin" />
366
+ Generating token…
367
+ </>
368
+ ) : state === 'copied' ? (
369
+ <>
370
+ <CheckIcon className="h-3 w-3" />
371
+ Copied with token
372
+ </>
373
+ ) : (
374
+ <>
375
+ <ClipboardIcon className="h-3 w-3" />
376
+ Copy agent prompt
377
+ </>
378
+ )}
379
+ </button>
380
+ </div>
381
+ )
382
+ }
383
+
384
+ // ─── Video Performance Cards ──────────────────────────────────────────────────
385
+
386
+ interface VideoItem {
387
+ title: string
388
+ thumbnailUrl: string | null
389
+ watchTime: string
390
+ views: number
391
+ href: string | null
392
+ badge?: string
393
+ }
394
+
395
+ function TopVideosCard({
396
+ label,
397
+ subtitle,
398
+ icon: Icon,
399
+ iconColor,
400
+ videos,
401
+ }: {
402
+ label: string
403
+ subtitle: string
404
+ icon: React.ComponentType<{ className?: string }>
405
+ iconColor: string
406
+ videos: VideoItem[]
407
+ }) {
408
+ if (videos.length === 0) return null
409
+
410
+ return (
411
+ <Card className="flex-1">
412
+ <CardHeader className="pb-3">
413
+ <div className="flex items-center gap-2.5">
414
+ <div
415
+ className={`flex h-7 w-7 items-center justify-center rounded-lg ${iconColor}`}
416
+ >
417
+ <Icon className="h-3.5 w-3.5" />
418
+ </div>
419
+ <div>
420
+ <CardTitle className="text-sm font-semibold">{label}</CardTitle>
421
+ <CardDescription className="text-[11px]">
422
+ {subtitle}
423
+ </CardDescription>
424
+ </div>
425
+ </div>
426
+ </CardHeader>
427
+ <CardContent className="space-y-0 pt-0">
428
+ {videos.map((v, i) => {
429
+ const Row = v.href ? 'a' : 'div'
430
+ const linkProps = v.href
431
+ ? {
432
+ href: v.href,
433
+ target: '_blank' as const,
434
+ rel: 'noopener noreferrer',
435
+ }
436
+ : {}
437
+ return (
438
+ <Row
439
+ key={v.title}
440
+ {...linkProps}
441
+ className="hover:bg-muted/40 group -mx-2 flex items-center gap-3 rounded-lg px-2 py-2.5 transition-[background-color] duration-[150ms] ease-[cubic-bezier(0.23,1,0.32,1)]"
442
+ >
443
+ <span className="text-muted-foreground/50 w-4 text-right text-xs font-medium tabular-nums">
444
+ {i + 1}
445
+ </span>
446
+ {v.thumbnailUrl ? (
447
+ <img
448
+ src={v.thumbnailUrl}
449
+ alt=""
450
+ width={72}
451
+ height={40}
452
+ loading="lazy"
453
+ className="hidden h-10 w-[72px] shrink-0 rounded object-cover sm:block"
454
+ />
455
+ ) : (
456
+ <div className="bg-muted hidden h-10 w-[72px] shrink-0 items-center justify-center rounded sm:flex">
457
+ <PlayIcon
458
+ className="text-muted-foreground/40 h-4 w-4"
459
+ aria-hidden="true"
460
+ />
461
+ </div>
462
+ )}
463
+ <div className="min-w-0 flex-1">
464
+ <p className="text-foreground/90 group-hover:text-foreground line-clamp-2 text-[13px] font-medium leading-snug transition-colors">
465
+ {v.title}
466
+ </p>
467
+ <div className="text-muted-foreground mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px]">
468
+ <span className="flex items-center gap-1">
469
+ <ClockIcon className="h-3 w-3" aria-hidden="true" />
470
+ {v.watchTime}
471
+ </span>
472
+ <span>{fmtK(v.views)} views</span>
473
+ {v.badge && (
474
+ <span className="text-emerald-600 dark:text-emerald-400">
475
+ {v.badge}
476
+ </span>
477
+ )}
478
+ </div>
479
+ </div>
480
+ {v.href && (
481
+ <ExternalLinkIcon
482
+ className="text-muted-foreground/0 group-hover:text-muted-foreground/60 h-3.5 w-3.5 shrink-0 transition-colors"
483
+ aria-hidden="true"
484
+ />
485
+ )}
486
+ </Row>
487
+ )
488
+ })}
489
+ </CardContent>
490
+ </Card>
491
+ )
492
+ }
493
+
494
+ // ─── Shortlinks Card ──────────────────────────────────────────────────────────
495
+
496
+ function ShortlinksCard({
497
+ shortlinks,
498
+ totalClicks,
499
+ }: {
500
+ shortlinks: DashboardData['shortlinks']
501
+ totalClicks: number
502
+ }) {
503
+ const [expanded, setExpanded] = useState(false)
504
+ if (shortlinks.length === 0) return null
505
+
506
+ const visible = expanded ? shortlinks : shortlinks.slice(0, 10)
507
+ const hasMore = shortlinks.length > 10
508
+ const totalSignups = shortlinks.reduce((s, l) => s + l.signups, 0)
509
+ const totalPurchases = shortlinks.reduce((s, l) => s + l.purchases, 0)
510
+
511
+ return (
512
+ <Card>
513
+ <CardHeader>
514
+ <div className="flex items-center gap-2.5">
515
+ <LinkIcon className="text-muted-foreground h-4 w-4" />
516
+ <div>
517
+ <CardTitle className="text-sm font-semibold">
518
+ Top Shortlinks
519
+ </CardTitle>
520
+ <CardDescription className="text-[11px]">
521
+ {fmtK(totalClicks)} clicks · {totalSignups.toLocaleString()}{' '}
522
+ signups · {totalPurchases.toLocaleString()} purchases
523
+ </CardDescription>
524
+ </div>
525
+ </div>
526
+ </CardHeader>
527
+ <CardContent>
528
+ <div className="overflow-x-auto">
529
+ <table className="w-full">
530
+ <thead>
531
+ <tr className="text-muted-foreground border-border/50 border-b text-left text-[11px] uppercase tracking-wider">
532
+ <th className="pb-2.5 pr-4 font-medium">Link</th>
533
+ <th className="pb-2.5 pr-4 font-medium">Destination</th>
534
+ <th className="pb-2.5 text-right font-medium">Clicks</th>
535
+ <th className="hidden pb-2.5 text-right font-medium sm:table-cell">
536
+ Signups
537
+ </th>
538
+ <th className="hidden pb-2.5 text-right font-medium sm:table-cell">
539
+ Purchases
540
+ </th>
541
+ </tr>
542
+ </thead>
543
+ <tbody className="divide-border/30 divide-y">
544
+ {visible.map((link) => (
545
+ <tr key={link.shortlinkId} className="group">
546
+ <td className="py-2.5 pr-4 text-sm font-medium">
547
+ /s/{link.slug}
548
+ </td>
549
+ <td className="text-muted-foreground max-w-xs truncate py-2.5 pr-4 text-sm">
550
+ <a
551
+ href={link.url}
552
+ target="_blank"
553
+ rel="noopener noreferrer"
554
+ className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
555
+ >
556
+ {link.url
557
+ .replace(/^https?:\/\/(www\.)?/, '')
558
+ .substring(0, 50)}
559
+ <ExternalLinkIcon
560
+ className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-100"
561
+ aria-hidden="true"
562
+ />
563
+ </a>
564
+ </td>
565
+ <td className="text-foreground py-2.5 text-right text-sm font-semibold tabular-nums">
566
+ {link.clicks.toLocaleString()}
567
+ </td>
568
+ <td className="hidden py-2.5 text-right text-sm tabular-nums sm:table-cell">
569
+ {link.signups > 0 ? link.signups.toLocaleString() : '–'}
570
+ </td>
571
+ <td
572
+ className={`hidden py-2.5 text-right text-sm font-semibold tabular-nums sm:table-cell ${link.purchases > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}`}
573
+ >
574
+ {link.purchases > 0 ? link.purchases.toLocaleString() : '–'}
575
+ </td>
576
+ </tr>
577
+ ))}
578
+ </tbody>
579
+ </table>
580
+ </div>
581
+ {hasMore && (
582
+ <button
583
+ onClick={() => setExpanded(!expanded)}
584
+ className="text-muted-foreground hover:text-foreground mt-3 flex w-full items-center justify-center gap-1 text-xs transition-[color,transform] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]"
585
+ >
586
+ <ChevronDownIcon
587
+ className={`h-3 w-3 transition-transform duration-[200ms] ease-[cubic-bezier(0.23,1,0.32,1)] ${expanded ? 'rotate-180' : ''}`}
588
+ />
589
+ {expanded ? 'Show less' : `Show ${shortlinks.length - 10} more`}
590
+ </button>
591
+ )}
592
+ </CardContent>
593
+ </Card>
594
+ )
595
+ }
596
+
597
+ // ─── Range Selector ───────────────────────────────────────────────────────────
598
+
599
+ const RANGES: { value: AnalyticsRange; label: string }[] = [
600
+ { value: '24h', label: '24h' },
601
+ { value: '7d', label: '7d' },
602
+ { value: '30d', label: '30d' },
603
+ { value: '90d', label: '90d' },
604
+ { value: 'all', label: 'All' },
605
+ ]
606
+
607
+ const rangeParser = parseAsStringLiteral([
608
+ '24h',
609
+ '7d',
610
+ '30d',
611
+ '90d',
612
+ 'all',
613
+ ] as const).withDefault('30d')
614
+
615
+ // ─── Dashboard ────────────────────────────────────────────────────────────────
616
+
617
+ export function OmnibusDashboard({
618
+ data,
619
+ initialRange,
620
+ appName,
621
+ surveyDrilldownHref,
622
+ }: {
623
+ data: DashboardData
624
+ initialRange: AnalyticsRange
625
+ appName?: string
626
+ /** Link to the analytics survey list page (e.g. /admin/analytics/surveys).
627
+ * When set, the Survey Segments header becomes a clickable link. */
628
+ surveyDrilldownHref?: string
629
+ }) {
630
+ const [range, setRange] = useQueryState('range', rangeParser)
631
+ const [isPending, startTransition] = useTransition()
632
+
633
+ const rangeLabel =
634
+ range === '24h'
635
+ ? '24 hours'
636
+ : range === '7d'
637
+ ? '7 days'
638
+ : range === '90d'
639
+ ? '90 days'
640
+ : range === 'all'
641
+ ? 'all time'
642
+ : '30 days'
643
+
644
+ const signupCount =
645
+ data.attribution.find((a) => a.type === 'signup')?.count ?? 0
646
+ const purchaseAttrCount =
647
+ data.attribution.find((a) => a.type === 'purchase')?.count ?? 0
648
+ const totalClicks = data.shortlinks.reduce((s, l) => s + l.clicks, 0)
649
+
650
+ // ── Presence flags ──────────────────────────────────────────────────────
651
+ const hasRevenue =
652
+ data.summary.totalRevenue > 0 || data.summary.purchaseCount > 0
653
+ const hasRevenueChart = data.daily.some((d) => d.revenue > 0)
654
+ const hasByProduct = data.byProduct.length > 0
655
+ const hasByCountry = data.byCountry.length > 0
656
+ const hasRecentPurchases = data.recentPurchases.length > 0
657
+ const hasAttribution = data.attribution.length > 0
658
+ const hasAttributionCoverage = data.attributionCoverage != null
659
+ const hasMux = data.mux != null
660
+ const hasTraffic = data.traffic != null
661
+ const hasSurveys =
662
+ data.surveySegments != null && data.surveySegments.length > 0
663
+ const hasSurveyCorrelation = data.surveyCorrelation != null
664
+ const hasShortlinks = data.shortlinks.length > 0
665
+
666
+ return (
667
+ <div className="flex flex-col gap-5 lg:gap-7">
668
+ {/* ── Header ──────────────────────────────────────────────────── */}
669
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
670
+ <div className="min-w-0">
671
+ <h1 className="text-pretty text-xl font-bold tracking-tight sm:text-2xl">
672
+ Analytics
673
+ </h1>
674
+ <p className="text-muted-foreground truncate text-[13px]">
675
+ Revenue · Attribution · Video · Traffic · Surveys — {rangeLabel}
676
+ </p>
677
+ </div>
678
+ <div className="flex shrink-0 items-center gap-2">
679
+ {isPending && (
680
+ <span className="text-muted-foreground animate-pulse text-xs">
681
+ Loading…
682
+ </span>
683
+ )}
684
+ <div className="border-border/40 bg-muted/20 inline-flex items-center gap-0.5 overflow-x-auto rounded-lg border p-0.5">
685
+ {RANGES.map(({ value, label }) => (
686
+ <button
687
+ key={value}
688
+ onClick={() =>
689
+ startTransition(() => {
690
+ setRange(value)
691
+ })
692
+ }
693
+ disabled={isPending}
694
+ className={`shrink-0 rounded-md px-2.5 py-1 text-[11px] font-medium transition-[background-color,color,transform] duration-[150ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.95] ${
695
+ range === value
696
+ ? 'bg-foreground text-background shadow-sm'
697
+ : 'text-muted-foreground hover:text-foreground'
698
+ } ${isPending ? 'cursor-wait opacity-60' : ''}`}
699
+ >
700
+ {label}
701
+ </button>
702
+ ))}
703
+ </div>
704
+ </div>
705
+ </div>
706
+
707
+ <div
708
+ className={`flex flex-col gap-5 transition-opacity duration-200 lg:gap-7 ${isPending ? 'pointer-events-none opacity-50' : ''}`}
709
+ >
710
+ {/* ── Agent API ────────────────────────────────────────────── */}
711
+ <AgentApiCard appName={appName} />
712
+
713
+ {/* ── Stat Grid ────────────────────────────────────────────── */}
714
+ <div className="grid grid-cols-2 gap-2.5 lg:grid-cols-3 xl:grid-cols-5">
715
+ {hasRevenue && (
716
+ <Stat
717
+ label="Revenue"
718
+ value={fmt$(data.summary.totalRevenue)}
719
+ sub={`${data.summary.purchaseCount} purchases · ${fmt$(data.summary.avgOrderValue)} avg`}
720
+ icon={DollarSignIcon}
721
+ accent
722
+ />
723
+ )}
724
+ {hasMux && (
725
+ <Stat
726
+ label="Site Watch Time"
727
+ value={fmtWatchMs(data.mux!.overview.totalPlayingTimeMs)}
728
+ sub={`${fmtK(data.mux!.overview.totalViews)} views · ${fmtK(data.mux!.overview.uniqueViewers)} viewers`}
729
+ icon={FilmIcon}
730
+ />
731
+ )}
732
+ {hasTraffic && (
733
+ <Stat
734
+ label="Sessions"
735
+ value={fmtK(data.traffic!.sessions)}
736
+ sub={`${fmtK(data.traffic!.totalUsers)} users · ${fmtK(data.traffic!.pageviews)} pages`}
737
+ icon={GlobeIcon}
738
+ />
739
+ )}
740
+ {purchaseAttrCount > 0 && (
741
+ <Stat
742
+ label="Conversions"
743
+ value={`${purchaseAttrCount.toLocaleString()}`}
744
+ sub={`${signupCount} signups · ${totalClicks > 0 ? ((purchaseAttrCount / signupCount) * 100).toFixed(1) : 0}% signup→purchase`}
745
+ icon={TrendingUpIcon}
746
+ />
747
+ )}
748
+ {hasShortlinks && (
749
+ <Stat
750
+ label="Link Clicks"
751
+ value={fmtK(totalClicks)}
752
+ sub={`${data.shortlinks.length} active · ${signupCount} signups`}
753
+ icon={MousePointerClickIcon}
754
+ />
755
+ )}
756
+ {hasSurveys && (
757
+ <Stat
758
+ label="Surveys"
759
+ value={`${data.surveySegments!.length}`}
760
+ sub={`${data.surveySegments!.reduce((s, q) => s + q.responses, 0).toLocaleString()} responses`}
761
+ icon={ClipboardListIcon}
762
+ />
763
+ )}
764
+ </div>
765
+
766
+ {/* ── Video Performance ────────────────────────────────────── */}
767
+ {hasMux && data.mux!.topVideos.length > 0 && (
768
+ <TopVideosCard
769
+ label="Site Videos"
770
+ subtitle={`${fmtWatchMs(data.mux!.overview.totalPlayingTimeMs)} watch time · ${fmtK(data.mux!.overview.uniqueViewers)} viewers`}
771
+ icon={FilmIcon}
772
+ iconColor="bg-violet-500/10 text-violet-500"
773
+ videos={data.mux!.topVideos.slice(0, 5).map((v) => ({
774
+ title: v.title,
775
+ thumbnailUrl: data.muxThumbnails[v.title] ?? null,
776
+ watchTime: fmtWatchMs(v.playingTimeMs),
777
+ views: v.views,
778
+ href: null,
779
+ }))}
780
+ />
781
+ )}
782
+
783
+ {/* ── Attribution Summary ──────────────────────────────────── */}
784
+ {purchaseAttrCount > 0 && (
785
+ <Card>
786
+ <CardHeader className="pb-3">
787
+ <div className="flex items-center gap-2.5">
788
+ <LinkIcon className="text-muted-foreground h-4 w-4" />
789
+ <div>
790
+ <CardTitle className="text-sm font-semibold">
791
+ Attribution Trail
792
+ </CardTitle>
793
+ <CardDescription className="text-[11px]">
794
+ Shortlink conversions · first-touch UTMs accumulating
795
+ </CardDescription>
796
+ </div>
797
+ </div>
798
+ </CardHeader>
799
+ <CardContent>
800
+ <div className="grid gap-3 sm:grid-cols-3">
801
+ <div className="bg-muted/20 rounded-lg p-3.5">
802
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
803
+ Click → Signup
804
+ </span>
805
+ <span className="text-foreground mt-1 block text-xl font-bold tabular-nums">
806
+ {signupCount.toLocaleString()}
807
+ </span>
808
+ </div>
809
+ <div className="bg-muted/20 rounded-lg p-3.5">
810
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
811
+ Click → Purchase
812
+ </span>
813
+ <span className="text-foreground mt-1 block text-xl font-bold tabular-nums">
814
+ {purchaseAttrCount.toLocaleString()}
815
+ </span>
816
+ </div>
817
+ <div className="bg-muted/20 rounded-lg p-3.5">
818
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
819
+ Conversion Rate
820
+ </span>
821
+ <span className="text-foreground mt-1 block text-xl font-bold tabular-nums">
822
+ {signupCount > 0
823
+ ? `${((purchaseAttrCount / signupCount) * 100).toFixed(1)}%`
824
+ : '—'}
825
+ </span>
826
+ </div>
827
+ </div>
828
+ </CardContent>
829
+ </Card>
830
+ )}
831
+
832
+ {/* ── Attribution Coverage ─────────────────────────────────── */}
833
+ {hasAttributionCoverage &&
834
+ data.attributionCoverage!.totalPurchases > 0 && (
835
+ <Card>
836
+ <CardHeader className="pb-3">
837
+ <div className="flex items-center gap-2.5">
838
+ <TrendingUpIcon className="text-muted-foreground h-4 w-4" />
839
+ <div>
840
+ <CardTitle className="text-sm font-semibold">
841
+ Attribution Coverage
842
+ </CardTitle>
843
+ <CardDescription className="text-[11px]">
844
+ Attributed vs dark revenue
845
+ </CardDescription>
846
+ </div>
847
+ </div>
848
+ </CardHeader>
849
+ <CardContent>
850
+ <div className="grid gap-3 sm:grid-cols-3">
851
+ <div className="bg-muted/20 rounded-lg p-3.5">
852
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
853
+ Attributed
854
+ </span>
855
+ <span className="text-foreground mt-1 block text-xl font-bold tabular-nums">
856
+ {fmt$(data.attributionCoverage!.attributedRevenue)}
857
+ </span>
858
+ <span className="text-muted-foreground text-[11px]">
859
+ {(
860
+ data.attributionCoverage!.attributionRate * 100
861
+ ).toFixed(1)}
862
+ % of total
863
+ </span>
864
+ </div>
865
+ <div className="bg-muted/20 rounded-lg p-3.5">
866
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
867
+ Dark / Unknown
868
+ </span>
869
+ <span className="text-foreground mt-1 block text-xl font-bold tabular-nums">
870
+ {fmt$(data.attributionCoverage!.unattributedRevenue)}
871
+ </span>
872
+ </div>
873
+ <div className="bg-muted/20 rounded-lg p-3.5">
874
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
875
+ Total Purchases
876
+ </span>
877
+ <span className="text-foreground mt-1 block text-xl font-bold tabular-nums">
878
+ {data.attributionCoverage!.totalPurchases.toLocaleString()}
879
+ </span>
880
+ </div>
881
+ </div>
882
+ </CardContent>
883
+ </Card>
884
+ )}
885
+
886
+ {/* ── Survey Segments ──────────────────────────────────────── */}
887
+ {hasSurveys && (
888
+ <Card>
889
+ <CardHeader className="pb-3">
890
+ <div className="flex items-center justify-between">
891
+ <div className="flex items-center gap-2.5">
892
+ <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500/10 text-indigo-500">
893
+ <ClipboardListIcon className="h-3.5 w-3.5" />
894
+ </div>
895
+ <div>
896
+ <CardTitle className="text-sm font-semibold">
897
+ Survey Segments
898
+ </CardTitle>
899
+ <CardDescription className="text-[11px]">
900
+ How users self-categorize across survey questions
901
+ </CardDescription>
902
+ </div>
903
+ </div>
904
+ {surveyDrilldownHref && (
905
+ <a
906
+ href={surveyDrilldownHref}
907
+ className="text-muted-foreground hover:text-foreground text-[11px] font-medium transition-colors"
908
+ >
909
+ View all surveys →
910
+ </a>
911
+ )}
912
+ </div>
913
+ </CardHeader>
914
+ <CardContent className="space-y-6">
915
+ {data.surveySegments!.slice(0, 8).map((q) => {
916
+ const totalForQuestion = q.answerDistribution.reduce(
917
+ (s, a) => s + a.count,
918
+ 0,
919
+ )
920
+ return (
921
+ <div key={q.questionId}>
922
+ <div className="mb-2.5 flex items-start justify-between gap-2">
923
+ <p className="text-foreground text-[13px] font-medium leading-snug">
924
+ {q.question}
925
+ </p>
926
+ <span className="text-muted-foreground shrink-0 text-[11px] tabular-nums">
927
+ {totalForQuestion.toLocaleString()} responses
928
+ </span>
929
+ </div>
930
+ <div className="space-y-1.5">
931
+ {q.answerDistribution.slice(0, 6).map((a) => {
932
+ const pct =
933
+ totalForQuestion > 0
934
+ ? (a.count / totalForQuestion) * 100
935
+ : 0
936
+ return (
937
+ <div
938
+ key={a.answer}
939
+ className="group flex items-center gap-2.5"
940
+ >
941
+ <span
942
+ className="text-muted-foreground w-[120px] shrink-0 truncate text-right text-[12px]"
943
+ title={a.answer}
944
+ >
945
+ {a.answer}
946
+ </span>
947
+ <div className="bg-muted/50 relative h-5 flex-1 overflow-hidden rounded">
948
+ <div
949
+ className="absolute inset-y-0 left-0 rounded bg-indigo-500/20 transition-all duration-300"
950
+ style={{ width: `${Math.max(pct, 1)}%` }}
951
+ />
952
+ <div className="relative flex h-full items-center px-2">
953
+ <span className="text-foreground/70 text-[11px] font-medium tabular-nums">
954
+ {pct.toFixed(0)}%
955
+ </span>
956
+ </div>
957
+ </div>
958
+ <span className="text-muted-foreground w-10 shrink-0 text-right text-[11px] tabular-nums">
959
+ {a.count.toLocaleString()}
960
+ </span>
961
+ </div>
962
+ )
963
+ })}
964
+ {q.answerDistribution.length > 6 && (
965
+ <p className="text-muted-foreground/60 pl-[132px] text-[11px]">
966
+ +{q.answerDistribution.length - 6} more answers
967
+ </p>
968
+ )}
969
+ </div>
970
+ </div>
971
+ )
972
+ })}
973
+ </CardContent>
974
+ </Card>
975
+ )}
976
+
977
+ {/* ── Survey → Purchase Correlation ────────────────────────── */}
978
+ {hasSurveyCorrelation && (
979
+ <Card>
980
+ <CardHeader className="pb-3">
981
+ <div className="flex items-center gap-2.5">
982
+ <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
983
+ <TrendingUpIcon className="h-3.5 w-3.5" />
984
+ </div>
985
+ <div>
986
+ <CardTitle className="text-sm font-semibold">
987
+ Survey → Purchase Correlation
988
+ </CardTitle>
989
+ <CardDescription className="text-[11px]">
990
+ Which survey answers predict purchases? We match survey
991
+ respondents against the purchases table to find conversion
992
+ rates per answer.
993
+ </CardDescription>
994
+ </div>
995
+ </div>
996
+ </CardHeader>
997
+ <CardContent className="space-y-5">
998
+ {/* How it works */}
999
+ <p className="text-muted-foreground text-[12px] leading-relaxed">
1000
+ We look at every user who answered a survey and check if they
1001
+ ever made a purchase.
1002
+ <strong className="text-foreground"> Conversion</strong> =
1003
+ respondents who purchased / total respondents.
1004
+ <strong className="text-foreground"> Baseline</strong> =
1005
+ purchase rate of users who <em>never</em> took a survey, so you
1006
+ can see if survey-takers convert at a higher or lower rate.
1007
+ <strong className="text-foreground"> Revenue</strong> = total
1008
+ lifetime spend of respondents who purchased.
1009
+ </p>
1010
+
1011
+ {/* Headline stats */}
1012
+ {(() => {
1013
+ const sc = data.surveyCorrelation!
1014
+ const totalRespondents = sc.totalRespondents ?? 0
1015
+ const purchased = sc.respondentsWhoPurchased ?? 0
1016
+ const convRate = sc.overallConversionRate ?? 0
1017
+ const revenue = sc.totalRevenueFromRespondents ?? 0
1018
+ const baseline = sc.baselineConversionRate ?? 0
1019
+ const pre = sc.prePurchaseRespondents ?? 0
1020
+ const post = sc.postPurchaseRespondents ?? 0
1021
+ const never = sc.neverPurchasedRespondents ?? 0
1022
+
1023
+ return (
1024
+ <>
1025
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
1026
+ <div className="bg-muted/20 rounded-lg p-3">
1027
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
1028
+ Respondents
1029
+ </span>
1030
+ <span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
1031
+ {totalRespondents.toLocaleString()}
1032
+ </span>
1033
+ <span className="text-muted-foreground/70 text-[10px]">
1034
+ unique users who answered
1035
+ </span>
1036
+ </div>
1037
+ <div className="bg-muted/20 rounded-lg p-3">
1038
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
1039
+ Purchased
1040
+ </span>
1041
+ <span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
1042
+ {purchased.toLocaleString()}
1043
+ </span>
1044
+ <span className="text-muted-foreground/70 text-[10px]">
1045
+ of those also bought
1046
+ </span>
1047
+ </div>
1048
+ <div className="bg-muted/20 rounded-lg p-3">
1049
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
1050
+ Conversion
1051
+ </span>
1052
+ <span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
1053
+ {(convRate * 100).toFixed(1)}%
1054
+ </span>
1055
+ <span className="text-muted-foreground/70 text-[10px]">
1056
+ respondent → purchaser
1057
+ </span>
1058
+ </div>
1059
+ <div className="bg-muted/20 rounded-lg p-3">
1060
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
1061
+ Revenue
1062
+ </span>
1063
+ <span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
1064
+ {fmt$(revenue)}
1065
+ </span>
1066
+ <span className="text-muted-foreground/70 text-[10px]">
1067
+ lifetime spend of purchasers
1068
+ </span>
1069
+ </div>
1070
+ <div className="bg-muted/20 rounded-lg p-3">
1071
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
1072
+ Baseline
1073
+ </span>
1074
+ <span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
1075
+ {(baseline * 100).toFixed(1)}%
1076
+ </span>
1077
+ <span className="text-muted-foreground/70 text-[10px]">
1078
+ users who never took a survey
1079
+ </span>
1080
+ </div>
1081
+ </div>
1082
+
1083
+ {/* Pre/Post breakdown */}
1084
+ <div className="border-border/30 rounded-lg border px-4 py-3">
1085
+ <p className="text-muted-foreground mb-2 text-[11px]">
1086
+ Of the {purchased.toLocaleString()} purchasers, when did
1087
+ they first respond relative to their first purchase?
1088
+ </p>
1089
+ <div className="flex flex-wrap items-center gap-x-6 gap-y-2">
1090
+ <div className="flex items-center gap-2">
1091
+ <span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
1092
+ <span className="text-[12px]">
1093
+ Responded first, then bought:{' '}
1094
+ <span className="text-foreground font-semibold tabular-nums">
1095
+ {pre.toLocaleString()}
1096
+ </span>
1097
+ </span>
1098
+ </div>
1099
+ <div className="flex items-center gap-2">
1100
+ <span className="inline-block h-2 w-2 rounded-full bg-blue-500" />
1101
+ <span className="text-[12px]">
1102
+ Bought first, then responded:{' '}
1103
+ <span className="text-foreground font-semibold tabular-nums">
1104
+ {post.toLocaleString()}
1105
+ </span>
1106
+ </span>
1107
+ </div>
1108
+ <div className="flex items-center gap-2">
1109
+ <span className="inline-block h-2 w-2 rounded-full bg-gray-400" />
1110
+ <span className="text-[12px]">
1111
+ Responded but never bought:{' '}
1112
+ <span className="text-foreground font-semibold tabular-nums">
1113
+ {never.toLocaleString()}
1114
+ </span>
1115
+ </span>
1116
+ </div>
1117
+ </div>
1118
+ </div>
1119
+ </>
1120
+ )
1121
+ })()}
1122
+
1123
+ {/* Per-answer conversion grouped by question */}
1124
+ {(() => {
1125
+ const allRows = data.surveyCorrelation!.byQuestion ?? []
1126
+ if (allRows.length === 0) return null
1127
+
1128
+ const seen: Record<string, boolean> = {}
1129
+ const questionIds: string[] = []
1130
+ const answerCounts: Record<string, number> = {}
1131
+ for (const r of allRows) {
1132
+ if (!seen[r.questionId]) {
1133
+ seen[r.questionId] = true
1134
+ questionIds.push(r.questionId)
1135
+ answerCounts[r.questionId] = 0
1136
+ }
1137
+ answerCounts[r.questionId]!++
1138
+ }
1139
+
1140
+ const filteredQIds = questionIds.filter(
1141
+ (qId) => (answerCounts[qId] ?? 0) <= 10,
1142
+ )
1143
+ if (filteredQIds.length === 0) return null
1144
+
1145
+ return (
1146
+ <div className="space-y-6">
1147
+ <p className="text-muted-foreground text-[11px]">
1148
+ Per answer: of everyone who chose this option, what % went
1149
+ on to purchase?
1150
+ </p>
1151
+ {filteredQIds.map((qId) => {
1152
+ const answers = allRows
1153
+ .filter(
1154
+ (r) =>
1155
+ r.questionId === qId && r.answer !== '[free text]',
1156
+ )
1157
+ .sort(
1158
+ (a, b) => (b.respondents ?? 0) - (a.respondents ?? 0),
1159
+ )
1160
+ if (answers.length === 0) return null
1161
+ const title = answers[0]?.questionTitle ?? qId
1162
+ return (
1163
+ <div key={qId}>
1164
+ <p className="text-foreground mb-2 text-[13px] font-semibold leading-snug">
1165
+ {title}
1166
+ </p>
1167
+ <div className="space-y-1.5">
1168
+ {answers.map((row) => {
1169
+ const pct = (row.conversionRate ?? 0) * 100
1170
+ const purchased = row.purchasers ?? 0
1171
+ const responded = row.respondents ?? 0
1172
+ return (
1173
+ <div
1174
+ key={row.answer}
1175
+ className="group flex items-center gap-2.5"
1176
+ >
1177
+ <span
1178
+ className="text-muted-foreground w-[120px] shrink-0 truncate text-right text-[12px]"
1179
+ title={row.answer}
1180
+ >
1181
+ {row.answer}
1182
+ </span>
1183
+ <div className="bg-muted/50 relative h-5 flex-1 overflow-hidden rounded">
1184
+ <div
1185
+ className="absolute inset-y-0 left-0 rounded bg-emerald-500/20 transition-all duration-300"
1186
+ style={{ width: `${Math.max(pct, 1)}%` }}
1187
+ />
1188
+ <div className="relative flex h-full items-center px-2">
1189
+ <span className="text-foreground/70 text-[11px] font-medium tabular-nums">
1190
+ {pct.toFixed(1)}%
1191
+ </span>
1192
+ </div>
1193
+ </div>
1194
+ <span className="text-muted-foreground w-12 shrink-0 text-right text-[11px] tabular-nums">
1195
+ {purchased}/{responded}
1196
+ </span>
1197
+ </div>
1198
+ )
1199
+ })}
1200
+ </div>
1201
+ </div>
1202
+ )
1203
+ })}
1204
+ </div>
1205
+ )
1206
+ })()}
1207
+ </CardContent>
1208
+ </Card>
1209
+ )}
1210
+
1211
+ {/* ── Revenue Chart ─────────────────────────────────────────── */}
1212
+ {hasRevenueChart && (
1213
+ <Card className="overflow-hidden">
1214
+ <CardHeader className="pb-3">
1215
+ <div className="flex items-center gap-2.5">
1216
+ <TrendingUpIcon className="text-muted-foreground h-4 w-4" />
1217
+ <div>
1218
+ <CardTitle className="text-sm font-semibold">
1219
+ Daily Revenue
1220
+ </CardTitle>
1221
+ <CardDescription className="text-[11px]">
1222
+ {rangeLabel}
1223
+ </CardDescription>
1224
+ </div>
1225
+ </div>
1226
+ </CardHeader>
1227
+ <CardContent className="pb-3">
1228
+ <RevenueChart
1229
+ data={data.daily}
1230
+ previousData={data.previousDaily}
1231
+ />
1232
+ </CardContent>
1233
+ </Card>
1234
+ )}
1235
+
1236
+ {/* ── Products + Countries ──────────────────────────────────── */}
1237
+ {(hasByProduct || hasByCountry) && (
1238
+ <div className="grid gap-3 md:grid-cols-2">
1239
+ {/* By Product */}
1240
+ {hasByProduct && (
1241
+ <Card>
1242
+ <CardHeader className="pb-3">
1243
+ <div className="flex items-center gap-2.5">
1244
+ <ShoppingCartIcon className="text-muted-foreground h-4 w-4" />
1245
+ <div>
1246
+ <CardTitle className="text-sm font-semibold">
1247
+ By Product
1248
+ </CardTitle>
1249
+ <CardDescription className="text-[11px]">
1250
+ Revenue breakdown
1251
+ </CardDescription>
1252
+ </div>
1253
+ </div>
1254
+ </CardHeader>
1255
+ <CardContent>
1256
+ <div className="flex flex-col gap-3">
1257
+ {data.byProduct.map((p) => {
1258
+ const pct =
1259
+ data.summary.totalRevenue > 0
1260
+ ? (p.revenue / data.summary.totalRevenue) * 100
1261
+ : 0
1262
+ return (
1263
+ <div key={p.productId} className="space-y-1.5">
1264
+ <div className="flex items-center justify-between gap-2">
1265
+ <span className="truncate text-sm font-medium">
1266
+ {p.productName}
1267
+ </span>
1268
+ <span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
1269
+ {fmt$(p.revenue)}
1270
+ </span>
1271
+ </div>
1272
+ <div className="flex items-center gap-2">
1273
+ <div className="bg-muted/50 relative h-1.5 flex-1 overflow-hidden rounded-full">
1274
+ <div
1275
+ className="absolute inset-y-0 left-0 rounded-full bg-emerald-500/60"
1276
+ style={{ width: `${Math.min(pct, 100)}%` }}
1277
+ />
1278
+ </div>
1279
+ <span className="text-muted-foreground text-[11px] tabular-nums">
1280
+ {p.count}
1281
+ </span>
1282
+ </div>
1283
+ </div>
1284
+ )
1285
+ })}
1286
+ </div>
1287
+ </CardContent>
1288
+ </Card>
1289
+ )}
1290
+
1291
+ {/* By Country */}
1292
+ {hasByCountry && (
1293
+ <Card className="overflow-hidden">
1294
+ <CardHeader className="pb-3">
1295
+ <div className="flex items-center gap-2.5">
1296
+ <GlobeIcon className="text-muted-foreground h-4 w-4" />
1297
+ <div>
1298
+ <CardTitle className="text-sm font-semibold">
1299
+ By Country
1300
+ </CardTitle>
1301
+ <CardDescription className="text-[11px]">
1302
+ Top 10 by revenue
1303
+ </CardDescription>
1304
+ </div>
1305
+ </div>
1306
+ </CardHeader>
1307
+ <CardContent className="pb-3">
1308
+ <CountryChart data={data.byCountry} />
1309
+ </CardContent>
1310
+ </Card>
1311
+ )}
1312
+ </div>
1313
+ )}
1314
+
1315
+ {/* ── Full Site Video Table ─────────────────────────────────── */}
1316
+ {hasMux && data.mux!.topVideos.length > 5 && (
1317
+ <Card>
1318
+ <CardHeader className="pb-3">
1319
+ <div className="flex items-center gap-2.5">
1320
+ <FilmIcon className="text-muted-foreground h-4 w-4" />
1321
+ <div>
1322
+ <CardTitle className="text-sm font-semibold">
1323
+ All Site Videos
1324
+ </CardTitle>
1325
+ <CardDescription className="text-[11px]">
1326
+ By watch time (Mux)
1327
+ </CardDescription>
1328
+ </div>
1329
+ </div>
1330
+ </CardHeader>
1331
+ <CardContent>
1332
+ <div className="overflow-x-auto">
1333
+ <table className="w-full">
1334
+ <thead>
1335
+ <tr className="text-muted-foreground border-border/50 border-b text-left text-[11px] uppercase tracking-wider">
1336
+ <th className="pb-2.5 pr-4 font-medium">#</th>
1337
+ <th className="pb-2.5 pr-4 font-medium">Title</th>
1338
+ <th className="pb-2.5 pr-4 text-right font-medium">
1339
+ Watch Time
1340
+ </th>
1341
+ <th className="pb-2.5 text-right font-medium">Views</th>
1342
+ </tr>
1343
+ </thead>
1344
+ <tbody className="divide-border/30 divide-y">
1345
+ {data.mux!.topVideos.slice(0, 10).map((v, i) => (
1346
+ <tr key={v.title} className="group">
1347
+ <td className="text-muted-foreground py-2.5 pr-4 text-sm tabular-nums">
1348
+ {i + 1}
1349
+ </td>
1350
+ <td className="max-w-[300px] truncate py-2.5 pr-4 text-sm font-medium">
1351
+ {v.title}
1352
+ </td>
1353
+ <td className="text-foreground py-2.5 pr-4 text-right text-sm font-semibold tabular-nums">
1354
+ {fmtWatchMs(v.playingTimeMs)}
1355
+ </td>
1356
+ <td className="text-muted-foreground py-2.5 text-right text-sm tabular-nums">
1357
+ {fmtK(v.views)}
1358
+ </td>
1359
+ </tr>
1360
+ ))}
1361
+ </tbody>
1362
+ </table>
1363
+ </div>
1364
+ </CardContent>
1365
+ </Card>
1366
+ )}
1367
+
1368
+ {/* ── Shortlinks ────────────────────────────────────────────── */}
1369
+ {hasShortlinks && (
1370
+ <ShortlinksCard
1371
+ shortlinks={data.shortlinks}
1372
+ totalClicks={totalClicks}
1373
+ />
1374
+ )}
1375
+
1376
+ {/* ── Recent Purchases ──────────────────────────────────────── */}
1377
+ {hasRecentPurchases && (
1378
+ <Card>
1379
+ <CardHeader className="pb-3">
1380
+ <div className="flex items-center justify-between">
1381
+ <div className="flex items-center gap-2.5">
1382
+ <div className="bg-muted flex h-6 w-6 items-center justify-center rounded-md">
1383
+ <ShoppingCartIcon className="text-muted-foreground h-3.5 w-3.5" />
1384
+ </div>
1385
+ <div>
1386
+ <CardTitle className="text-sm font-semibold">
1387
+ Team Purchases
1388
+ </CardTitle>
1389
+ <CardDescription className="text-[11px]">
1390
+ Multi-seat deals · sorted by amount
1391
+ </CardDescription>
1392
+ </div>
1393
+ </div>
1394
+ <span className="rounded-full bg-red-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-red-600 dark:text-red-400">
1395
+ OH YEAH
1396
+ </span>
1397
+ </div>
1398
+ </CardHeader>
1399
+ <CardContent>
1400
+ <div className="overflow-x-auto">
1401
+ <table className="w-full">
1402
+ <thead>
1403
+ <tr className="text-muted-foreground border-border/50 border-b text-left text-[11px] uppercase tracking-wider">
1404
+ <th className="pb-2.5 pr-4 text-right font-medium">
1405
+ Amount
1406
+ </th>
1407
+ <th className="pb-2.5 pr-4 text-right font-medium">
1408
+ Seats
1409
+ </th>
1410
+ <th className="pb-2.5 pr-4 font-medium">Product</th>
1411
+ <th className="hidden pb-2.5 pr-4 font-medium sm:table-cell">
1412
+ Buyer
1413
+ </th>
1414
+ <th className="hidden pb-2.5 pr-4 font-medium sm:table-cell">
1415
+ Country
1416
+ </th>
1417
+ <th className="pb-2.5 pr-4 font-medium">When</th>
1418
+ </tr>
1419
+ </thead>
1420
+ <tbody className="divide-border/30 divide-y">
1421
+ {data.recentPurchases.map((p) => (
1422
+ <tr key={p.id}>
1423
+ <td
1424
+ className={`py-2.5 pr-4 text-right text-sm font-semibold tabular-nums ${
1425
+ p.totalAmount > 0
1426
+ ? 'text-emerald-600 dark:text-emerald-400'
1427
+ : 'text-muted-foreground'
1428
+ }`}
1429
+ >
1430
+ {p.totalAmount > 0 ? fmt$(p.totalAmount) : 'Free'}
1431
+ </td>
1432
+ <td className="py-2.5 pr-4 text-right text-sm tabular-nums">
1433
+ {p.seats ?? '—'}
1434
+ </td>
1435
+ <td className="max-w-[200px] truncate py-2.5 pr-4 text-sm font-medium">
1436
+ {p.productName}
1437
+ </td>
1438
+ <td className="text-muted-foreground hidden max-w-[150px] truncate py-2.5 pr-4 text-sm sm:table-cell">
1439
+ {p.userName ?? p.userEmail ?? '—'}
1440
+ </td>
1441
+ <td className="text-muted-foreground hidden py-2.5 pr-4 text-sm sm:table-cell">
1442
+ {p.country
1443
+ ? `${countryFlag(p.country)} ${p.country}`
1444
+ : '—'}
1445
+ </td>
1446
+ <td className="text-muted-foreground whitespace-nowrap py-2.5 pr-4 text-sm">
1447
+ {fmtAgo(p.createdAt)}
1448
+ </td>
1449
+ </tr>
1450
+ ))}
1451
+ </tbody>
1452
+ </table>
1453
+ </div>
1454
+ </CardContent>
1455
+ </Card>
1456
+ )}
1457
+ </div>
1458
+ </div>
1459
+ )
1460
+ }