@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.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/dist/index.d.mts +155 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +652 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +609 -0
- package/dist/index.mjs.map +1 -0
- package/dist/ranges-D6AZwpmm.d.mts +282 -0
- package/dist/ranges-D6AZwpmm.d.ts +282 -0
- package/dist/server.d.mts +280 -0
- package/dist/server.d.ts +280 -0
- package/dist/server.js +937 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +882 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +68 -0
- package/src/boundary.test.ts +93 -0
- package/src/core/core.test.ts +175 -0
- package/src/core/cutover.ts +109 -0
- package/src/core/ranges.ts +103 -0
- package/src/core/siteConfig.ts +150 -0
- package/src/index.ts +86 -0
- package/src/reportData.ts +120 -0
- package/src/server/auth.ts +133 -0
- package/src/server/cache.ts +75 -0
- package/src/server/createHandler.ts +199 -0
- package/src/server/ga4.ts +149 -0
- package/src/server/googleAuth.ts +139 -0
- package/src/server/orders.ts +127 -0
- package/src/server/reports/acquisition.ts +85 -0
- package/src/server/reports/journey.ts +112 -0
- package/src/server/reports/measurementHealth.ts +170 -0
- package/src/server/reports/typefaceInterest.ts +140 -0
- package/src/server/vercel.ts +68 -0
- package/src/server.ts +27 -0
- package/src/studio/Figure.tsx +175 -0
- package/src/studio/VisitorInsightsTool.tsx +247 -0
- package/src/studio/panels.tsx +233 -0
- package/src/studio/useReport.ts +99 -0
- 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
|
+
}
|