@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,233 @@
1
+ /**
2
+ * The four report panels.
3
+ *
4
+ * Every panel renders a table or labelled bars rather than a chart, and every one surfaces its own
5
+ * caveats inline. Tables are the accessible representation as well as the visual one, so there is
6
+ * no separate "view as data" toggle that could drift out of sync with what is displayed.
7
+ */
8
+
9
+ import React from 'react'
10
+ import { Box, Card, Flex, Heading, Stack, Text } from '@liiift-studio/sanity-ui-compat'
11
+ import { ComparisonBar, MetricFigure, NoticeList, formatCount, formatPercent } from './Figure'
12
+ import type {
13
+ AcquisitionData,
14
+ JourneyData,
15
+ MeasurementHealthData,
16
+ TypefaceInterestData,
17
+ } from '../reportData'
18
+ import type { MetricValue } from '../types'
19
+
20
+ /** Largest available value across metrics, for scaling bars. */
21
+ function maxOf(metrics: MetricValue[]): number {
22
+ return metrics.reduce((max, metric) => (metric.status === 'unavailable' ? max : Math.max(max, metric.value)), 0)
23
+ }
24
+
25
+ /** Shared table styling — scrolls inside its own container so the panel never scrolls sideways. */
26
+ const tableWrap: React.CSSProperties = { overflowX: 'auto', width: '100%' }
27
+ const table: React.CSSProperties = { width: '100%', borderCollapse: 'collapse', minWidth: 420 }
28
+ const cell: React.CSSProperties = { padding: '8px 12px', textAlign: 'left', borderBottom: '1px solid var(--card-border-color)' }
29
+ const numericCell: React.CSSProperties = { ...cell, textAlign: 'right' }
30
+
31
+ /**
32
+ * Measurement Health — how much of reality each source sees.
33
+ * Compares pageviews to pageviews. Sessions and orders sit alongside as context and are never
34
+ * subtracted from a pageview count.
35
+ */
36
+ export function MeasurementHealthPanel({ data }: { data: MeasurementHealthData }): React.ReactElement {
37
+ const pageviewMax = maxOf([data.ga4Pageviews, data.vercelPageviews])
38
+
39
+ return (
40
+ <Stack space={4}>
41
+ <Stack space={3}>
42
+ <Heading size={1}>Pageviews, source against source</Heading>
43
+ <Text size={1} muted>
44
+ The same unit on both sides. Vercel is cookieless and ungated; GA4 is consent-gated and
45
+ blockable, so GA4 seeing fewer is expected.
46
+ </Text>
47
+ <ComparisonBar label="Vercel pageviews" metric={data.vercelPageviews} max={pageviewMax} tone="primary" />
48
+ <ComparisonBar label="GA4 pageviews" metric={data.ga4Pageviews} max={pageviewMax} tone="default" />
49
+ </Stack>
50
+
51
+ {data.shortfallRatio !== null && (
52
+ <Card padding={3} radius={2} tone="transparent" border>
53
+ <Stack space={2}>
54
+ <Text size={1} weight="semibold">
55
+ GA4 shortfall: {formatPercent(Math.abs(data.shortfallRatio), 1)}
56
+ </Text>
57
+ <Text size={1} muted>{data.interpretation}</Text>
58
+ </Stack>
59
+ </Card>
60
+ )}
61
+
62
+ <Stack space={3}>
63
+ <Heading size={1}>Context</Heading>
64
+ <Text size={1} muted>
65
+ Different units to the figures above, and to each other. Shown for scale, never differenced.
66
+ </Text>
67
+ <Flex gap={4} wrap="wrap">
68
+ <Stack space={2}>
69
+ <Text size={1} muted>GA4 sessions</Text>
70
+ <MetricFigure metric={data.ga4Sessions} label="GA4 sessions" />
71
+ </Stack>
72
+ <Stack space={2}>
73
+ <Text size={1} muted>Orders</Text>
74
+ <MetricFigure metric={data.orders} label="Orders" />
75
+ </Stack>
76
+ <Stack space={2}>
77
+ <Text size={1} muted>Consent granted</Text>
78
+ <MetricFigure metric={data.consentRate} label="Consent granted, percent of sessions" />
79
+ </Stack>
80
+ </Flex>
81
+ </Stack>
82
+ </Stack>
83
+ )
84
+ }
85
+
86
+ /** Acquisition — where visitors came from, with design-industry referrers called out. */
87
+ export function AcquisitionPanel({ data }: { data: AcquisitionData }): React.ReactElement {
88
+ return (
89
+ <Stack space={4}>
90
+ <Flex gap={4} wrap="wrap">
91
+ <Stack space={2}>
92
+ <Text size={1} muted>Sessions</Text>
93
+ <Text size={4}>{formatCount(data.totalSessions)}</Text>
94
+ </Stack>
95
+ {data.designIndustryShare !== null && (
96
+ <Stack space={2}>
97
+ <Text size={1} muted>From design-industry referrers</Text>
98
+ <Text size={4}>{formatPercent(data.designIndustryShare, 1)}</Text>
99
+ </Stack>
100
+ )}
101
+ {data.unattributedShare !== null && (
102
+ <Stack space={2}>
103
+ <Text size={1} muted>Unattributed</Text>
104
+ <Text size={4}>{formatPercent(data.unattributedShare, 1)}</Text>
105
+ </Stack>
106
+ )}
107
+ </Flex>
108
+
109
+ <Box style={tableWrap}>
110
+ <table style={table}>
111
+ <caption style={{ textAlign: 'left', paddingBottom: 8 }}>
112
+ <Text size={1} muted>Traffic sources by sessions</Text>
113
+ </caption>
114
+ <thead>
115
+ <tr>
116
+ <th scope="col" style={cell}><Text size={1} weight="semibold">Source</Text></th>
117
+ <th scope="col" style={cell}><Text size={1} weight="semibold">Channel</Text></th>
118
+ <th scope="col" style={numericCell}><Text size={1} weight="semibold">Sessions</Text></th>
119
+ </tr>
120
+ </thead>
121
+ <tbody>
122
+ {data.rows.map((row) => (
123
+ <tr key={`${row.source}-${row.channel}`}>
124
+ <th scope="row" style={cell}>
125
+ <Flex gap={2} align="center">
126
+ <Text size={1}>{row.source}</Text>
127
+ {row.designIndustry && <Text size={0} muted>· design industry</Text>}
128
+ {row.unattributed && <Text size={0} muted>· unattributed</Text>}
129
+ </Flex>
130
+ </th>
131
+ <td style={cell}><Text size={1} muted>{row.channel}</Text></td>
132
+ <td style={numericCell}><Text size={1}>{formatCount(row.sessions)}</Text></td>
133
+ </tr>
134
+ ))}
135
+ </tbody>
136
+ </table>
137
+ </Box>
138
+
139
+ {data.rowsWithheld && (
140
+ <NoticeList notices={['GA4 withheld some low-traffic rows for privacy, so this list is shorter than reality.']} />
141
+ )}
142
+ </Stack>
143
+ )
144
+ }
145
+
146
+ /** Journey — per-step totals with adjacent drop-off, explicitly not a tracked path. */
147
+ export function JourneyPanel({ data }: { data: JourneyData }): React.ReactElement {
148
+ const max = maxOf(data.steps.map((step) => step.count))
149
+
150
+ return (
151
+ <Stack space={4}>
152
+ <Card padding={3} radius={2} tone="caution" border>
153
+ <Text size={1}>{data.approximationNote}</Text>
154
+ </Card>
155
+
156
+ <Stack space={3}>
157
+ {data.steps.map((step) => (
158
+ <Stack space={2} key={step.key}>
159
+ <ComparisonBar label={step.label} metric={step.count} max={max} tone="primary" />
160
+ {step.conversionFromPrevious !== null && (
161
+ <Text size={0} muted>{formatPercent(step.conversionFromPrevious, 1)} of the previous measurable step</Text>
162
+ )}
163
+ </Stack>
164
+ ))}
165
+ </Stack>
166
+
167
+ {data.topExitPages.length > 0 && (
168
+ <Stack space={3}>
169
+ <Heading size={1}>Where sessions ended</Heading>
170
+ <Box style={tableWrap}>
171
+ <table style={table}>
172
+ <thead>
173
+ <tr>
174
+ <th scope="col" style={cell}><Text size={1} weight="semibold">Page</Text></th>
175
+ <th scope="col" style={numericCell}><Text size={1} weight="semibold">Exits</Text></th>
176
+ </tr>
177
+ </thead>
178
+ <tbody>
179
+ {data.topExitPages.map((page) => (
180
+ <tr key={page.path}>
181
+ <th scope="row" style={cell}><Text size={1}>{page.path}</Text></th>
182
+ <td style={numericCell}><Text size={1}>{formatCount(page.exits)}</Text></td>
183
+ </tr>
184
+ ))}
185
+ </tbody>
186
+ </table>
187
+ </Box>
188
+ </Stack>
189
+ )}
190
+ </Stack>
191
+ )
192
+ }
193
+
194
+ /** Typeface interest — viewed, tested and bought per family. */
195
+ export function TypefaceInterestPanel({ data }: { data: TypefaceInterestData }): React.ReactElement {
196
+ return (
197
+ <Stack space={4}>
198
+ <Card padding={3} radius={2} tone="transparent" border>
199
+ <Text size={1} muted>{data.interpretationNote}</Text>
200
+ </Card>
201
+
202
+ <Box style={tableWrap}>
203
+ <table style={table}>
204
+ <caption style={{ textAlign: 'left', paddingBottom: 8 }}>
205
+ <Text size={1} muted>Engagement by typeface</Text>
206
+ </caption>
207
+ <thead>
208
+ <tr>
209
+ <th scope="col" style={cell}><Text size={1} weight="semibold">Typeface</Text></th>
210
+ <th scope="col" style={numericCell}><Text size={1} weight="semibold">Viewed</Text></th>
211
+ <th scope="col" style={numericCell}><Text size={1} weight="semibold">Tested</Text></th>
212
+ <th scope="col" style={numericCell}><Text size={1} weight="semibold">Bought</Text></th>
213
+ <th scope="col" style={numericCell}><Text size={1} weight="semibold">Test rate</Text></th>
214
+ </tr>
215
+ </thead>
216
+ <tbody>
217
+ {data.rows.map((row) => (
218
+ <tr key={row.typeface}>
219
+ <th scope="row" style={cell}><Text size={1}>{row.typeface}</Text></th>
220
+ <td style={numericCell}><MetricFigure metric={row.viewed} label={`${row.typeface} viewed`} size={1} /></td>
221
+ <td style={numericCell}><MetricFigure metric={row.tested} label={`${row.typeface} tested`} size={1} /></td>
222
+ <td style={numericCell}><MetricFigure metric={row.bought} label={`${row.typeface} bought`} size={1} /></td>
223
+ <td style={numericCell}>
224
+ <Text size={1} muted>{row.testRate === null ? '—' : formatPercent(row.testRate, 1)}</Text>
225
+ </td>
226
+ </tr>
227
+ ))}
228
+ </tbody>
229
+ </table>
230
+ </Box>
231
+ </Stack>
232
+ )
233
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Data hook for the Studio panels.
3
+ *
4
+ * Forwards the Studio's own Sanity session token to the site's API route, which verifies it against
5
+ * Sanity. There is no shared secret: the Studio bundle is public, so anything compiled into it is
6
+ * extractable.
7
+ */
8
+
9
+ import { useCallback, useEffect, useRef, useState } from 'react'
10
+ import { useClient } from 'sanity'
11
+ import type { RangeKey, ReportEnvelope, ReportName } from '../types'
12
+
13
+ /** Sanity API version this tool pins. Fixed rather than "latest" so behaviour cannot drift. */
14
+ const API_VERSION = '2024-03-01'
15
+
16
+ /** Loading state for a report. */
17
+ export type ReportState<T> =
18
+ | { status: 'idle' }
19
+ | { status: 'loading' }
20
+ | { status: 'ready'; envelope: ReportEnvelope<T> }
21
+ | { status: 'error'; message: string }
22
+
23
+ /** Options for useReport. */
24
+ export interface UseReportOptions {
25
+ /** Base URL of the site serving the reports, e.g. `https://dardenstudio.com`. */
26
+ apiBaseUrl: string
27
+ report: ReportName
28
+ range: RangeKey
29
+ }
30
+
31
+ /**
32
+ * Fetch a report, re-fetching when the range changes.
33
+ *
34
+ * @returns the current state plus a `reload` for manual refresh
35
+ */
36
+ export function useReport<T>({ apiBaseUrl, report, range }: UseReportOptions): {
37
+ state: ReportState<T>
38
+ reload: () => void
39
+ } {
40
+ const client = useClient({ apiVersion: API_VERSION })
41
+ const [state, setState] = useState<ReportState<T>>({ status: 'idle' })
42
+ const [nonce, setNonce] = useState(0)
43
+
44
+ // Lets an in-flight response from a previous range be discarded rather than overwriting a newer one.
45
+ const requestIdRef = useRef(0)
46
+
47
+ const reload = useCallback(() => setNonce((n) => n + 1), [])
48
+
49
+ useEffect(() => {
50
+ const requestId = requestIdRef.current + 1
51
+ requestIdRef.current = requestId
52
+
53
+ const controller = new AbortController()
54
+ setState({ status: 'loading' })
55
+
56
+ async function run() {
57
+ // The Studio client carries the session token under token-based auth. Under cookie-based
58
+ // auth it does not, and there is no way to forward credentials the browser will not
59
+ // expose — so say so plainly rather than failing with an opaque 401.
60
+ const token = client.config().token
61
+ if (!token) {
62
+ setState({
63
+ status: 'error',
64
+ message: 'No Sanity session token available in this Studio. Visitor Insights needs token-based auth to call the site API.',
65
+ })
66
+ return
67
+ }
68
+
69
+ try {
70
+ const url = `${apiBaseUrl.replace(/\/$/, '')}/api/visitor-insights/${report}?range=${range}`
71
+ const response = await fetch(url, {
72
+ headers: { Authorization: `Bearer ${token}` },
73
+ signal: controller.signal,
74
+ })
75
+
76
+ if (requestIdRef.current !== requestId) return
77
+
78
+ if (!response.ok) {
79
+ const detail = response.status === 401 ? 'Not authorised — sign in to the Studio again.' : `Request failed (${response.status})`
80
+ setState({ status: 'error', message: detail })
81
+ return
82
+ }
83
+
84
+ const envelope = (await response.json()) as ReportEnvelope<T>
85
+ if (requestIdRef.current !== requestId) return
86
+
87
+ setState({ status: 'ready', envelope })
88
+ } catch (e) {
89
+ if (controller.signal.aborted || requestIdRef.current !== requestId) return
90
+ setState({ status: 'error', message: (e as Error).message })
91
+ }
92
+ }
93
+
94
+ void run()
95
+ return () => controller.abort()
96
+ }, [client, apiBaseUrl, report, range, nonce])
97
+
98
+ return { state, reload }
99
+ }
package/src/types.ts ADDED
@@ -0,0 +1,135 @@
1
+ /** Shared contract types for the Studio plugin and the API-route handler it calls. */
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Metric values
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /**
8
+ * Why a metric has no usable number. Kept distinct rather than collapsed into one flag, because
9
+ * the UI must say different things: "this site never tracked it" is permanent, "not yet at this
10
+ * date" resolves as time passes, and "GA4 withheld it" means the number exists but is hidden.
11
+ */
12
+ export type UnavailableReason =
13
+ /** The event has never been instrumented on this site. Permanent for historical ranges. */
14
+ | 'not_instrumented'
15
+ /** Instrumented, but the requested range predates the cutover date. */
16
+ | 'before_cutover'
17
+ /** GA4 suppressed the row for privacy thresholding. A real number exists; we may not see it. */
18
+ | 'suppressed'
19
+ /** The upstream source errored or timed out. Retryable, unlike the others. */
20
+ | 'source_error'
21
+ /** The metric is not defined for this site at all (e.g. a flow that does not exist there). */
22
+ | 'not_applicable'
23
+
24
+ /**
25
+ * A metric that may legitimately have no value.
26
+ *
27
+ * The whole point of this union is that there is no way to read a number without first handling
28
+ * the absent case — a missing metric can never silently coerce to 0 and be charted as a real
29
+ * trough. `partial` carries a number that is only valid for part of the requested range.
30
+ */
31
+ export type MetricValue =
32
+ | { status: 'ok'; value: number }
33
+ | { status: 'partial'; value: number; coveredFrom: string; note: string }
34
+ | { status: 'unavailable'; reason: UnavailableReason; detail?: string }
35
+
36
+ /** Construct an available metric. */
37
+ export function ok(value: number): MetricValue {
38
+ return { status: 'ok', value }
39
+ }
40
+
41
+ /** Construct an unavailable metric. */
42
+ export function unavailable(reason: UnavailableReason, detail?: string): MetricValue {
43
+ return { status: 'unavailable', reason, detail }
44
+ }
45
+
46
+ /**
47
+ * Construct a metric covering only part of the requested range.
48
+ *
49
+ * @param value - the number for the covered portion
50
+ * @param coveredFrom - ISO date from which the number is valid
51
+ * @param note - short human-readable reason, shown next to the figure
52
+ */
53
+ export function partial(value: number, coveredFrom: string, note: string): MetricValue {
54
+ return { status: 'partial', value, coveredFrom, note }
55
+ }
56
+
57
+ /**
58
+ * Read a metric's number, or `null` when there isn't a usable one.
59
+ * Use at render time so the absent case has to be handled explicitly.
60
+ */
61
+ export function valueOrNull(metric: MetricValue): number | null {
62
+ return metric.status === 'unavailable' ? null : metric.value
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Date ranges
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /** The range granularities offered in the UI. */
70
+ export type RangeKey = 'week' | 'quarter' | 'year'
71
+
72
+ /**
73
+ * A resolved date range. Both bounds are inclusive ISO `YYYY-MM-DD` dates in `timezone`.
74
+ *
75
+ * `timezone` is carried explicitly because GA4 buckets by the property's configured timezone,
76
+ * Vercel reports in UTC, and Sanity `_createdAt` is UTC — comparing them without a single declared
77
+ * anchor silently shifts events across day and week boundaries.
78
+ */
79
+ export interface DateRange {
80
+ key: RangeKey
81
+ start: string
82
+ end: string
83
+ timezone: string
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Reports
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /** The report names this package serves. Used as a strict allow-list, never as dynamic dispatch. */
91
+ export const REPORT_NAMES = [
92
+ 'measurement-health',
93
+ 'acquisition',
94
+ 'journey',
95
+ 'typeface-interest',
96
+ ] as const
97
+
98
+ export type ReportName = (typeof REPORT_NAMES)[number]
99
+
100
+ /** Narrow an untrusted string to a known report name. */
101
+ export function isReportName(value: unknown): value is ReportName {
102
+ return typeof value === 'string' && (REPORT_NAMES as readonly string[]).includes(value)
103
+ }
104
+
105
+ /** Which upstreams a report consulted, and how each fared. */
106
+ export type SourceStatus =
107
+ | { status: 'ok' }
108
+ | { status: 'unconfigured' }
109
+ | { status: 'error'; message: string }
110
+
111
+ /** Upstream data sources this package can read. */
112
+ export type SourceName = 'ga4' | 'vercel' | 'sanity'
113
+
114
+ /**
115
+ * Envelope around every report response.
116
+ *
117
+ * `sources` is mandatory rather than optional because partial failure is the normal case here,
118
+ * not the exception: a panel comparing three sources must be able to distinguish "this source
119
+ * reported zero" from "this source never answered", which a bare payload cannot express.
120
+ */
121
+ export interface ReportEnvelope<T> {
122
+ report: ReportName
123
+ range: DateRange
124
+ /** Per-source outcome. A report may succeed overall while one source is unconfigured. */
125
+ sources: Partial<Record<SourceName, SourceStatus>>
126
+ /** Caveats to surface in the UI, e.g. GA4 sampling or an instrumentation cutover in range. */
127
+ notices: string[]
128
+ data: T
129
+ }
130
+
131
+ /** Error shape returned by the handler. Never leaks upstream error text to the browser. */
132
+ export interface ReportError {
133
+ error: string
134
+ report?: string
135
+ }