@liiift-studio/sanity-visitor-insights 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/dist/index.d.mts +155 -0
  4. package/dist/index.d.ts +155 -0
  5. package/dist/index.js +652 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +609 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/ranges-D6AZwpmm.d.mts +282 -0
  10. package/dist/ranges-D6AZwpmm.d.ts +282 -0
  11. package/dist/server.d.mts +280 -0
  12. package/dist/server.d.ts +280 -0
  13. package/dist/server.js +937 -0
  14. package/dist/server.js.map +1 -0
  15. package/dist/server.mjs +882 -0
  16. package/dist/server.mjs.map +1 -0
  17. package/package.json +68 -0
  18. package/src/boundary.test.ts +93 -0
  19. package/src/core/core.test.ts +175 -0
  20. package/src/core/cutover.ts +109 -0
  21. package/src/core/ranges.ts +103 -0
  22. package/src/core/siteConfig.ts +150 -0
  23. package/src/index.ts +86 -0
  24. package/src/reportData.ts +120 -0
  25. package/src/server/auth.ts +133 -0
  26. package/src/server/cache.ts +75 -0
  27. package/src/server/createHandler.ts +199 -0
  28. package/src/server/ga4.ts +149 -0
  29. package/src/server/googleAuth.ts +139 -0
  30. package/src/server/orders.ts +127 -0
  31. package/src/server/reports/acquisition.ts +85 -0
  32. package/src/server/reports/journey.ts +112 -0
  33. package/src/server/reports/measurementHealth.ts +170 -0
  34. package/src/server/reports/typefaceInterest.ts +140 -0
  35. package/src/server/vercel.ts +68 -0
  36. package/src/server.ts +27 -0
  37. package/src/studio/Figure.tsx +175 -0
  38. package/src/studio/VisitorInsightsTool.tsx +247 -0
  39. package/src/studio/panels.tsx +233 -0
  40. package/src/studio/useReport.ts +99 -0
  41. package/src/types.ts +135 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Event instrumentation cutovers.
3
+ *
4
+ * GA4 cannot backfill events: data from before an event was instrumented does not exist and never
5
+ * will. A range spanning a cutover therefore mixes two measurement regimes, and a chart drawn
6
+ * across it reads as a change in visitor behaviour when it is really a change in what was counted.
7
+ *
8
+ * This lives in the package rather than in each site repo so all three foundries share one model
9
+ * and one set of semantics, and so the Studio panel and the server handler agree by construction.
10
+ */
11
+
12
+ import type { DateRange, MetricValue } from '../types'
13
+ import { unavailable, partial } from '../types'
14
+
15
+ /** An event that predates this work — long-running, with no discontinuity to mark. */
16
+ export const PREEXISTING = 'preexisting' as const
17
+
18
+ /**
19
+ * When an event began firing on a site.
20
+ * `PREEXISTING` = live before this work began. `null` = planned, not yet deployed.
21
+ * An ISO `YYYY-MM-DD` string = the deploy date, set in the commit that ships the event.
22
+ */
23
+ export type EventCutover = typeof PREEXISTING | string | null
24
+
25
+ /** How completely an event covers a requested range. */
26
+ export type Coverage =
27
+ | { status: 'full' }
28
+ | { status: 'partial'; cutover: string }
29
+ | { status: 'none'; reason: 'not_instrumented' | 'before_cutover' | 'unknown_event'; cutover?: string }
30
+
31
+ /**
32
+ * Determine how well an event covers a date range.
33
+ *
34
+ * @param cutovers - the site's event cutover map
35
+ * @param eventName - GA4 event name to look up
36
+ * @param range - the requested range
37
+ */
38
+ export function coverageForRange(
39
+ cutovers: Readonly<Record<string, EventCutover>>,
40
+ eventName: string,
41
+ range: Pick<DateRange, 'start' | 'end'>,
42
+ ): Coverage {
43
+ if (!(eventName in cutovers)) return { status: 'none', reason: 'unknown_event' }
44
+
45
+ const cutover = cutovers[eventName]
46
+ // `in` proved the key exists, so undefined here means an explicitly undefined value.
47
+ if (cutover === null || cutover === undefined) return { status: 'none', reason: 'not_instrumented' }
48
+ if (cutover === PREEXISTING) return { status: 'full' }
49
+
50
+ const cutoverTime = Date.parse(cutover)
51
+ // An unparseable date is a config error; treat it as unknown rather than silently trusting it.
52
+ if (Number.isNaN(cutoverTime)) return { status: 'none', reason: 'unknown_event' }
53
+
54
+ if (Date.parse(range.end) < cutoverTime) return { status: 'none', reason: 'before_cutover', cutover }
55
+ if (Date.parse(range.start) < cutoverTime) return { status: 'partial', cutover }
56
+
57
+ return { status: 'full' }
58
+ }
59
+
60
+ /**
61
+ * Wrap a raw count in a MetricValue that reflects the event's coverage of the range.
62
+ *
63
+ * Call this instead of returning a bare number, so a value that only covers part of the range
64
+ * carries that fact with it all the way to the renderer.
65
+ *
66
+ * @param count - the number GA4 returned, or null if the query could not run
67
+ * @param coverage - result of coverageForRange for the same event and range
68
+ */
69
+ export function applyCoverage(count: number | null, coverage: Coverage): MetricValue {
70
+ if (coverage.status === 'none') {
71
+ return unavailable(
72
+ coverage.reason === 'before_cutover' ? 'before_cutover' : 'not_instrumented',
73
+ coverage.cutover ? `Instrumented from ${coverage.cutover}` : undefined,
74
+ )
75
+ }
76
+
77
+ if (count === null) return unavailable('source_error')
78
+
79
+ if (coverage.status === 'partial') {
80
+ return partial(count, coverage.cutover, `Only counted from ${coverage.cutover}, when this event was added`)
81
+ }
82
+
83
+ return { status: 'ok', value: count }
84
+ }
85
+
86
+ /**
87
+ * Human-readable notices for every event in a range that is not fully covered.
88
+ * Surfaced in the report envelope so the panel can show caveats without recomputing them.
89
+ */
90
+ export function coverageNotices(
91
+ cutovers: Readonly<Record<string, EventCutover>>,
92
+ eventNames: readonly string[],
93
+ range: Pick<DateRange, 'start' | 'end'>,
94
+ ): string[] {
95
+ const notices: string[] = []
96
+
97
+ for (const name of eventNames) {
98
+ const coverage = coverageForRange(cutovers, name, range)
99
+ if (coverage.status === 'partial') {
100
+ notices.push(`${name} was instrumented on ${coverage.cutover}; figures before that date are missing, not zero.`)
101
+ } else if (coverage.status === 'none' && coverage.reason === 'not_instrumented') {
102
+ notices.push(`${name} is not instrumented on this site, so it cannot be reported for any range.`)
103
+ } else if (coverage.status === 'none' && coverage.reason === 'before_cutover') {
104
+ notices.push(`${name} did not exist during this range; it was instrumented on ${coverage.cutover}.`)
105
+ }
106
+ }
107
+
108
+ return notices
109
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Date-range resolution.
3
+ *
4
+ * Every range is resolved against one declared timezone — the GA4 property's — because the three
5
+ * sources bucket time differently: GA4 by the property timezone, Vercel by UTC, Sanity `_createdAt`
6
+ * by UTC. Without a single anchor, an order placed near midnight lands in different weeks depending
7
+ * on which source you ask, which corrupts exactly the cross-source comparison this tool exists for.
8
+ */
9
+
10
+ import type { DateRange, RangeKey } from '../types'
11
+
12
+ /** GA4 does not finalise the most recent days. Figures inside this window may still rise. */
13
+ export const GA4_PROCESSING_LAG_DAYS = 2
14
+
15
+ /** Format a Date as `YYYY-MM-DD` in the given IANA timezone. */
16
+ export function formatInTimeZone(date: Date, timeZone: string): string {
17
+ // en-CA gives ISO-ordered parts, which is what we want to reassemble.
18
+ const parts = new Intl.DateTimeFormat('en-CA', {
19
+ timeZone,
20
+ year: 'numeric',
21
+ month: '2-digit',
22
+ day: '2-digit',
23
+ }).formatToParts(date)
24
+
25
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
26
+ return `${get('year')}-${get('month')}-${get('day')}`
27
+ }
28
+
29
+ /** Shift an ISO `YYYY-MM-DD` date by a whole number of days. */
30
+ export function shiftDays(isoDate: string, days: number): string {
31
+ const time = Date.parse(`${isoDate}T00:00:00Z`)
32
+ if (Number.isNaN(time)) throw new Error(`Invalid ISO date: ${isoDate}`)
33
+ return new Date(time + days * 86_400_000).toISOString().slice(0, 10)
34
+ }
35
+
36
+ /** Inclusive whole days between two ISO dates. */
37
+ export function daysBetween(start: string, end: string): number {
38
+ return Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000) + 1
39
+ }
40
+
41
+ /** How many days each range key spans. */
42
+ const RANGE_DAYS: Record<RangeKey, number> = {
43
+ week: 7,
44
+ quarter: 91,
45
+ year: 365,
46
+ }
47
+
48
+ /**
49
+ * Resolve a range key into concrete dates, anchored to `timezone` and ending today.
50
+ *
51
+ * @param key - week, quarter or year
52
+ * @param timezone - IANA timezone of the GA4 property this range will be queried against
53
+ * @param now - injectable clock, so tests are deterministic
54
+ */
55
+ export function resolveRange(key: RangeKey, timezone: string, now: Date = new Date()): DateRange {
56
+ const end = formatInTimeZone(now, timezone)
57
+ const start = shiftDays(end, -(RANGE_DAYS[key] - 1))
58
+ return { key, start, end, timezone }
59
+ }
60
+
61
+ /**
62
+ * The equivalent range immediately before `range`, for period-over-period comparison.
63
+ *
64
+ * A bare count answers "how many", which on its own is not actionable; the comparison is what
65
+ * tells someone whether to do anything.
66
+ */
67
+ export function previousRange(range: DateRange): DateRange {
68
+ const span = daysBetween(range.start, range.end)
69
+ return {
70
+ key: range.key,
71
+ start: shiftDays(range.start, -span),
72
+ end: shiftDays(range.end, -span),
73
+ timezone: range.timezone,
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The trailing dates within a range whose GA4 figures are not yet settled.
79
+ * Returns an empty array when the range ends before the lag window.
80
+ */
81
+ export function provisionalDates(range: DateRange, now: Date = new Date()): string[] {
82
+ const today = formatInTimeZone(now, range.timezone)
83
+ const dates: string[] = []
84
+
85
+ for (let i = 0; i < GA4_PROCESSING_LAG_DAYS; i += 1) {
86
+ const date = shiftDays(today, -i)
87
+ if (date >= range.start && date <= range.end) dates.push(date)
88
+ }
89
+
90
+ return dates.sort()
91
+ }
92
+
93
+ /**
94
+ * Notice text when a range includes days GA4 has not finished processing, or `null` when it does not.
95
+ * Without this a Monday-morning glance at "this week" reads the trailing dip as a real drop.
96
+ */
97
+ export function provisionalNotice(range: DateRange, now: Date = new Date()): string | null {
98
+ const dates = provisionalDates(range, now)
99
+ if (dates.length === 0) return null
100
+
101
+ const from = dates[0]
102
+ return `GA4 has not finished processing ${from} onwards; those figures are provisional and will rise.`
103
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Per-site adapter configuration.
3
+ *
4
+ * The three foundries differ in ways that cannot be abstracted away: different GA4 event coverage,
5
+ * different order schemas, different flows that exist on one site and not another. Rather than
6
+ * branching on a site id inside the reports — which would mean editing this package to onboard a
7
+ * fourth foundry — each site describes itself through this config and the reports stay generic.
8
+ */
9
+
10
+ import type { EventCutover } from './cutover'
11
+
12
+ /** GA4 connection for one site. */
13
+ export interface Ga4Config {
14
+ /** Numeric GA4 property id. NOT the `G-XXXXXXX` measurement id, which is the client-side one. */
15
+ propertyId: string
16
+ /** IANA timezone the property is configured with. Every range is anchored to this. */
17
+ timezone: string
18
+ }
19
+
20
+ /** Vercel Web Analytics connection for one site. */
21
+ export interface VercelConfig {
22
+ projectId: string
23
+ teamId?: string
24
+ }
25
+
26
+ /** Where to count orders, and what they are called on this site. */
27
+ export interface OrdersConfig {
28
+ /** Document type holding orders. */
29
+ documentType: string
30
+ /**
31
+ * Field holding the typeface references on an order, if the site has one.
32
+ * Null where orders do not resolve to typefaces in a usable way.
33
+ */
34
+ typefacesField: string | null
35
+ /**
36
+ * GROQ filter appended to exclude non-typeface orders, e.g. merch-only orders on Darden.
37
+ * Merch inflates a family's apparent purchase count if not excluded.
38
+ */
39
+ excludeFilter?: string
40
+ }
41
+
42
+ /** One site's complete description of itself. */
43
+ export interface SiteAnalyticsConfig {
44
+ /** Stable machine id, e.g. `darden`. */
45
+ siteId: string
46
+ /** Human label shown in the Studio UI. */
47
+ label: string
48
+ /** Null when GA4 is not wired up for this site — reports degrade rather than fail. */
49
+ ga4: Ga4Config | null
50
+ /** Null when Vercel Web Analytics is unavailable, e.g. on a plan tier without API access. */
51
+ vercel: VercelConfig | null
52
+ orders: OrdersConfig
53
+ /**
54
+ * When each GA4 event began firing here. Events absent from this map report as `unknown_event`,
55
+ * which is the honest answer for a flow the site does not have — distinct from `not_instrumented`,
56
+ * which implies it is merely pending.
57
+ */
58
+ eventCutovers: Record<string, EventCutover>
59
+ /** Origins allowed to call this site's handler cross-origin, e.g. a separately deployed Studio. */
60
+ allowedStudioOrigins?: string[]
61
+ }
62
+
63
+ /** A problem found while validating a site config. */
64
+ export interface ConfigProblem {
65
+ field: string
66
+ message: string
67
+ }
68
+
69
+ /**
70
+ * Validate a site config at runtime.
71
+ *
72
+ * Compile-time types do not survive into a consuming site's `sanity.config.js`, which is plain
73
+ * JavaScript — so a typo'd property id or a measurement id pasted where a numeric property id
74
+ * belongs would otherwise surface as an empty chart rather than an error.
75
+ *
76
+ * @param config - the candidate config
77
+ * @returns the problems found; an empty array means the config is usable
78
+ */
79
+ export function validateSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): ConfigProblem[] {
80
+ const problems: ConfigProblem[] = []
81
+
82
+ if (!config) {
83
+ return [{ field: 'config', message: 'No site config supplied' }]
84
+ }
85
+
86
+ if (!config.siteId) problems.push({ field: 'siteId', message: 'Required' })
87
+ if (!config.label) problems.push({ field: 'label', message: 'Required' })
88
+
89
+ if (config.ga4) {
90
+ const { propertyId, timezone } = config.ga4
91
+ if (!propertyId) {
92
+ problems.push({ field: 'ga4.propertyId', message: 'Required when ga4 is configured' })
93
+ } else if (propertyId.startsWith('G-')) {
94
+ problems.push({
95
+ field: 'ga4.propertyId',
96
+ message: `Looks like a measurement id ("${propertyId}"). The Data API needs the numeric property id from GA4 Admin instead.`,
97
+ })
98
+ } else if (!/^\d+$/.test(propertyId)) {
99
+ problems.push({ field: 'ga4.propertyId', message: 'Must be the numeric GA4 property id' })
100
+ }
101
+
102
+ if (!timezone) {
103
+ problems.push({ field: 'ga4.timezone', message: 'Required — ranges are anchored to it' })
104
+ } else if (!isValidTimeZone(timezone)) {
105
+ problems.push({ field: 'ga4.timezone', message: `Not a recognised IANA timezone: ${timezone}` })
106
+ }
107
+ }
108
+
109
+ if (config.vercel && !config.vercel.projectId) {
110
+ problems.push({ field: 'vercel.projectId', message: 'Required when vercel is configured' })
111
+ }
112
+
113
+ if (!config.orders?.documentType) {
114
+ problems.push({ field: 'orders.documentType', message: 'Required' })
115
+ }
116
+
117
+ if (!config.eventCutovers) {
118
+ problems.push({ field: 'eventCutovers', message: 'Required — omit events the site does not have' })
119
+ }
120
+
121
+ for (const origin of config.allowedStudioOrigins ?? []) {
122
+ if (!/^https?:\/\//.test(origin)) {
123
+ problems.push({ field: 'allowedStudioOrigins', message: `Must be a full origin including scheme: ${origin}` })
124
+ }
125
+ if (origin.endsWith('/')) {
126
+ problems.push({ field: 'allowedStudioOrigins', message: `Must not have a trailing slash: ${origin}` })
127
+ }
128
+ }
129
+
130
+ return problems
131
+ }
132
+
133
+ /** Whether a string is an IANA timezone this runtime recognises. */
134
+ export function isValidTimeZone(timeZone: string): boolean {
135
+ try {
136
+ new Intl.DateTimeFormat('en-CA', { timeZone })
137
+ return true
138
+ } catch {
139
+ return false
140
+ }
141
+ }
142
+
143
+ /** Throw if a config is unusable, naming every problem at once rather than one per run. */
144
+ export function assertValidSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): asserts config is SiteAnalyticsConfig {
145
+ const problems = validateSiteConfig(config)
146
+ if (problems.length > 0) {
147
+ const detail = problems.map((p) => ` ${p.field}: ${p.message}`).join('\n')
148
+ throw new Error(`Invalid visitor-insights site config:\n${detail}`)
149
+ }
150
+ }
package/src/index.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Studio entry point — browser only.
3
+ *
4
+ * Must never import from `./server` or anything under `src/server/`, which reads credentials and
5
+ * uses node:crypto. The split export subpaths are what keep that guarantee enforceable.
6
+ */
7
+
8
+ import { definePlugin } from 'sanity'
9
+ import { resolveIcon } from '@liiift-studio/sanity-ui-compat/icons'
10
+ import { VisitorInsightsTool, type VisitorInsightsToolProps } from './studio/VisitorInsightsTool'
11
+
12
+ /** Chart glyph, resolved against whichever @sanity/icons major the Studio has installed. */
13
+ const ChartIcon = resolveIcon('ChartUpwardIcon', 'chart-upward')
14
+
15
+ /** Options for the Studio plugin. */
16
+ export interface VisitorInsightsPluginOptions {
17
+ /**
18
+ * Base URL of the site serving the reports, e.g. `https://dardenstudio.com`.
19
+ * Same-origin studios can pass an empty string.
20
+ */
21
+ apiBaseUrl: string
22
+ /** Label shown in the tool header. */
23
+ siteLabel: string
24
+ /** Tool name in the Studio URL. Defaults to `visitor-insights`. */
25
+ name?: string
26
+ /** Title in the Studio nav. Defaults to `Insights`. */
27
+ title?: string
28
+ /**
29
+ * Restrict the tool to these Sanity roles. Omit to show it to every Studio user.
30
+ * These panels read order-derived conversion figures, so gating to administrators is
31
+ * usually right — matching how the deploy and utilities tools are already gated.
32
+ */
33
+ roles?: string[]
34
+ }
35
+
36
+ /**
37
+ * Visitor Insights — visitor-behaviour analytics inside the Studio.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * plugins: [
42
+ * visitorInsights({
43
+ * apiBaseUrl: 'https://dardenstudio.com',
44
+ * siteLabel: 'Darden Studio',
45
+ * roles: ['administrator'],
46
+ * }),
47
+ * ]
48
+ * ```
49
+ */
50
+ export const visitorInsights = definePlugin<VisitorInsightsPluginOptions>((options) => {
51
+ const { apiBaseUrl, siteLabel, name = 'visitor-insights', title = 'Insights', roles } = options
52
+
53
+ return {
54
+ name: '@liiift-studio/sanity-visitor-insights',
55
+ tools: (prev, { currentUser }) => {
56
+ if (roles && roles.length > 0) {
57
+ const userRoles = currentUser?.roles?.map((role) => role.name) ?? []
58
+ const permitted = userRoles.some((role) => roles.includes(role))
59
+ if (!permitted) return prev
60
+ }
61
+
62
+ return [
63
+ ...prev,
64
+ {
65
+ name,
66
+ title,
67
+ icon: ChartIcon,
68
+ component: VisitorInsightsTool,
69
+ options: { apiBaseUrl, siteLabel } satisfies VisitorInsightsToolProps,
70
+ },
71
+ ]
72
+ },
73
+ }
74
+ })
75
+
76
+ export default visitorInsights
77
+
78
+ export { VisitorInsightsTool, type VisitorInsightsToolProps } from './studio/VisitorInsightsTool'
79
+ export { useReport, type ReportState, type UseReportOptions } from './studio/useReport'
80
+ export { MetricFigure, ComparisonBar, NoticeList, formatCount, formatPercent } from './studio/Figure'
81
+
82
+ // Shared contract types, safe in the browser — no server code reachable from here.
83
+ export * from './types'
84
+ export { PREEXISTING, coverageForRange, type EventCutover, type Coverage } from './core/cutover'
85
+ export { validateSiteConfig, type SiteAnalyticsConfig } from './core/siteConfig'
86
+ export { resolveRange, previousRange } from './core/ranges'
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Report result shapes, shared by the server that produces them and the panels that render them.
3
+ *
4
+ * These live outside both entry points on purpose. If the panels imported them from
5
+ * `src/server/reports/*` — even as `import type`, which does erase at build time — the module
6
+ * graph would still contain a client-to-server edge, and the guarantee that credential-reading
7
+ * code cannot reach a Studio bundle would rest on a compiler detail rather than on structure.
8
+ */
9
+
10
+ import type { MetricValue } from './types'
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Measurement health
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** How much of reality each source sees. Pageviews compare like with like; the rest is context. */
17
+ export interface MeasurementHealthData {
18
+ /** GA4 pageviews — directly comparable to `vercelPageviews`. */
19
+ ga4Pageviews: MetricValue
20
+ /** Vercel pageviews — cookieless, not consent-gated. */
21
+ vercelPageviews: MetricValue
22
+ /**
23
+ * Vercel minus GA4 pageviews, as a share of Vercel. Positive means GA4 saw less.
24
+ * Null when either side is unavailable — never computed against a missing operand.
25
+ */
26
+ shortfallRatio: number | null
27
+ /** GA4 sessions. Context only: a session is not a pageview and is never subtracted from one. */
28
+ ga4Sessions: MetricValue
29
+ /** Orders in range. Ground truth for conversions, shown as context. */
30
+ orders: MetricValue
31
+ /** Share of sessions that granted consent, where the event exists. */
32
+ consentRate: MetricValue
33
+ /** Plain-language reading of the numbers above, for a non-analyst audience. */
34
+ interpretation: string
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Acquisition
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** One acquisition source. */
42
+ export interface SourceRow {
43
+ /** GA4 `sessionSource` value. */
44
+ source: string
45
+ /** GA4 `sessionDefaultChannelGroup`, e.g. Organic Search. */
46
+ channel: string
47
+ sessions: number
48
+ /** True when this source is one of the design-industry referrers. */
49
+ designIndustry: boolean
50
+ /** True for GA4's `(not set)` / `(direct)` style buckets, which are not real sources. */
51
+ unattributed: boolean
52
+ }
53
+
54
+ /** Where visitors came from. */
55
+ export interface AcquisitionData {
56
+ rows: SourceRow[]
57
+ totalSessions: number
58
+ /** Sessions from design-industry referrers, as a share of the total. */
59
+ designIndustryShare: number | null
60
+ /** Sessions GA4 could not attribute, as a share of the total. */
61
+ unattributedShare: number | null
62
+ /** True when GA4 withheld low-count rows, so the tail is shorter than reality. */
63
+ rowsWithheld: boolean
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Journey
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** One rung of the funnel. */
71
+ export interface JourneyStep {
72
+ key: string
73
+ label: string
74
+ event: string
75
+ count: MetricValue
76
+ /**
77
+ * Share of the previous measurable step that reached this one.
78
+ * Null when either end is unavailable — a drop-off across an unmeasured step is meaningless.
79
+ */
80
+ conversionFromPrevious: number | null
81
+ }
82
+
83
+ /** A page where sessions commonly ended. */
84
+ export interface ExitPage {
85
+ path: string
86
+ exits: number
87
+ }
88
+
89
+ /** How far visitors get. Per-step totals, never an observed path. */
90
+ export interface JourneyData {
91
+ steps: JourneyStep[]
92
+ topExitPages: ExitPage[]
93
+ /** Always true. The UI must not present these steps as a tracked journey. */
94
+ approximate: true
95
+ /** Why it is approximate, in words the panel can show directly. */
96
+ approximationNote: string
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Typeface interest
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /** One family's interest figures. */
104
+ export interface TypefaceInterestRow {
105
+ typeface: string
106
+ viewed: MetricValue
107
+ tested: MetricValue
108
+ bought: MetricValue
109
+ /** Tested divided by viewed. Null unless both are real numbers. */
110
+ testRate: number | null
111
+ }
112
+
113
+ /** Viewed, tested and bought, by family. */
114
+ export interface TypefaceInterestData {
115
+ rows: TypefaceInterestRow[]
116
+ /** Stated in the response so the UI cannot omit it. */
117
+ interpretationNote: string
118
+ /** True when GA4 withheld low-count rows, so quiet families may be missing entirely. */
119
+ rowsWithheld: boolean
120
+ }