@coursebuilder/analytics 1.1.0 → 1.1.2-canary.0.0c9beb2ae
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/dist/api/index.d.ts +22 -2
- package/dist/api/index.js +40 -5
- package/dist/api/index.js.map +1 -1
- package/dist/catalog.d.ts +1 -1
- package/dist/catalog.js +43 -1
- package/dist/catalog.js.map +1 -1
- package/dist/components/index.d.ts +46 -1
- package/dist/components/index.js +200 -8
- package/dist/components/index.js.map +1 -1
- package/dist/engine.js +94 -6
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +94 -6
- package/dist/index.js.map +1 -1
- package/dist/providers/database.d.ts +174 -2
- package/dist/providers/database.js +711 -20
- package/dist/providers/database.js.map +1 -1
- package/dist/providers/index.js +713 -22
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/survey.d.ts +1 -1
- package/dist/providers/survey.js +2 -2
- package/dist/providers/survey.js.map +1 -1
- package/dist/types.d.ts +189 -3
- package/package.json +7 -5
- package/src/api/catalog-handler.ts +44 -2
- package/src/api/token-handler.ts +3 -2
- package/src/catalog.ts +49 -1
- package/src/components/omnibus-dashboard.tsx +340 -6
- package/src/engine.ts +69 -8
- package/src/providers/attribution-recovery.test.ts +63 -0
- package/src/providers/attribution-recovery.ts +97 -0
- package/src/providers/database.ts +880 -42
- package/src/providers/survey.ts +3 -1
- package/src/types.ts +215 -2
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
PlayIcon,
|
|
18
18
|
ShoppingCartIcon,
|
|
19
19
|
TrendingUpIcon,
|
|
20
|
+
RouteIcon,
|
|
20
21
|
} from 'lucide-react'
|
|
21
22
|
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
|
22
23
|
|
|
@@ -28,7 +29,7 @@ import {
|
|
|
28
29
|
CardTitle,
|
|
29
30
|
} from '@coursebuilder/ui'
|
|
30
31
|
|
|
31
|
-
import type { AnalyticsRange } from '../types'
|
|
32
|
+
import type { AnalyticsRange, TrafficBreakdownRow } from '../types'
|
|
32
33
|
import { CountryChart } from './country-chart'
|
|
33
34
|
import { RevenueChart } from './revenue-chart'
|
|
34
35
|
|
|
@@ -106,6 +107,11 @@ export interface DashboardData {
|
|
|
106
107
|
totalUsers: number
|
|
107
108
|
newUsers: number
|
|
108
109
|
pageviews: number
|
|
110
|
+
deviceCategories?: Array<TrafficBreakdownRow & { deviceCategory: string }>
|
|
111
|
+
operatingSystems?: Array<TrafficBreakdownRow & { operatingSystem: string }>
|
|
112
|
+
screenResolutions?: Array<
|
|
113
|
+
TrafficBreakdownRow & { screenResolution: string }
|
|
114
|
+
>
|
|
109
115
|
} | null
|
|
110
116
|
attributionCoverage: {
|
|
111
117
|
totalRevenue: number
|
|
@@ -113,6 +119,16 @@ export interface DashboardData {
|
|
|
113
119
|
unattributedRevenue: number
|
|
114
120
|
attributionRate: number
|
|
115
121
|
totalPurchases: number
|
|
122
|
+
quality?: Record<
|
|
123
|
+
'strong' | 'medium' | 'weak' | 'unknown',
|
|
124
|
+
{
|
|
125
|
+
lane: 'strong' | 'medium' | 'weak' | 'unknown'
|
|
126
|
+
purchases: number
|
|
127
|
+
revenue: number
|
|
128
|
+
revenuePercent: number
|
|
129
|
+
notes: string[]
|
|
130
|
+
}
|
|
131
|
+
>
|
|
116
132
|
} | null
|
|
117
133
|
mux: VideoDashboardData | null
|
|
118
134
|
muxThumbnails: Record<string, string>
|
|
@@ -125,6 +141,29 @@ export interface DashboardData {
|
|
|
125
141
|
answerDistribution: { answer: string; count: number }[]
|
|
126
142
|
}[]
|
|
127
143
|
| null
|
|
144
|
+
valuePaths?: {
|
|
145
|
+
contacts: number
|
|
146
|
+
events: number
|
|
147
|
+
intents: number
|
|
148
|
+
completedIntents: number
|
|
149
|
+
pendingIntents: number
|
|
150
|
+
blockedIntents: number
|
|
151
|
+
answerEvents: number
|
|
152
|
+
dripEvents: number
|
|
153
|
+
enteredEvents: number
|
|
154
|
+
participantsWithAnswerClicks: number
|
|
155
|
+
participantsWithNoAnswerClicks: number
|
|
156
|
+
terminalParticipants: number
|
|
157
|
+
answerOptions: {
|
|
158
|
+
key: string
|
|
159
|
+
step: string
|
|
160
|
+
optionValue: string
|
|
161
|
+
count: number
|
|
162
|
+
}[]
|
|
163
|
+
answerSteps: { step: string; count: number }[]
|
|
164
|
+
terminalSteps: { emailResourceId: string; count: number }[]
|
|
165
|
+
notes: string[]
|
|
166
|
+
} | null
|
|
128
167
|
surveyCorrelation: {
|
|
129
168
|
totalRespondents: number
|
|
130
169
|
respondentsWhoPurchased: number
|
|
@@ -594,6 +633,133 @@ function ShortlinksCard({
|
|
|
594
633
|
)
|
|
595
634
|
}
|
|
596
635
|
|
|
636
|
+
// ─── Traffic Breakdowns ──────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
function fmtPct(v: number): string {
|
|
639
|
+
return `${v.toFixed(1)}%`
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function TrafficBreakdownTable<T extends TrafficBreakdownRow>({
|
|
643
|
+
title,
|
|
644
|
+
label,
|
|
645
|
+
rows,
|
|
646
|
+
getName,
|
|
647
|
+
}: {
|
|
648
|
+
title: string
|
|
649
|
+
label: string
|
|
650
|
+
rows: T[]
|
|
651
|
+
getName: (row: T) => string
|
|
652
|
+
}) {
|
|
653
|
+
if (!rows.length) return null
|
|
654
|
+
|
|
655
|
+
return (
|
|
656
|
+
<section className="min-w-0">
|
|
657
|
+
<div className="mb-2 flex items-baseline justify-between gap-2">
|
|
658
|
+
<h3 className="text-sm font-semibold">{title}</h3>
|
|
659
|
+
<span className="text-muted-foreground text-[11px]">
|
|
660
|
+
Top {rows.length}
|
|
661
|
+
</span>
|
|
662
|
+
</div>
|
|
663
|
+
<div className="overflow-x-auto rounded-xl border border-border/50">
|
|
664
|
+
<table className="w-full min-w-[440px] text-left text-sm">
|
|
665
|
+
<thead className="bg-muted/30 text-muted-foreground text-[11px] uppercase tracking-wide">
|
|
666
|
+
<tr>
|
|
667
|
+
<th className="px-3 py-2 font-medium">{label}</th>
|
|
668
|
+
<th className="px-3 py-2 text-right font-medium">Sessions</th>
|
|
669
|
+
<th className="px-3 py-2 text-right font-medium">Users</th>
|
|
670
|
+
<th className="px-3 py-2 text-right font-medium">In rows</th>
|
|
671
|
+
<th className="px-3 py-2 text-right font-medium">Of traffic</th>
|
|
672
|
+
</tr>
|
|
673
|
+
</thead>
|
|
674
|
+
<tbody>
|
|
675
|
+
{rows.map((row) => (
|
|
676
|
+
<tr
|
|
677
|
+
key={getName(row)}
|
|
678
|
+
className="border-t border-border/40 tabular-nums"
|
|
679
|
+
>
|
|
680
|
+
<td className="max-w-[220px] truncate px-3 py-2 font-medium">
|
|
681
|
+
{getName(row)}
|
|
682
|
+
</td>
|
|
683
|
+
<td className="px-3 py-2 text-right">
|
|
684
|
+
{row.sessions.toLocaleString()}
|
|
685
|
+
</td>
|
|
686
|
+
<td className="px-3 py-2 text-right">
|
|
687
|
+
{row.users.toLocaleString()}
|
|
688
|
+
</td>
|
|
689
|
+
<td className="px-3 py-2 text-right">
|
|
690
|
+
{fmtPct(row.sessionPercent)}
|
|
691
|
+
</td>
|
|
692
|
+
<td className="px-3 py-2 text-right">
|
|
693
|
+
{fmtPct(row.trafficSessionPercent)}
|
|
694
|
+
</td>
|
|
695
|
+
</tr>
|
|
696
|
+
))}
|
|
697
|
+
</tbody>
|
|
698
|
+
</table>
|
|
699
|
+
</div>
|
|
700
|
+
</section>
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function TrafficBreakdownsCard({
|
|
705
|
+
traffic,
|
|
706
|
+
}: {
|
|
707
|
+
traffic: NonNullable<DashboardData['traffic']>
|
|
708
|
+
}) {
|
|
709
|
+
const deviceCategories = traffic.deviceCategories ?? []
|
|
710
|
+
const operatingSystems = traffic.operatingSystems ?? []
|
|
711
|
+
const screenResolutions = traffic.screenResolutions ?? []
|
|
712
|
+
|
|
713
|
+
if (
|
|
714
|
+
deviceCategories.length === 0 &&
|
|
715
|
+
operatingSystems.length === 0 &&
|
|
716
|
+
screenResolutions.length === 0
|
|
717
|
+
) {
|
|
718
|
+
return null
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return (
|
|
722
|
+
<Card>
|
|
723
|
+
<CardHeader>
|
|
724
|
+
<div className="flex items-center gap-2.5">
|
|
725
|
+
<GlobeIcon className="text-muted-foreground h-4 w-4" />
|
|
726
|
+
<div>
|
|
727
|
+
<CardTitle className="text-sm font-semibold">
|
|
728
|
+
Traffic Details
|
|
729
|
+
</CardTitle>
|
|
730
|
+
<CardDescription className="text-[11px]">
|
|
731
|
+
Device, OS, and screen share. In rows is share inside the table.
|
|
732
|
+
Of traffic is share of total sessions.
|
|
733
|
+
</CardDescription>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
</CardHeader>
|
|
737
|
+
<CardContent>
|
|
738
|
+
<div className="grid gap-4 xl:grid-cols-3">
|
|
739
|
+
<TrafficBreakdownTable
|
|
740
|
+
title="Device category"
|
|
741
|
+
label="Device"
|
|
742
|
+
rows={deviceCategories}
|
|
743
|
+
getName={(row) => row.deviceCategory}
|
|
744
|
+
/>
|
|
745
|
+
<TrafficBreakdownTable
|
|
746
|
+
title="Operating systems"
|
|
747
|
+
label="OS"
|
|
748
|
+
rows={operatingSystems.slice(0, 10)}
|
|
749
|
+
getName={(row) => row.operatingSystem}
|
|
750
|
+
/>
|
|
751
|
+
<TrafficBreakdownTable
|
|
752
|
+
title="Screen resolutions"
|
|
753
|
+
label="Resolution"
|
|
754
|
+
rows={screenResolutions.slice(0, 10)}
|
|
755
|
+
getName={(row) => row.screenResolution}
|
|
756
|
+
/>
|
|
757
|
+
</div>
|
|
758
|
+
</CardContent>
|
|
759
|
+
</Card>
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
|
|
597
763
|
// ─── Range Selector ───────────────────────────────────────────────────────────
|
|
598
764
|
|
|
599
765
|
const RANGES: { value: AnalyticsRange; label: string }[] = [
|
|
@@ -661,6 +827,7 @@ export function OmnibusDashboard({
|
|
|
661
827
|
const hasSurveys =
|
|
662
828
|
data.surveySegments != null && data.surveySegments.length > 0
|
|
663
829
|
const hasSurveyCorrelation = data.surveyCorrelation != null
|
|
830
|
+
const hasValuePaths = data.valuePaths != null && data.valuePaths.contacts > 0
|
|
664
831
|
const hasShortlinks = data.shortlinks.length > 0
|
|
665
832
|
|
|
666
833
|
return (
|
|
@@ -672,7 +839,7 @@ export function OmnibusDashboard({
|
|
|
672
839
|
Analytics
|
|
673
840
|
</h1>
|
|
674
841
|
<p className="text-muted-foreground truncate text-[13px]">
|
|
675
|
-
Revenue · Attribution · Video · Traffic · Surveys
|
|
842
|
+
Revenue · Attribution · Video · Traffic · Surveys, {rangeLabel}
|
|
676
843
|
</p>
|
|
677
844
|
</div>
|
|
678
845
|
<div className="flex shrink-0 items-center gap-2">
|
|
@@ -753,6 +920,14 @@ export function OmnibusDashboard({
|
|
|
753
920
|
icon={MousePointerClickIcon}
|
|
754
921
|
/>
|
|
755
922
|
)}
|
|
923
|
+
{hasValuePaths && (
|
|
924
|
+
<Stat
|
|
925
|
+
label="Value Paths"
|
|
926
|
+
value={`${data.valuePaths!.terminalParticipants}/${data.valuePaths!.contacts}`}
|
|
927
|
+
sub={`${data.valuePaths!.answerEvents} answers · ${data.valuePaths!.dripEvents} drips`}
|
|
928
|
+
icon={RouteIcon}
|
|
929
|
+
/>
|
|
930
|
+
)}
|
|
756
931
|
{hasSurveys && (
|
|
757
932
|
<Stat
|
|
758
933
|
label="Surveys"
|
|
@@ -763,6 +938,9 @@ export function OmnibusDashboard({
|
|
|
763
938
|
)}
|
|
764
939
|
</div>
|
|
765
940
|
|
|
941
|
+
{/* ── Traffic Details ──────────────────────────────────────── */}
|
|
942
|
+
{hasTraffic && <TrafficBreakdownsCard traffic={data.traffic!} />}
|
|
943
|
+
|
|
766
944
|
{/* ── Video Performance ────────────────────────────────────── */}
|
|
767
945
|
{hasMux && data.mux!.topVideos.length > 0 && (
|
|
768
946
|
<TopVideosCard
|
|
@@ -821,7 +999,7 @@ export function OmnibusDashboard({
|
|
|
821
999
|
<span className="text-foreground mt-1 block text-xl font-bold tabular-nums">
|
|
822
1000
|
{signupCount > 0
|
|
823
1001
|
? `${((purchaseAttrCount / signupCount) * 100).toFixed(1)}%`
|
|
824
|
-
: '
|
|
1002
|
+
: '-'}
|
|
825
1003
|
</span>
|
|
826
1004
|
</div>
|
|
827
1005
|
</div>
|
|
@@ -879,10 +1057,166 @@ export function OmnibusDashboard({
|
|
|
879
1057
|
</span>
|
|
880
1058
|
</div>
|
|
881
1059
|
</div>
|
|
1060
|
+
{data.attributionCoverage!.quality && (
|
|
1061
|
+
<div className="mt-4 grid gap-2 sm:grid-cols-4">
|
|
1062
|
+
{(['strong', 'medium', 'weak', 'unknown'] as const).map(
|
|
1063
|
+
(lane) => {
|
|
1064
|
+
const item = data.attributionCoverage!.quality![lane]
|
|
1065
|
+
return (
|
|
1066
|
+
<div
|
|
1067
|
+
key={lane}
|
|
1068
|
+
className="rounded-lg border border-border/40 p-3"
|
|
1069
|
+
>
|
|
1070
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
1071
|
+
{lane}
|
|
1072
|
+
</span>
|
|
1073
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
1074
|
+
{fmt$(item.revenue)}
|
|
1075
|
+
</span>
|
|
1076
|
+
<span className="text-muted-foreground text-[11px]">
|
|
1077
|
+
{item.purchases.toLocaleString()} purchases ·{' '}
|
|
1078
|
+
{(item.revenuePercent * 100).toFixed(1)}%
|
|
1079
|
+
</span>
|
|
1080
|
+
</div>
|
|
1081
|
+
)
|
|
1082
|
+
},
|
|
1083
|
+
)}
|
|
1084
|
+
</div>
|
|
1085
|
+
)}
|
|
882
1086
|
</CardContent>
|
|
883
1087
|
</Card>
|
|
884
1088
|
)}
|
|
885
1089
|
|
|
1090
|
+
{/* ── Value Paths ──────────────────────────────────────────── */}
|
|
1091
|
+
{hasValuePaths && (
|
|
1092
|
+
<Card>
|
|
1093
|
+
<CardHeader className="pb-3">
|
|
1094
|
+
<div className="flex items-center gap-2.5">
|
|
1095
|
+
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-sky-500/10 text-sky-500">
|
|
1096
|
+
<RouteIcon className="h-3.5 w-3.5" />
|
|
1097
|
+
</div>
|
|
1098
|
+
<div>
|
|
1099
|
+
<CardTitle className="text-sm font-semibold">
|
|
1100
|
+
Value Paths
|
|
1101
|
+
</CardTitle>
|
|
1102
|
+
<CardDescription className="text-[11px]">
|
|
1103
|
+
Progression, answer clicks, drip fallback, and terminal
|
|
1104
|
+
completion
|
|
1105
|
+
</CardDescription>
|
|
1106
|
+
</div>
|
|
1107
|
+
</div>
|
|
1108
|
+
</CardHeader>
|
|
1109
|
+
<CardContent className="space-y-5">
|
|
1110
|
+
{(() => {
|
|
1111
|
+
const vp = data.valuePaths!
|
|
1112
|
+
const completionRate =
|
|
1113
|
+
vp.contacts > 0
|
|
1114
|
+
? (vp.terminalParticipants / vp.contacts) * 100
|
|
1115
|
+
: 0
|
|
1116
|
+
const clickRate =
|
|
1117
|
+
vp.contacts > 0
|
|
1118
|
+
? (vp.participantsWithAnswerClicks / vp.contacts) * 100
|
|
1119
|
+
: 0
|
|
1120
|
+
return (
|
|
1121
|
+
<>
|
|
1122
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
|
|
1123
|
+
<div className="bg-muted/20 rounded-lg p-3">
|
|
1124
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
1125
|
+
Contacts
|
|
1126
|
+
</span>
|
|
1127
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
1128
|
+
{vp.contacts.toLocaleString()}
|
|
1129
|
+
</span>
|
|
1130
|
+
</div>
|
|
1131
|
+
<div className="bg-muted/20 rounded-lg p-3">
|
|
1132
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
1133
|
+
Terminal
|
|
1134
|
+
</span>
|
|
1135
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
1136
|
+
{vp.terminalParticipants.toLocaleString()}
|
|
1137
|
+
</span>
|
|
1138
|
+
<span className="text-muted-foreground/70 text-[10px]">
|
|
1139
|
+
{completionRate.toFixed(0)}% completed
|
|
1140
|
+
</span>
|
|
1141
|
+
</div>
|
|
1142
|
+
<div className="bg-muted/20 rounded-lg p-3">
|
|
1143
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
1144
|
+
Answered
|
|
1145
|
+
</span>
|
|
1146
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
1147
|
+
{vp.participantsWithAnswerClicks.toLocaleString()}
|
|
1148
|
+
</span>
|
|
1149
|
+
<span className="text-muted-foreground/70 text-[10px]">
|
|
1150
|
+
{clickRate.toFixed(0)}% clicked
|
|
1151
|
+
</span>
|
|
1152
|
+
</div>
|
|
1153
|
+
<div className="bg-muted/20 rounded-lg p-3">
|
|
1154
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
1155
|
+
Drips
|
|
1156
|
+
</span>
|
|
1157
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
1158
|
+
{vp.dripEvents.toLocaleString()}
|
|
1159
|
+
</span>
|
|
1160
|
+
</div>
|
|
1161
|
+
<div className="bg-muted/20 rounded-lg p-3">
|
|
1162
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
1163
|
+
Blocked
|
|
1164
|
+
</span>
|
|
1165
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
1166
|
+
{vp.blockedIntents.toLocaleString()}
|
|
1167
|
+
</span>
|
|
1168
|
+
</div>
|
|
1169
|
+
</div>
|
|
1170
|
+
|
|
1171
|
+
{vp.answerOptions.length > 0 && (
|
|
1172
|
+
<div className="space-y-2">
|
|
1173
|
+
<p className="text-muted-foreground text-[11px]">
|
|
1174
|
+
Top answer choices by captured answer event
|
|
1175
|
+
</p>
|
|
1176
|
+
<div className="space-y-1.5">
|
|
1177
|
+
{vp.answerOptions.slice(0, 8).map((answer) => {
|
|
1178
|
+
const pct =
|
|
1179
|
+
vp.answerEvents > 0
|
|
1180
|
+
? (answer.count / vp.answerEvents) * 100
|
|
1181
|
+
: 0
|
|
1182
|
+
return (
|
|
1183
|
+
<div
|
|
1184
|
+
key={answer.key}
|
|
1185
|
+
className="flex items-center gap-2.5"
|
|
1186
|
+
>
|
|
1187
|
+
<span
|
|
1188
|
+
className="text-muted-foreground w-[140px] shrink-0 truncate text-right text-[12px]"
|
|
1189
|
+
title={answer.key}
|
|
1190
|
+
>
|
|
1191
|
+
{answer.optionValue}
|
|
1192
|
+
</span>
|
|
1193
|
+
<div className="bg-muted/50 relative h-5 flex-1 overflow-hidden rounded">
|
|
1194
|
+
<div
|
|
1195
|
+
className="absolute inset-y-0 left-0 rounded bg-sky-500/20"
|
|
1196
|
+
style={{ width: `${Math.max(pct, 1)}%` }}
|
|
1197
|
+
/>
|
|
1198
|
+
<div className="relative flex h-full items-center px-2">
|
|
1199
|
+
<span className="text-foreground/70 text-[11px] font-medium tabular-nums">
|
|
1200
|
+
{pct.toFixed(0)}%
|
|
1201
|
+
</span>
|
|
1202
|
+
</div>
|
|
1203
|
+
</div>
|
|
1204
|
+
<span className="text-muted-foreground w-10 shrink-0 text-right text-[11px] tabular-nums">
|
|
1205
|
+
{answer.count.toLocaleString()}
|
|
1206
|
+
</span>
|
|
1207
|
+
</div>
|
|
1208
|
+
)
|
|
1209
|
+
})}
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
)}
|
|
1213
|
+
</>
|
|
1214
|
+
)
|
|
1215
|
+
})()}
|
|
1216
|
+
</CardContent>
|
|
1217
|
+
</Card>
|
|
1218
|
+
)}
|
|
1219
|
+
|
|
886
1220
|
{/* ── Survey Segments ──────────────────────────────────────── */}
|
|
887
1221
|
{hasSurveys && (
|
|
888
1222
|
<Card>
|
|
@@ -1430,18 +1764,18 @@ export function OmnibusDashboard({
|
|
|
1430
1764
|
{p.totalAmount > 0 ? fmt$(p.totalAmount) : 'Free'}
|
|
1431
1765
|
</td>
|
|
1432
1766
|
<td className="py-2.5 pr-4 text-right text-sm tabular-nums">
|
|
1433
|
-
{p.seats ?? '
|
|
1767
|
+
{p.seats ?? '-'}
|
|
1434
1768
|
</td>
|
|
1435
1769
|
<td className="max-w-[200px] truncate py-2.5 pr-4 text-sm font-medium">
|
|
1436
1770
|
{p.productName}
|
|
1437
1771
|
</td>
|
|
1438
1772
|
<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 ?? '
|
|
1773
|
+
{p.userName ?? p.userEmail ?? '-'}
|
|
1440
1774
|
</td>
|
|
1441
1775
|
<td className="text-muted-foreground hidden py-2.5 pr-4 text-sm sm:table-cell">
|
|
1442
1776
|
{p.country
|
|
1443
1777
|
? `${countryFlag(p.country)} ${p.country}`
|
|
1444
|
-
: '
|
|
1778
|
+
: '-'}
|
|
1445
1779
|
</td>
|
|
1446
1780
|
<td className="text-muted-foreground whitespace-nowrap py-2.5 pr-4 text-sm">
|
|
1447
1781
|
{fmtAgo(p.createdAt)}
|
package/src/engine.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
QueryResult,
|
|
6
6
|
SurfaceMap,
|
|
7
7
|
SurfaceName,
|
|
8
|
+
TrafficAnalyticsRange,
|
|
8
9
|
} from './types'
|
|
9
10
|
|
|
10
11
|
export type AnalyticsProvider = Record<
|
|
@@ -14,7 +15,7 @@ export type AnalyticsProvider = Record<
|
|
|
14
15
|
|
|
15
16
|
export type ProviderMap = Record<string, AnalyticsProvider>
|
|
16
17
|
|
|
17
|
-
function toGA4Range(range: AnalyticsRange):
|
|
18
|
+
function toGA4Range(range: AnalyticsRange): TrafficAnalyticsRange {
|
|
18
19
|
if (range === 'all') return '90d'
|
|
19
20
|
return range
|
|
20
21
|
}
|
|
@@ -27,9 +28,10 @@ function toYouTubeRange(range: AnalyticsRange): '24h' | '7d' | '30d' | '90d' {
|
|
|
27
28
|
async function invokeSurface<S extends SurfaceName>(
|
|
28
29
|
providers: ProviderMap,
|
|
29
30
|
surface: S,
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
options: Required<Pick<QueryOptions, 'range' | 'limit' | 'offset'>> &
|
|
32
|
+
Omit<QueryOptions, 'range' | 'limit' | 'offset'>,
|
|
32
33
|
): Promise<SurfaceMap[S] | null> {
|
|
34
|
+
const { range, limit, offset } = options
|
|
33
35
|
const entry = catalog[surface]
|
|
34
36
|
const provider = providers[entry.provider]
|
|
35
37
|
|
|
@@ -70,7 +72,7 @@ async function invokeSurface<S extends SurfaceName>(
|
|
|
70
72
|
case 'traffic/pages':
|
|
71
73
|
case 'traffic/sources': {
|
|
72
74
|
const fn = provider[entry.fn] as (
|
|
73
|
-
range:
|
|
75
|
+
range: TrafficAnalyticsRange,
|
|
74
76
|
limit?: number,
|
|
75
77
|
) => Promise<SurfaceMap[S]>
|
|
76
78
|
if (surface === 'traffic/pages' || surface === 'traffic/sources') {
|
|
@@ -86,22 +88,71 @@ async function invokeSurface<S extends SurfaceName>(
|
|
|
86
88
|
) => Promise<SurfaceMap[S]>
|
|
87
89
|
return fn(range)
|
|
88
90
|
}
|
|
89
|
-
case 'attribution/
|
|
91
|
+
case 'attribution/sources':
|
|
92
|
+
case 'attribution/coverage':
|
|
93
|
+
case 'attribution/commerce-lanes': {
|
|
94
|
+
const fn = provider[entry.fn] as (
|
|
95
|
+
range: AnalyticsRange,
|
|
96
|
+
filters?: Pick<QueryOptions, 'productId'>,
|
|
97
|
+
) => Promise<SurfaceMap[S]>
|
|
98
|
+
return fn(range, { productId: options.productId })
|
|
99
|
+
}
|
|
100
|
+
case 'attribution/email-campaigns':
|
|
101
|
+
case 'attribution/email-campaigns/strict': {
|
|
102
|
+
const fn = provider[entry.fn] as (
|
|
103
|
+
range: AnalyticsRange,
|
|
104
|
+
limit?: number,
|
|
105
|
+
filters?: Pick<QueryOptions, 'productId'>,
|
|
106
|
+
) => Promise<SurfaceMap[S]>
|
|
107
|
+
return fn(range, limit, { productId: options.productId })
|
|
108
|
+
}
|
|
109
|
+
case 'attribution/checkout-receipt': {
|
|
110
|
+
const fn = provider[entry.fn] as (
|
|
111
|
+
filters: Pick<QueryOptions, 'purchaseId'>,
|
|
112
|
+
) => Promise<SurfaceMap[S]>
|
|
113
|
+
return fn({ purchaseId: options.purchaseId })
|
|
114
|
+
}
|
|
115
|
+
case 'attribution/checkout-survey-fallback': {
|
|
90
116
|
const fn = provider[entry.fn] as (
|
|
91
117
|
range: AnalyticsRange,
|
|
92
118
|
limit?: number,
|
|
119
|
+
filters?: Pick<QueryOptions, 'productId'>,
|
|
120
|
+
) => Promise<SurfaceMap[S]>
|
|
121
|
+
return fn(range, limit, { productId: options.productId })
|
|
122
|
+
}
|
|
123
|
+
case 'surveys/questions': {
|
|
124
|
+
const fn = provider[entry.fn] as (
|
|
125
|
+
range: AnalyticsRange,
|
|
126
|
+
limit: number,
|
|
93
127
|
) => Promise<SurfaceMap[S]>
|
|
94
128
|
return fn(range, limit)
|
|
95
129
|
}
|
|
96
|
-
case 'surveys/questions':
|
|
97
130
|
case 'surveys/responses': {
|
|
98
131
|
const fn = provider[entry.fn] as (
|
|
99
132
|
range: AnalyticsRange,
|
|
100
133
|
limit: number,
|
|
134
|
+
offset: number,
|
|
101
135
|
) => Promise<SurfaceMap[S]>
|
|
102
|
-
return fn(range, limit)
|
|
136
|
+
return fn(range, limit, offset)
|
|
103
137
|
}
|
|
104
138
|
default: {
|
|
139
|
+
if (surface === 'correlation/survey-revenue/product') {
|
|
140
|
+
const productSurveyFn = provider[entry.fn] as (
|
|
141
|
+
range: AnalyticsRange,
|
|
142
|
+
limit?: number,
|
|
143
|
+
filters?: Pick<
|
|
144
|
+
QueryOptions,
|
|
145
|
+
'productId' | 'surveyId' | 'surveySlug' | 'questionId'
|
|
146
|
+
>,
|
|
147
|
+
) => Promise<SurfaceMap[S] | null>
|
|
148
|
+
return productSurveyFn(range, limit, {
|
|
149
|
+
productId: options.productId,
|
|
150
|
+
surveyId: options.surveyId,
|
|
151
|
+
surveySlug: options.surveySlug,
|
|
152
|
+
questionId: options.questionId,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
105
156
|
const fn = provider[entry.fn] as (
|
|
106
157
|
range: AnalyticsRange,
|
|
107
158
|
limit?: number,
|
|
@@ -132,11 +183,21 @@ export function createAnalyticsEngine(providers: ProviderMap) {
|
|
|
132
183
|
): Promise<QueryResult<S>> {
|
|
133
184
|
const range = options?.range ?? '30d'
|
|
134
185
|
const limit = options?.limit ?? 20
|
|
186
|
+
const offset = options?.offset ?? 0
|
|
135
187
|
const entry = catalog[surface]
|
|
136
188
|
const startMs = Date.now()
|
|
137
189
|
|
|
138
190
|
try {
|
|
139
|
-
const data = await invokeSurface(providers, surface,
|
|
191
|
+
const data = await invokeSurface(providers, surface, {
|
|
192
|
+
range,
|
|
193
|
+
limit,
|
|
194
|
+
offset,
|
|
195
|
+
productId: options?.productId,
|
|
196
|
+
purchaseId: options?.purchaseId,
|
|
197
|
+
surveyId: options?.surveyId,
|
|
198
|
+
surveySlug: options?.surveySlug,
|
|
199
|
+
questionId: options?.questionId,
|
|
200
|
+
})
|
|
140
201
|
|
|
141
202
|
if (data === null) {
|
|
142
203
|
return {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classifyPurchaseAttribution,
|
|
5
|
+
hasExactShortlinkPurchaseAttribution,
|
|
6
|
+
SHORTLINK_RECOVERY_LANE,
|
|
7
|
+
} from './attribution-recovery'
|
|
8
|
+
|
|
9
|
+
describe('attribution recovery classification', () => {
|
|
10
|
+
it('keeps purchase field attribution ahead of exact shortlink recovery', () => {
|
|
11
|
+
expect(
|
|
12
|
+
classifyPurchaseAttribution({
|
|
13
|
+
purchaseId: 'purch_1',
|
|
14
|
+
fields: { attribution: { source: 'email' } },
|
|
15
|
+
shortlinkAttributions: [
|
|
16
|
+
{
|
|
17
|
+
type: 'purchase',
|
|
18
|
+
metadata: JSON.stringify({ purchaseId: 'purch_1' }),
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
}),
|
|
22
|
+
).toBe('purchase_field')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('recovers an otherwise dark purchase from exact shortlink metadata', () => {
|
|
26
|
+
expect(
|
|
27
|
+
classifyPurchaseAttribution({
|
|
28
|
+
purchaseId: 'purch_2',
|
|
29
|
+
fields: {},
|
|
30
|
+
shortlinkAttributions: [
|
|
31
|
+
{
|
|
32
|
+
type: 'purchase',
|
|
33
|
+
metadata: JSON.stringify({ purchaseId: 'purch_2' }),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
).toBe(SHORTLINK_RECOVERY_LANE)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('ignores invalid shortlink attribution metadata', () => {
|
|
41
|
+
expect(
|
|
42
|
+
hasExactShortlinkPurchaseAttribution({
|
|
43
|
+
purchaseId: 'purch_3',
|
|
44
|
+
attributions: [{ type: 'purchase', metadata: '{not json' }],
|
|
45
|
+
}),
|
|
46
|
+
).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('ignores non-matching shortlink attribution metadata', () => {
|
|
50
|
+
expect(
|
|
51
|
+
classifyPurchaseAttribution({
|
|
52
|
+
purchaseId: 'purch_4',
|
|
53
|
+
fields: {},
|
|
54
|
+
shortlinkAttributions: [
|
|
55
|
+
{
|
|
56
|
+
type: 'purchase',
|
|
57
|
+
metadata: JSON.stringify({ purchaseId: 'purch_other' }),
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
).toBe('dark')
|
|
62
|
+
})
|
|
63
|
+
})
|