@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,282 @@
1
+ /** Shared contract types for the Studio plugin and the API-route handler it calls. */
2
+ /**
3
+ * Why a metric has no usable number. Kept distinct rather than collapsed into one flag, because
4
+ * the UI must say different things: "this site never tracked it" is permanent, "not yet at this
5
+ * date" resolves as time passes, and "GA4 withheld it" means the number exists but is hidden.
6
+ */
7
+ type UnavailableReason =
8
+ /** The event has never been instrumented on this site. Permanent for historical ranges. */
9
+ 'not_instrumented'
10
+ /** Instrumented, but the requested range predates the cutover date. */
11
+ | 'before_cutover'
12
+ /** GA4 suppressed the row for privacy thresholding. A real number exists; we may not see it. */
13
+ | 'suppressed'
14
+ /** The upstream source errored or timed out. Retryable, unlike the others. */
15
+ | 'source_error'
16
+ /** The metric is not defined for this site at all (e.g. a flow that does not exist there). */
17
+ | 'not_applicable';
18
+ /**
19
+ * A metric that may legitimately have no value.
20
+ *
21
+ * The whole point of this union is that there is no way to read a number without first handling
22
+ * the absent case — a missing metric can never silently coerce to 0 and be charted as a real
23
+ * trough. `partial` carries a number that is only valid for part of the requested range.
24
+ */
25
+ type MetricValue = {
26
+ status: 'ok';
27
+ value: number;
28
+ } | {
29
+ status: 'partial';
30
+ value: number;
31
+ coveredFrom: string;
32
+ note: string;
33
+ } | {
34
+ status: 'unavailable';
35
+ reason: UnavailableReason;
36
+ detail?: string;
37
+ };
38
+ /** Construct an available metric. */
39
+ declare function ok(value: number): MetricValue;
40
+ /** Construct an unavailable metric. */
41
+ declare function unavailable(reason: UnavailableReason, detail?: string): MetricValue;
42
+ /**
43
+ * Construct a metric covering only part of the requested range.
44
+ *
45
+ * @param value - the number for the covered portion
46
+ * @param coveredFrom - ISO date from which the number is valid
47
+ * @param note - short human-readable reason, shown next to the figure
48
+ */
49
+ declare function partial(value: number, coveredFrom: string, note: string): MetricValue;
50
+ /**
51
+ * Read a metric's number, or `null` when there isn't a usable one.
52
+ * Use at render time so the absent case has to be handled explicitly.
53
+ */
54
+ declare function valueOrNull(metric: MetricValue): number | null;
55
+ /** The range granularities offered in the UI. */
56
+ type RangeKey = 'week' | 'quarter' | 'year';
57
+ /**
58
+ * A resolved date range. Both bounds are inclusive ISO `YYYY-MM-DD` dates in `timezone`.
59
+ *
60
+ * `timezone` is carried explicitly because GA4 buckets by the property's configured timezone,
61
+ * Vercel reports in UTC, and Sanity `_createdAt` is UTC — comparing them without a single declared
62
+ * anchor silently shifts events across day and week boundaries.
63
+ */
64
+ interface DateRange {
65
+ key: RangeKey;
66
+ start: string;
67
+ end: string;
68
+ timezone: string;
69
+ }
70
+ /** The report names this package serves. Used as a strict allow-list, never as dynamic dispatch. */
71
+ declare const REPORT_NAMES: readonly ["measurement-health", "acquisition", "journey", "typeface-interest"];
72
+ type ReportName = (typeof REPORT_NAMES)[number];
73
+ /** Narrow an untrusted string to a known report name. */
74
+ declare function isReportName(value: unknown): value is ReportName;
75
+ /** Which upstreams a report consulted, and how each fared. */
76
+ type SourceStatus = {
77
+ status: 'ok';
78
+ } | {
79
+ status: 'unconfigured';
80
+ } | {
81
+ status: 'error';
82
+ message: string;
83
+ };
84
+ /** Upstream data sources this package can read. */
85
+ type SourceName = 'ga4' | 'vercel' | 'sanity';
86
+ /**
87
+ * Envelope around every report response.
88
+ *
89
+ * `sources` is mandatory rather than optional because partial failure is the normal case here,
90
+ * not the exception: a panel comparing three sources must be able to distinguish "this source
91
+ * reported zero" from "this source never answered", which a bare payload cannot express.
92
+ */
93
+ interface ReportEnvelope<T> {
94
+ report: ReportName;
95
+ range: DateRange;
96
+ /** Per-source outcome. A report may succeed overall while one source is unconfigured. */
97
+ sources: Partial<Record<SourceName, SourceStatus>>;
98
+ /** Caveats to surface in the UI, e.g. GA4 sampling or an instrumentation cutover in range. */
99
+ notices: string[];
100
+ data: T;
101
+ }
102
+ /** Error shape returned by the handler. Never leaks upstream error text to the browser. */
103
+ interface ReportError {
104
+ error: string;
105
+ report?: string;
106
+ }
107
+
108
+ /**
109
+ * Event instrumentation cutovers.
110
+ *
111
+ * GA4 cannot backfill events: data from before an event was instrumented does not exist and never
112
+ * will. A range spanning a cutover therefore mixes two measurement regimes, and a chart drawn
113
+ * across it reads as a change in visitor behaviour when it is really a change in what was counted.
114
+ *
115
+ * This lives in the package rather than in each site repo so all three foundries share one model
116
+ * and one set of semantics, and so the Studio panel and the server handler agree by construction.
117
+ */
118
+
119
+ /** An event that predates this work — long-running, with no discontinuity to mark. */
120
+ declare const PREEXISTING: "preexisting";
121
+ /**
122
+ * When an event began firing on a site.
123
+ * `PREEXISTING` = live before this work began. `null` = planned, not yet deployed.
124
+ * An ISO `YYYY-MM-DD` string = the deploy date, set in the commit that ships the event.
125
+ */
126
+ type EventCutover = typeof PREEXISTING | string | null;
127
+ /** How completely an event covers a requested range. */
128
+ type Coverage = {
129
+ status: 'full';
130
+ } | {
131
+ status: 'partial';
132
+ cutover: string;
133
+ } | {
134
+ status: 'none';
135
+ reason: 'not_instrumented' | 'before_cutover' | 'unknown_event';
136
+ cutover?: string;
137
+ };
138
+ /**
139
+ * Determine how well an event covers a date range.
140
+ *
141
+ * @param cutovers - the site's event cutover map
142
+ * @param eventName - GA4 event name to look up
143
+ * @param range - the requested range
144
+ */
145
+ declare function coverageForRange(cutovers: Readonly<Record<string, EventCutover>>, eventName: string, range: Pick<DateRange, 'start' | 'end'>): Coverage;
146
+ /**
147
+ * Wrap a raw count in a MetricValue that reflects the event's coverage of the range.
148
+ *
149
+ * Call this instead of returning a bare number, so a value that only covers part of the range
150
+ * carries that fact with it all the way to the renderer.
151
+ *
152
+ * @param count - the number GA4 returned, or null if the query could not run
153
+ * @param coverage - result of coverageForRange for the same event and range
154
+ */
155
+ declare function applyCoverage(count: number | null, coverage: Coverage): MetricValue;
156
+ /**
157
+ * Human-readable notices for every event in a range that is not fully covered.
158
+ * Surfaced in the report envelope so the panel can show caveats without recomputing them.
159
+ */
160
+ declare function coverageNotices(cutovers: Readonly<Record<string, EventCutover>>, eventNames: readonly string[], range: Pick<DateRange, 'start' | 'end'>): string[];
161
+
162
+ /**
163
+ * Per-site adapter configuration.
164
+ *
165
+ * The three foundries differ in ways that cannot be abstracted away: different GA4 event coverage,
166
+ * different order schemas, different flows that exist on one site and not another. Rather than
167
+ * branching on a site id inside the reports — which would mean editing this package to onboard a
168
+ * fourth foundry — each site describes itself through this config and the reports stay generic.
169
+ */
170
+
171
+ /** GA4 connection for one site. */
172
+ interface Ga4Config {
173
+ /** Numeric GA4 property id. NOT the `G-XXXXXXX` measurement id, which is the client-side one. */
174
+ propertyId: string;
175
+ /** IANA timezone the property is configured with. Every range is anchored to this. */
176
+ timezone: string;
177
+ }
178
+ /** Vercel Web Analytics connection for one site. */
179
+ interface VercelConfig {
180
+ projectId: string;
181
+ teamId?: string;
182
+ }
183
+ /** Where to count orders, and what they are called on this site. */
184
+ interface OrdersConfig {
185
+ /** Document type holding orders. */
186
+ documentType: string;
187
+ /**
188
+ * Field holding the typeface references on an order, if the site has one.
189
+ * Null where orders do not resolve to typefaces in a usable way.
190
+ */
191
+ typefacesField: string | null;
192
+ /**
193
+ * GROQ filter appended to exclude non-typeface orders, e.g. merch-only orders on Darden.
194
+ * Merch inflates a family's apparent purchase count if not excluded.
195
+ */
196
+ excludeFilter?: string;
197
+ }
198
+ /** One site's complete description of itself. */
199
+ interface SiteAnalyticsConfig {
200
+ /** Stable machine id, e.g. `darden`. */
201
+ siteId: string;
202
+ /** Human label shown in the Studio UI. */
203
+ label: string;
204
+ /** Null when GA4 is not wired up for this site — reports degrade rather than fail. */
205
+ ga4: Ga4Config | null;
206
+ /** Null when Vercel Web Analytics is unavailable, e.g. on a plan tier without API access. */
207
+ vercel: VercelConfig | null;
208
+ orders: OrdersConfig;
209
+ /**
210
+ * When each GA4 event began firing here. Events absent from this map report as `unknown_event`,
211
+ * which is the honest answer for a flow the site does not have — distinct from `not_instrumented`,
212
+ * which implies it is merely pending.
213
+ */
214
+ eventCutovers: Record<string, EventCutover>;
215
+ /** Origins allowed to call this site's handler cross-origin, e.g. a separately deployed Studio. */
216
+ allowedStudioOrigins?: string[];
217
+ }
218
+ /** A problem found while validating a site config. */
219
+ interface ConfigProblem {
220
+ field: string;
221
+ message: string;
222
+ }
223
+ /**
224
+ * Validate a site config at runtime.
225
+ *
226
+ * Compile-time types do not survive into a consuming site's `sanity.config.js`, which is plain
227
+ * JavaScript — so a typo'd property id or a measurement id pasted where a numeric property id
228
+ * belongs would otherwise surface as an empty chart rather than an error.
229
+ *
230
+ * @param config - the candidate config
231
+ * @returns the problems found; an empty array means the config is usable
232
+ */
233
+ declare function validateSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): ConfigProblem[];
234
+ /** Whether a string is an IANA timezone this runtime recognises. */
235
+ declare function isValidTimeZone(timeZone: string): boolean;
236
+ /** Throw if a config is unusable, naming every problem at once rather than one per run. */
237
+ declare function assertValidSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): asserts config is SiteAnalyticsConfig;
238
+
239
+ /**
240
+ * Date-range resolution.
241
+ *
242
+ * Every range is resolved against one declared timezone — the GA4 property's — because the three
243
+ * sources bucket time differently: GA4 by the property timezone, Vercel by UTC, Sanity `_createdAt`
244
+ * by UTC. Without a single anchor, an order placed near midnight lands in different weeks depending
245
+ * on which source you ask, which corrupts exactly the cross-source comparison this tool exists for.
246
+ */
247
+
248
+ /** GA4 does not finalise the most recent days. Figures inside this window may still rise. */
249
+ declare const GA4_PROCESSING_LAG_DAYS = 2;
250
+ /** Format a Date as `YYYY-MM-DD` in the given IANA timezone. */
251
+ declare function formatInTimeZone(date: Date, timeZone: string): string;
252
+ /** Shift an ISO `YYYY-MM-DD` date by a whole number of days. */
253
+ declare function shiftDays(isoDate: string, days: number): string;
254
+ /** Inclusive whole days between two ISO dates. */
255
+ declare function daysBetween(start: string, end: string): number;
256
+ /**
257
+ * Resolve a range key into concrete dates, anchored to `timezone` and ending today.
258
+ *
259
+ * @param key - week, quarter or year
260
+ * @param timezone - IANA timezone of the GA4 property this range will be queried against
261
+ * @param now - injectable clock, so tests are deterministic
262
+ */
263
+ declare function resolveRange(key: RangeKey, timezone: string, now?: Date): DateRange;
264
+ /**
265
+ * The equivalent range immediately before `range`, for period-over-period comparison.
266
+ *
267
+ * A bare count answers "how many", which on its own is not actionable; the comparison is what
268
+ * tells someone whether to do anything.
269
+ */
270
+ declare function previousRange(range: DateRange): DateRange;
271
+ /**
272
+ * The trailing dates within a range whose GA4 figures are not yet settled.
273
+ * Returns an empty array when the range ends before the lag window.
274
+ */
275
+ declare function provisionalDates(range: DateRange, now?: Date): string[];
276
+ /**
277
+ * Notice text when a range includes days GA4 has not finished processing, or `null` when it does not.
278
+ * Without this a Monday-morning glance at "this week" reads the trailing dip as a real drop.
279
+ */
280
+ declare function provisionalNotice(range: DateRange, now?: Date): string | null;
281
+
282
+ export { type Coverage as C, type DateRange as D, type EventCutover as E, GA4_PROCESSING_LAG_DAYS as G, type MetricValue as M, type OrdersConfig as O, PREEXISTING as P, type ReportEnvelope as R, type SiteAnalyticsConfig as S, type UnavailableReason as U, type VercelConfig as V, type ReportName as a, type RangeKey as b, REPORT_NAMES as c, type ReportError as d, type SourceName as e, type SourceStatus as f, coverageForRange as g, previousRange as h, isReportName as i, valueOrNull as j, type ConfigProblem as k, type Ga4Config as l, applyCoverage as m, assertValidSiteConfig as n, ok as o, partial as p, coverageNotices as q, resolveRange as r, daysBetween as s, formatInTimeZone as t, unavailable as u, validateSiteConfig as v, isValidTimeZone as w, provisionalDates as x, provisionalNotice as y, shiftDays as z };
@@ -0,0 +1,280 @@
1
+ import { S as SiteAnalyticsConfig, M as MetricValue } from './ranges-D6AZwpmm.mjs';
2
+ export { k as ConfigProblem, C as Coverage, D as DateRange, E as EventCutover, G as GA4_PROCESSING_LAG_DAYS, l as Ga4Config, O as OrdersConfig, P as PREEXISTING, c as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, d as ReportError, a as ReportName, e as SourceName, f as SourceStatus, U as UnavailableReason, V as VercelConfig, m as applyCoverage, n as assertValidSiteConfig, g as coverageForRange, q as coverageNotices, s as daysBetween, t as formatInTimeZone, i as isReportName, w as isValidTimeZone, o as ok, p as partial, h as previousRange, x as provisionalDates, y as provisionalNotice, r as resolveRange, z as shiftDays, u as unavailable, v as validateSiteConfig, j as valueOrNull } from './ranges-D6AZwpmm.mjs';
3
+
4
+ /**
5
+ * Request authentication and CORS for the report handler.
6
+ *
7
+ * The Studio forwards its own Sanity session token and this verifies it against Sanity. A shared
8
+ * secret was the obvious alternative and is the wrong one: the Studio bundle is served publicly, so
9
+ * any secret compiled into it is extractable, which moves the bar from "know the URL" to "open
10
+ * devtools". Verifying a session token instead proves the caller is a logged-in project user, and
11
+ * gives a real identity to log rather than an anonymous caller who read a constant.
12
+ *
13
+ * This mirrors the pattern already in production on Darden's order-action endpoints.
14
+ */
15
+ /** Minimal shape this module needs from a Next.js API request. */
16
+ interface HandlerRequest {
17
+ method?: string;
18
+ url?: string;
19
+ headers: Record<string, string | string[] | undefined>;
20
+ query?: Record<string, string | string[] | undefined>;
21
+ }
22
+ /** Minimal shape this module needs from a Next.js API response. */
23
+ interface HandlerResponse {
24
+ setHeader(name: string, value: string): void;
25
+ status(code: number): HandlerResponse;
26
+ json(body: unknown): void;
27
+ end(): void;
28
+ }
29
+ /** A verified Sanity Studio user. */
30
+ interface StudioUser {
31
+ id: string;
32
+ name?: string;
33
+ email?: string;
34
+ roles?: Array<{
35
+ name: string;
36
+ }>;
37
+ }
38
+ /** Outcome of verifying a request's Studio token. */
39
+ type VerifyResult = {
40
+ ok: true;
41
+ user: StudioUser;
42
+ } | {
43
+ ok: false;
44
+ reason: string;
45
+ };
46
+ /**
47
+ * Verify that a request carries a valid Sanity user token for this project.
48
+ *
49
+ * @param req - the incoming request
50
+ * @param sanityProjectId - project the token must belong to
51
+ * @returns ok with the resolved user, or a reason suitable for logging but never for the response
52
+ */
53
+ declare function verifyStudioRequest(req: HandlerRequest, sanityProjectId: string): Promise<VerifyResult>;
54
+ /**
55
+ * Apply CORS headers for a Studio-originated request and answer the preflight.
56
+ *
57
+ * Echoes one allow-listed origin rather than sending `*`. A wildcard is acceptable for public HTML
58
+ * but not for an endpoint that takes an Authorization header and returns business data.
59
+ *
60
+ * @param req - the incoming request
61
+ * @param res - the response to decorate
62
+ * @param allowedOrigins - exact origins permitted to call cross-origin
63
+ * @returns true when the request was a preflight and is now fully answered
64
+ */
65
+ declare function applyCors(req: HandlerRequest, res: HandlerResponse, allowedOrigins: readonly string[]): boolean;
66
+ /**
67
+ * Guard a report endpoint. Responds 401 and returns null when the caller cannot be verified.
68
+ *
69
+ * @returns the verified user, or null when the request has already been answered with a 401
70
+ */
71
+ declare function requireStudioUser(req: HandlerRequest, res: HandlerResponse, sanityProjectId: string): Promise<StudioUser | null>;
72
+
73
+ /** What this module needs from a Sanity client — kept minimal so it is trivial to stub in tests. */
74
+ interface SanityQueryClient {
75
+ fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>;
76
+ }
77
+
78
+ /**
79
+ * The mountable Next.js API-route handler.
80
+ *
81
+ * Ships from this package rather than being copy-pasted into each site so the three foundries
82
+ * cannot drift apart: a fix applied here reaches all of them on a version bump, instead of being
83
+ * applied to one repo and forgotten in the other two. A consuming site's route is then:
84
+ *
85
+ * // pages/api/visitor-insights/[report].js
86
+ * import { createVisitorInsightsHandler } from '@liiift-studio/sanity-visitor-insights/server'
87
+ * export default createVisitorInsightsHandler({ config: mySiteConfig })
88
+ *
89
+ * Credentials are read from the environment inside this module and never leave the server.
90
+ */
91
+
92
+ /** Environment variables this handler reads. Names are fixed so all three sites match. */
93
+ declare const ENV_VARS: {
94
+ /** Service-account JSON (raw or base64) with Viewer on the GA4 property. */
95
+ readonly googleServiceAccount: "VISITOR_INSIGHTS_GA4_SERVICE_ACCOUNT";
96
+ /** Vercel API token with read access to the project. */
97
+ readonly vercelToken: "VISITOR_INSIGHTS_VERCEL_TOKEN";
98
+ };
99
+ /** Options for building a handler. */
100
+ interface HandlerOptions {
101
+ /** This site's description of itself. */
102
+ config: SiteAnalyticsConfig;
103
+ /**
104
+ * Sanity client used for order counts. Supply the site's existing server-side client so this
105
+ * package does not need its own Sanity credentials.
106
+ */
107
+ sanityClient?: SanityQueryClient;
108
+ /** Sanity project id used to verify Studio tokens. Defaults to `SANITY_STUDIO_PROJECT_ID`. */
109
+ sanityProjectId?: string;
110
+ /** Cache lifetime for report responses. */
111
+ cacheTtlMs?: number;
112
+ }
113
+ /**
114
+ * Build the API-route handler for a site.
115
+ *
116
+ * @param options - the site config and its Sanity client
117
+ * @returns a Next.js Pages Router API handler
118
+ */
119
+ declare function createVisitorInsightsHandler(options: HandlerOptions): (req: HandlerRequest, res: HandlerResponse) => Promise<void>;
120
+
121
+ /** Empty the cache. Test seam, and useful after a config change. */
122
+ declare function clearCache(): void;
123
+
124
+ /**
125
+ * Report result shapes, shared by the server that produces them and the panels that render them.
126
+ *
127
+ * These live outside both entry points on purpose. If the panels imported them from
128
+ * `src/server/reports/*` — even as `import type`, which does erase at build time — the module
129
+ * graph would still contain a client-to-server edge, and the guarantee that credential-reading
130
+ * code cannot reach a Studio bundle would rest on a compiler detail rather than on structure.
131
+ */
132
+
133
+ /** How much of reality each source sees. Pageviews compare like with like; the rest is context. */
134
+ interface MeasurementHealthData {
135
+ /** GA4 pageviews — directly comparable to `vercelPageviews`. */
136
+ ga4Pageviews: MetricValue;
137
+ /** Vercel pageviews — cookieless, not consent-gated. */
138
+ vercelPageviews: MetricValue;
139
+ /**
140
+ * Vercel minus GA4 pageviews, as a share of Vercel. Positive means GA4 saw less.
141
+ * Null when either side is unavailable — never computed against a missing operand.
142
+ */
143
+ shortfallRatio: number | null;
144
+ /** GA4 sessions. Context only: a session is not a pageview and is never subtracted from one. */
145
+ ga4Sessions: MetricValue;
146
+ /** Orders in range. Ground truth for conversions, shown as context. */
147
+ orders: MetricValue;
148
+ /** Share of sessions that granted consent, where the event exists. */
149
+ consentRate: MetricValue;
150
+ /** Plain-language reading of the numbers above, for a non-analyst audience. */
151
+ interpretation: string;
152
+ }
153
+ /** One acquisition source. */
154
+ interface SourceRow {
155
+ /** GA4 `sessionSource` value. */
156
+ source: string;
157
+ /** GA4 `sessionDefaultChannelGroup`, e.g. Organic Search. */
158
+ channel: string;
159
+ sessions: number;
160
+ /** True when this source is one of the design-industry referrers. */
161
+ designIndustry: boolean;
162
+ /** True for GA4's `(not set)` / `(direct)` style buckets, which are not real sources. */
163
+ unattributed: boolean;
164
+ }
165
+ /** Where visitors came from. */
166
+ interface AcquisitionData {
167
+ rows: SourceRow[];
168
+ totalSessions: number;
169
+ /** Sessions from design-industry referrers, as a share of the total. */
170
+ designIndustryShare: number | null;
171
+ /** Sessions GA4 could not attribute, as a share of the total. */
172
+ unattributedShare: number | null;
173
+ /** True when GA4 withheld low-count rows, so the tail is shorter than reality. */
174
+ rowsWithheld: boolean;
175
+ }
176
+ /** One rung of the funnel. */
177
+ interface JourneyStep {
178
+ key: string;
179
+ label: string;
180
+ event: string;
181
+ count: MetricValue;
182
+ /**
183
+ * Share of the previous measurable step that reached this one.
184
+ * Null when either end is unavailable — a drop-off across an unmeasured step is meaningless.
185
+ */
186
+ conversionFromPrevious: number | null;
187
+ }
188
+ /** A page where sessions commonly ended. */
189
+ interface ExitPage {
190
+ path: string;
191
+ exits: number;
192
+ }
193
+ /** How far visitors get. Per-step totals, never an observed path. */
194
+ interface JourneyData {
195
+ steps: JourneyStep[];
196
+ topExitPages: ExitPage[];
197
+ /** Always true. The UI must not present these steps as a tracked journey. */
198
+ approximate: true;
199
+ /** Why it is approximate, in words the panel can show directly. */
200
+ approximationNote: string;
201
+ }
202
+ /** One family's interest figures. */
203
+ interface TypefaceInterestRow {
204
+ typeface: string;
205
+ viewed: MetricValue;
206
+ tested: MetricValue;
207
+ bought: MetricValue;
208
+ /** Tested divided by viewed. Null unless both are real numbers. */
209
+ testRate: number | null;
210
+ }
211
+ /** Viewed, tested and bought, by family. */
212
+ interface TypefaceInterestData {
213
+ rows: TypefaceInterestRow[];
214
+ /** Stated in the response so the UI cannot omit it. */
215
+ interpretationNote: string;
216
+ /** True when GA4 withheld low-count rows, so quiet families may be missing entirely. */
217
+ rowsWithheld: boolean;
218
+ }
219
+
220
+ /**
221
+ * Acquisition — where visitors come from.
222
+ *
223
+ * Design-industry referrers are pulled out as a named segment rather than left in a generic
224
+ * referrer table. A visit from Fonts In Use or Typewolf is a pre-qualified, industry-literate
225
+ * visitor and behaves nothing like average organic traffic; burying those rows among search and
226
+ * social discards the distinction a foundry actually acts on.
227
+ *
228
+ * `(not set)` rows are surfaced rather than swept into "other". On a type-foundry site they can be
229
+ * a large share — bookmarked visits, stripped referrers, AI crawlers — and hiding them makes the
230
+ * table look more complete than it is.
231
+ */
232
+
233
+ /** Referrer hosts that identify a design-industry source worth tracking separately. */
234
+ declare const DESIGN_INDUSTRY_SOURCES: readonly ["fontsinuse.com", "typewolf.com", "typographica.org", "fonts.google.com", "behance.net", "dribbble.com", "itsnicethat.com"];
235
+
236
+ /**
237
+ * Journey — how far visitors get, and where they stop.
238
+ *
239
+ * This is an ordered step funnel, not a path graph, and that is a deliberate limit rather than a
240
+ * simplification. GA4's Data API has no path-exploration endpoint; Path Exploration is a UI-only
241
+ * feature. What is available is per-event totals and `runFunnelReport`, an alpha surface that
242
+ * returns step-conversion marginals — not observed sequences. Rendering ribbons from marginals
243
+ * would assert that a given visitor went A to B to C when no such co-occurrence was ever measured.
244
+ *
245
+ * So each step is reported as its own honest total, adjacent drop-off is derived between steps, and
246
+ * the response is explicitly flagged as an approximation for the UI to display.
247
+ *
248
+ * A step whose event is not instrumented on this site reports as unavailable, never as zero. On a
249
+ * site missing `begin_checkout`, the cart-to-checkout drop-off is not "100% drop-off" — it is
250
+ * unmeasured, and the two must not look alike.
251
+ */
252
+
253
+ /** The funnel, in order. Each entry names the GA4 event that evidences the step. */
254
+ declare const JOURNEY_STEPS: readonly [{
255
+ readonly key: "landed";
256
+ readonly label: "Landed";
257
+ readonly event: "page_view";
258
+ }, {
259
+ readonly key: "viewed_typeface";
260
+ readonly label: "Viewed a typeface";
261
+ readonly event: "view_item";
262
+ }, {
263
+ readonly key: "tested";
264
+ readonly label: "Used the type tester";
265
+ readonly event: "tester_engaged";
266
+ }, {
267
+ readonly key: "added_to_cart";
268
+ readonly label: "Added to cart";
269
+ readonly event: "add_to_cart";
270
+ }, {
271
+ readonly key: "began_checkout";
272
+ readonly label: "Began checkout";
273
+ readonly event: "begin_checkout";
274
+ }, {
275
+ readonly key: "purchased";
276
+ readonly label: "Purchased";
277
+ readonly event: "purchase";
278
+ }];
279
+
280
+ export { type AcquisitionData, DESIGN_INDUSTRY_SOURCES, ENV_VARS, type ExitPage, type HandlerOptions, type HandlerRequest, type HandlerResponse, JOURNEY_STEPS, type JourneyData, type JourneyStep, type MeasurementHealthData, MetricValue, type SanityQueryClient, SiteAnalyticsConfig, type SourceRow, type StudioUser, type TypefaceInterestData, type TypefaceInterestRow, applyCors, clearCache, createVisitorInsightsHandler, requireStudioUser, verifyStudioRequest };