@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.
@@ -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 {rangeLabel}
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): '24h' | '7d' | '30d' | '90d' {
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
- range: AnalyticsRange,
31
- limit: number,
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: '24h' | '7d' | '30d' | '90d',
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/email-campaigns': {
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, range, limit)
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
+ })