@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.
- package/LICENSE +21 -0
- package/dist/api/index.d.ts +158 -0
- package/dist/api/index.js +317 -0
- package/dist/api/index.js.map +1 -0
- package/dist/catalog.d.ts +14 -0
- package/dist/catalog.js +209 -0
- package/dist/catalog.js.map +1 -0
- package/dist/components/index.d.ts +172 -0
- package/dist/components/index.js +1258 -0
- package/dist/components/index.js.map +1 -0
- package/dist/engine.d.ts +20 -0
- package/dist/engine.js +350 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +353 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/database.d.ts +79 -0
- package/dist/providers/database.js +533 -0
- package/dist/providers/database.js.map +1 -0
- package/dist/providers/derived.d.ts +45 -0
- package/dist/providers/derived.js +32 -0
- package/dist/providers/derived.js.map +1 -0
- package/dist/providers/ga4.d.ts +43 -0
- package/dist/providers/ga4.js +220 -0
- package/dist/providers/ga4.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.js +1239 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/mux.d.ts +103 -0
- package/dist/providers/mux.js +241 -0
- package/dist/providers/mux.js.map +1 -0
- package/dist/providers/survey.d.ts +102 -0
- package/dist/providers/survey.js +233 -0
- package/dist/providers/survey.js.map +1 -0
- package/dist/types.d.ts +303 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +101 -0
- package/src/api/catalog-handler.ts +321 -0
- package/src/api/index.ts +4 -0
- package/src/api/token-handler.ts +71 -0
- package/src/catalog.ts +223 -0
- package/src/components/country-chart.tsx +114 -0
- package/src/components/index.ts +5 -0
- package/src/components/omnibus-dashboard.tsx +1460 -0
- package/src/components/revenue-chart.tsx +251 -0
- package/src/components/use-chart-colors.ts +75 -0
- package/src/engine.ts +201 -0
- package/src/index.ts +7 -0
- package/src/providers/database.ts +795 -0
- package/src/providers/derived.ts +79 -0
- package/src/providers/ga4.ts +173 -0
- package/src/providers/index.ts +44 -0
- package/src/providers/mux.ts +438 -0
- package/src/providers/survey.ts +487 -0
- 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
|
+
}
|