@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
package/dist/server.js ADDED
@@ -0,0 +1,937 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/server.ts
21
+ var server_exports = {};
22
+ __export(server_exports, {
23
+ DESIGN_INDUSTRY_SOURCES: () => DESIGN_INDUSTRY_SOURCES,
24
+ ENV_VARS: () => ENV_VARS,
25
+ GA4_PROCESSING_LAG_DAYS: () => GA4_PROCESSING_LAG_DAYS,
26
+ JOURNEY_STEPS: () => JOURNEY_STEPS,
27
+ PREEXISTING: () => PREEXISTING,
28
+ REPORT_NAMES: () => REPORT_NAMES,
29
+ applyCors: () => applyCors,
30
+ applyCoverage: () => applyCoverage,
31
+ assertValidSiteConfig: () => assertValidSiteConfig,
32
+ clearCache: () => clearCache,
33
+ coverageForRange: () => coverageForRange,
34
+ coverageNotices: () => coverageNotices,
35
+ createVisitorInsightsHandler: () => createVisitorInsightsHandler,
36
+ daysBetween: () => daysBetween,
37
+ formatInTimeZone: () => formatInTimeZone,
38
+ isReportName: () => isReportName,
39
+ isValidTimeZone: () => isValidTimeZone,
40
+ ok: () => ok,
41
+ partial: () => partial,
42
+ previousRange: () => previousRange,
43
+ provisionalDates: () => provisionalDates,
44
+ provisionalNotice: () => provisionalNotice,
45
+ requireStudioUser: () => requireStudioUser,
46
+ resolveRange: () => resolveRange,
47
+ shiftDays: () => shiftDays,
48
+ unavailable: () => unavailable,
49
+ validateSiteConfig: () => validateSiteConfig,
50
+ valueOrNull: () => valueOrNull,
51
+ verifyStudioRequest: () => verifyStudioRequest
52
+ });
53
+ module.exports = __toCommonJS(server_exports);
54
+
55
+ // src/types.ts
56
+ function ok(value) {
57
+ return { status: "ok", value };
58
+ }
59
+ function unavailable(reason, detail) {
60
+ return { status: "unavailable", reason, detail };
61
+ }
62
+ function partial(value, coveredFrom, note) {
63
+ return { status: "partial", value, coveredFrom, note };
64
+ }
65
+ function valueOrNull(metric) {
66
+ return metric.status === "unavailable" ? null : metric.value;
67
+ }
68
+ var REPORT_NAMES = [
69
+ "measurement-health",
70
+ "acquisition",
71
+ "journey",
72
+ "typeface-interest"
73
+ ];
74
+ function isReportName(value) {
75
+ return typeof value === "string" && REPORT_NAMES.includes(value);
76
+ }
77
+
78
+ // src/core/siteConfig.ts
79
+ function validateSiteConfig(config) {
80
+ const problems = [];
81
+ if (!config) {
82
+ return [{ field: "config", message: "No site config supplied" }];
83
+ }
84
+ if (!config.siteId) problems.push({ field: "siteId", message: "Required" });
85
+ if (!config.label) problems.push({ field: "label", message: "Required" });
86
+ if (config.ga4) {
87
+ const { propertyId, timezone } = config.ga4;
88
+ if (!propertyId) {
89
+ problems.push({ field: "ga4.propertyId", message: "Required when ga4 is configured" });
90
+ } else if (propertyId.startsWith("G-")) {
91
+ problems.push({
92
+ field: "ga4.propertyId",
93
+ message: `Looks like a measurement id ("${propertyId}"). The Data API needs the numeric property id from GA4 Admin instead.`
94
+ });
95
+ } else if (!/^\d+$/.test(propertyId)) {
96
+ problems.push({ field: "ga4.propertyId", message: "Must be the numeric GA4 property id" });
97
+ }
98
+ if (!timezone) {
99
+ problems.push({ field: "ga4.timezone", message: "Required \u2014 ranges are anchored to it" });
100
+ } else if (!isValidTimeZone(timezone)) {
101
+ problems.push({ field: "ga4.timezone", message: `Not a recognised IANA timezone: ${timezone}` });
102
+ }
103
+ }
104
+ if (config.vercel && !config.vercel.projectId) {
105
+ problems.push({ field: "vercel.projectId", message: "Required when vercel is configured" });
106
+ }
107
+ if (!config.orders?.documentType) {
108
+ problems.push({ field: "orders.documentType", message: "Required" });
109
+ }
110
+ if (!config.eventCutovers) {
111
+ problems.push({ field: "eventCutovers", message: "Required \u2014 omit events the site does not have" });
112
+ }
113
+ for (const origin of config.allowedStudioOrigins ?? []) {
114
+ if (!/^https?:\/\//.test(origin)) {
115
+ problems.push({ field: "allowedStudioOrigins", message: `Must be a full origin including scheme: ${origin}` });
116
+ }
117
+ if (origin.endsWith("/")) {
118
+ problems.push({ field: "allowedStudioOrigins", message: `Must not have a trailing slash: ${origin}` });
119
+ }
120
+ }
121
+ return problems;
122
+ }
123
+ function isValidTimeZone(timeZone) {
124
+ try {
125
+ new Intl.DateTimeFormat("en-CA", { timeZone });
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+ function assertValidSiteConfig(config) {
132
+ const problems = validateSiteConfig(config);
133
+ if (problems.length > 0) {
134
+ const detail = problems.map((p) => ` ${p.field}: ${p.message}`).join("\n");
135
+ throw new Error(`Invalid visitor-insights site config:
136
+ ${detail}`);
137
+ }
138
+ }
139
+
140
+ // src/core/cutover.ts
141
+ var PREEXISTING = "preexisting";
142
+ function coverageForRange(cutovers, eventName, range) {
143
+ if (!(eventName in cutovers)) return { status: "none", reason: "unknown_event" };
144
+ const cutover = cutovers[eventName];
145
+ if (cutover === null || cutover === void 0) return { status: "none", reason: "not_instrumented" };
146
+ if (cutover === PREEXISTING) return { status: "full" };
147
+ const cutoverTime = Date.parse(cutover);
148
+ if (Number.isNaN(cutoverTime)) return { status: "none", reason: "unknown_event" };
149
+ if (Date.parse(range.end) < cutoverTime) return { status: "none", reason: "before_cutover", cutover };
150
+ if (Date.parse(range.start) < cutoverTime) return { status: "partial", cutover };
151
+ return { status: "full" };
152
+ }
153
+ function applyCoverage(count, coverage) {
154
+ if (coverage.status === "none") {
155
+ return unavailable(
156
+ coverage.reason === "before_cutover" ? "before_cutover" : "not_instrumented",
157
+ coverage.cutover ? `Instrumented from ${coverage.cutover}` : void 0
158
+ );
159
+ }
160
+ if (count === null) return unavailable("source_error");
161
+ if (coverage.status === "partial") {
162
+ return partial(count, coverage.cutover, `Only counted from ${coverage.cutover}, when this event was added`);
163
+ }
164
+ return { status: "ok", value: count };
165
+ }
166
+ function coverageNotices(cutovers, eventNames, range) {
167
+ const notices = [];
168
+ for (const name of eventNames) {
169
+ const coverage = coverageForRange(cutovers, name, range);
170
+ if (coverage.status === "partial") {
171
+ notices.push(`${name} was instrumented on ${coverage.cutover}; figures before that date are missing, not zero.`);
172
+ } else if (coverage.status === "none" && coverage.reason === "not_instrumented") {
173
+ notices.push(`${name} is not instrumented on this site, so it cannot be reported for any range.`);
174
+ } else if (coverage.status === "none" && coverage.reason === "before_cutover") {
175
+ notices.push(`${name} did not exist during this range; it was instrumented on ${coverage.cutover}.`);
176
+ }
177
+ }
178
+ return notices;
179
+ }
180
+
181
+ // src/core/ranges.ts
182
+ var GA4_PROCESSING_LAG_DAYS = 2;
183
+ function formatInTimeZone(date, timeZone) {
184
+ const parts = new Intl.DateTimeFormat("en-CA", {
185
+ timeZone,
186
+ year: "numeric",
187
+ month: "2-digit",
188
+ day: "2-digit"
189
+ }).formatToParts(date);
190
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
191
+ return `${get("year")}-${get("month")}-${get("day")}`;
192
+ }
193
+ function shiftDays(isoDate, days) {
194
+ const time = Date.parse(`${isoDate}T00:00:00Z`);
195
+ if (Number.isNaN(time)) throw new Error(`Invalid ISO date: ${isoDate}`);
196
+ return new Date(time + days * 864e5).toISOString().slice(0, 10);
197
+ }
198
+ function daysBetween(start, end) {
199
+ return Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 864e5) + 1;
200
+ }
201
+ var RANGE_DAYS = {
202
+ week: 7,
203
+ quarter: 91,
204
+ year: 365
205
+ };
206
+ function resolveRange(key, timezone, now = /* @__PURE__ */ new Date()) {
207
+ const end = formatInTimeZone(now, timezone);
208
+ const start = shiftDays(end, -(RANGE_DAYS[key] - 1));
209
+ return { key, start, end, timezone };
210
+ }
211
+ function previousRange(range) {
212
+ const span = daysBetween(range.start, range.end);
213
+ return {
214
+ key: range.key,
215
+ start: shiftDays(range.start, -span),
216
+ end: shiftDays(range.end, -span),
217
+ timezone: range.timezone
218
+ };
219
+ }
220
+ function provisionalDates(range, now = /* @__PURE__ */ new Date()) {
221
+ const today = formatInTimeZone(now, range.timezone);
222
+ const dates = [];
223
+ for (let i = 0; i < GA4_PROCESSING_LAG_DAYS; i += 1) {
224
+ const date = shiftDays(today, -i);
225
+ if (date >= range.start && date <= range.end) dates.push(date);
226
+ }
227
+ return dates.sort();
228
+ }
229
+ function provisionalNotice(range, now = /* @__PURE__ */ new Date()) {
230
+ const dates = provisionalDates(range, now);
231
+ if (dates.length === 0) return null;
232
+ const from = dates[0];
233
+ return `GA4 has not finished processing ${from} onwards; those figures are provisional and will rise.`;
234
+ }
235
+
236
+ // src/server/auth.ts
237
+ function header(req, name) {
238
+ const raw = req.headers[name] ?? req.headers[name.toLowerCase()];
239
+ return Array.isArray(raw) ? raw[0] : raw;
240
+ }
241
+ async function verifyStudioRequest(req, sanityProjectId) {
242
+ const authorization = header(req, "authorization") ?? "";
243
+ const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : null;
244
+ if (!token) return { ok: false, reason: "no-token" };
245
+ if (!sanityProjectId) {
246
+ console.error("Visitor insights: Sanity project id not configured \u2014 cannot verify Studio requests");
247
+ return { ok: false, reason: "not-configured" };
248
+ }
249
+ try {
250
+ const response = await fetch(`https://${sanityProjectId}.api.sanity.io/v2021-06-07/users/me`, {
251
+ headers: { Authorization: `Bearer ${token}` }
252
+ });
253
+ if (!response.ok) return { ok: false, reason: `sanity-${response.status}` };
254
+ const user = await response.json();
255
+ if (!user || !user.id) return { ok: false, reason: "no-user" };
256
+ return { ok: true, user };
257
+ } catch (e) {
258
+ console.error("Visitor insights: Studio token verification failed:", e.message);
259
+ return { ok: false, reason: "verify-error" };
260
+ }
261
+ }
262
+ function applyCors(req, res, allowedOrigins) {
263
+ const origin = header(req, "origin");
264
+ if (origin && allowedOrigins.includes(origin)) {
265
+ res.setHeader("Access-Control-Allow-Origin", origin);
266
+ res.setHeader("Vary", "Origin");
267
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
268
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
269
+ res.setHeader("Access-Control-Max-Age", "600");
270
+ }
271
+ if (req.method === "OPTIONS") {
272
+ res.status(204).end();
273
+ return true;
274
+ }
275
+ return false;
276
+ }
277
+ async function requireStudioUser(req, res, sanityProjectId) {
278
+ const result = await verifyStudioRequest(req, sanityProjectId);
279
+ if (result.ok) return result.user;
280
+ console.error(`Visitor insights: rejected unauthenticated request (${result.reason}) on ${req.url ?? "unknown"}`);
281
+ res.status(401).json({ error: "Not authorised. Sign in to the Studio and try again." });
282
+ return null;
283
+ }
284
+
285
+ // src/server/googleAuth.ts
286
+ var import_node_crypto = require("crypto");
287
+ var ANALYTICS_READONLY_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
288
+ var TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
289
+ var ASSERTION_TTL_SECONDS = 3600;
290
+ var REFRESH_MARGIN_SECONDS = 60;
291
+ var tokenCache = /* @__PURE__ */ new Map();
292
+ function base64url(input) {
293
+ return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
294
+ }
295
+ function parseServiceAccountKey(raw) {
296
+ if (!raw) return null;
297
+ let text = raw.trim();
298
+ if (!text.startsWith("{")) {
299
+ try {
300
+ text = Buffer.from(text, "base64").toString("utf8");
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+ try {
306
+ const parsed = JSON.parse(text);
307
+ if (!parsed.client_email || !parsed.private_key) return null;
308
+ return {
309
+ client_email: parsed.client_email,
310
+ // Escaped newlines survive most env-var round trips; real newlines do not always.
311
+ private_key: parsed.private_key.replace(/\\n/g, "\n")
312
+ };
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+ async function getAccessToken(key) {
318
+ const now = Math.floor(Date.now() / 1e3);
319
+ const cached = tokenCache.get(key.client_email);
320
+ if (cached && cached.expiresAt - REFRESH_MARGIN_SECONDS > now) {
321
+ return cached.token;
322
+ }
323
+ const header2 = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
324
+ const claims = base64url(
325
+ JSON.stringify({
326
+ iss: key.client_email,
327
+ scope: ANALYTICS_READONLY_SCOPE,
328
+ aud: TOKEN_ENDPOINT,
329
+ exp: now + ASSERTION_TTL_SECONDS,
330
+ iat: now
331
+ })
332
+ );
333
+ const signer = (0, import_node_crypto.createSign)("RSA-SHA256");
334
+ signer.update(`${header2}.${claims}`);
335
+ const signature = base64url(signer.sign(key.private_key));
336
+ const assertion = `${header2}.${claims}.${signature}`;
337
+ const response = await fetch(TOKEN_ENDPOINT, {
338
+ method: "POST",
339
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
340
+ body: new URLSearchParams({
341
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
342
+ assertion
343
+ })
344
+ });
345
+ if (!response.ok) {
346
+ throw new Error(`Google token exchange failed with ${response.status}`);
347
+ }
348
+ const body = await response.json();
349
+ if (!body.access_token) throw new Error("Google token exchange returned no access token");
350
+ tokenCache.set(key.client_email, {
351
+ token: body.access_token,
352
+ expiresAt: now + (body.expires_in ?? ASSERTION_TTL_SECONDS)
353
+ });
354
+ return body.access_token;
355
+ }
356
+
357
+ // src/server/ga4.ts
358
+ var DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
359
+ function toMetricNumber(raw) {
360
+ if (raw === void 0 || raw === "") return Number.NaN;
361
+ const parsed = Number(raw);
362
+ return Number.isFinite(parsed) ? parsed : Number.NaN;
363
+ }
364
+ function parseReport(raw) {
365
+ const rows = (raw.rows ?? []).map((row) => ({
366
+ dimensions: (row.dimensionValues ?? []).map((d) => d.value ?? ""),
367
+ metrics: (row.metricValues ?? []).map((m) => toMetricNumber(m.value))
368
+ }));
369
+ return {
370
+ rows,
371
+ thresholded: raw.metadata?.subjectToThresholding === true,
372
+ sampled: Array.isArray(raw.metadata?.samplingMetadatas) && raw.metadata.samplingMetadatas.length > 0,
373
+ rowCount: raw.rowCount ?? rows.length
374
+ };
375
+ }
376
+ function createGa4Client(propertyId, key) {
377
+ async function post(path, body) {
378
+ const token = await getAccessToken(key);
379
+ const response = await fetch(`${DATA_API_BASE}/properties/${propertyId}:${path}`, {
380
+ method: "POST",
381
+ headers: {
382
+ Authorization: `Bearer ${token}`,
383
+ "Content-Type": "application/json"
384
+ },
385
+ body: JSON.stringify(body)
386
+ });
387
+ if (!response.ok) {
388
+ throw new Error(`GA4 ${path} failed with ${response.status}`);
389
+ }
390
+ return await response.json();
391
+ }
392
+ return {
393
+ async runReport(request) {
394
+ const raw = await post("runReport", request);
395
+ return parseReport(raw);
396
+ },
397
+ /**
398
+ * Run several reports in one call. Preferred over parallel runReport calls: it is one
399
+ * quota-charged request and one round trip rather than N of each.
400
+ */
401
+ async batchRunReports(requests) {
402
+ if (requests.length === 0) return [];
403
+ const raw = await post("batchRunReports", { requests });
404
+ return (raw.reports ?? []).map(parseReport);
405
+ }
406
+ };
407
+ }
408
+ function eventNameFilter(eventName) {
409
+ return {
410
+ filter: {
411
+ fieldName: "eventName",
412
+ stringFilter: { matchType: "EXACT", value: eventName }
413
+ }
414
+ };
415
+ }
416
+ function sumFirstMetric(report) {
417
+ return report.rows.reduce((total, row) => {
418
+ const value = row.metrics[0];
419
+ return value !== void 0 && Number.isFinite(value) ? total + value : total;
420
+ }, 0);
421
+ }
422
+
423
+ // src/server/vercel.ts
424
+ var API_BASE = "https://api.vercel.com";
425
+ function createVercelClient(projectId, token, teamId) {
426
+ return {
427
+ async pageviews(start, end) {
428
+ const params = new URLSearchParams({
429
+ projectId,
430
+ since: `${start}T00:00:00.000Z`,
431
+ until: `${end}T23:59:59.999Z`
432
+ });
433
+ if (teamId) params.set("teamId", teamId);
434
+ const response = await fetch(`${API_BASE}/v1/web-analytics/timeseries?${params.toString()}`, {
435
+ headers: { Authorization: `Bearer ${token}` }
436
+ });
437
+ if (!response.ok) {
438
+ throw new Error(`Vercel Web Analytics failed with ${response.status}`);
439
+ }
440
+ const body = await response.json();
441
+ const byDate = {};
442
+ let total = 0;
443
+ for (const point of body.data ?? []) {
444
+ if (!point.key) continue;
445
+ const date = point.key.slice(0, 10);
446
+ const count = typeof point.total === "number" ? point.total : 0;
447
+ byDate[date] = (byDate[date] ?? 0) + count;
448
+ total += count;
449
+ }
450
+ return { byDate, total };
451
+ }
452
+ };
453
+ }
454
+
455
+ // src/server/cache.ts
456
+ var DEFAULT_TTL_MS = 5 * 60 * 1e3;
457
+ var MAX_ENTRIES = 200;
458
+ var store = /* @__PURE__ */ new Map();
459
+ function cacheKey(parts) {
460
+ return parts.map((p) => String(p ?? "")).join("|");
461
+ }
462
+ function getCached(key) {
463
+ const entry = store.get(key);
464
+ if (!entry) return void 0;
465
+ if (entry.expiresAt <= Date.now()) {
466
+ store.delete(key);
467
+ return void 0;
468
+ }
469
+ return entry.value;
470
+ }
471
+ function setCached(key, value, ttlMs = DEFAULT_TTL_MS) {
472
+ if (store.size >= MAX_ENTRIES && !store.has(key)) {
473
+ const oldest = store.keys().next().value;
474
+ if (oldest !== void 0) store.delete(oldest);
475
+ }
476
+ store.set(key, { value, expiresAt: Date.now() + ttlMs });
477
+ }
478
+ async function withCache(key, ttlMs, compute) {
479
+ const hit = getCached(key);
480
+ if (hit !== void 0) return hit;
481
+ const value = await compute();
482
+ setCached(key, value, ttlMs);
483
+ return value;
484
+ }
485
+ function clearCache() {
486
+ store.clear();
487
+ }
488
+
489
+ // src/server/orders.ts
490
+ var SAFE_ORDER_PROJECTION = "{ _createdAt, orderStatus }";
491
+ function orderFilter(documentType, excludeFilter) {
492
+ const clauses = [
493
+ `_type == $documentType`,
494
+ `_createdAt >= $start`,
495
+ `_createdAt <= $end`
496
+ ];
497
+ if (excludeFilter) clauses.push(`(${excludeFilter})`);
498
+ return clauses.join(" && ");
499
+ }
500
+ async function countOrders(client, documentType, start, end, excludeFilter) {
501
+ const query = `*[${orderFilter(documentType, excludeFilter)}]${SAFE_ORDER_PROJECTION}`;
502
+ const orders = await client.fetch(query, {
503
+ documentType,
504
+ start: `${start}T00:00:00Z`,
505
+ end: `${end}T23:59:59Z`
506
+ });
507
+ const byDate = {};
508
+ for (const order of orders) {
509
+ const date = order._createdAt.slice(0, 10);
510
+ byDate[date] = (byDate[date] ?? 0) + 1;
511
+ }
512
+ return { byDate, total: orders.length };
513
+ }
514
+ async function countOrdersByTypeface(client, documentType, typefacesField, start, end, excludeFilter) {
515
+ if (!typefacesField) return null;
516
+ const query = `*[${orderFilter(documentType, excludeFilter)}]{
517
+ "typefaces": ${typefacesField}[]->{ title }
518
+ }`;
519
+ const orders = await client.fetch(query, {
520
+ documentType,
521
+ start: `${start}T00:00:00Z`,
522
+ end: `${end}T23:59:59Z`
523
+ });
524
+ const byTypeface = {};
525
+ let total = 0;
526
+ for (const order of orders) {
527
+ for (const typeface of order.typefaces ?? []) {
528
+ const title = typeface?.title;
529
+ if (!title) continue;
530
+ byTypeface[title] = (byTypeface[title] ?? 0) + 1;
531
+ total += 1;
532
+ }
533
+ }
534
+ return { byTypeface, total };
535
+ }
536
+
537
+ // src/server/reports/measurementHealth.ts
538
+ var CONSENT_EVENT = "consent_granted";
539
+ function interpret(ga4Views, vercelViews, shortfall, consent) {
540
+ if (ga4Views.status === "unavailable" && vercelViews.status === "unavailable") {
541
+ return "Neither source answered, so nothing can be said about coverage for this range.";
542
+ }
543
+ if (shortfall === null) {
544
+ return "Only one pageview source answered, so the two cannot be compared for this range.";
545
+ }
546
+ const percent = Math.round(Math.abs(shortfall) * 100);
547
+ if (Math.abs(shortfall) < 0.05) {
548
+ return `GA4 and Vercel agree to within ${percent}% on pageviews. Nothing here suggests a measurement problem.`;
549
+ }
550
+ const direction = shortfall > 0 ? "fewer" : "more";
551
+ const base = `GA4 recorded ${percent}% ${direction} pageviews than Vercel.`;
552
+ if (consent.status === "unavailable") {
553
+ return `${base} How much of that is consent refusal cannot be measured until the consent_granted event is instrumented, so the difference is currently unexplained rather than attributed.`;
554
+ }
555
+ const consentPercent = Math.round((1 - consent.value / 100) * 100);
556
+ return `${base} Around ${consentPercent}% of sessions did not grant analytics consent, which accounts for part of it. The remainder is unexplained \u2014 ad-blocking and bot filtering are plausible but are not separately measurable.`;
557
+ }
558
+ async function measurementHealth(input) {
559
+ const { config, range, ga4, vercel, sanity } = input;
560
+ let ga4Pageviews = unavailable("source_error", "GA4 not configured");
561
+ let ga4Sessions = unavailable("source_error", "GA4 not configured");
562
+ let consentRate = unavailable("not_instrumented");
563
+ if (ga4) {
564
+ try {
565
+ const [views, sessions, consent] = await ga4.batchRunReports([
566
+ {
567
+ metrics: [{ name: "screenPageViews" }],
568
+ dateRanges: [{ startDate: range.start, endDate: range.end }]
569
+ },
570
+ {
571
+ metrics: [{ name: "sessions" }],
572
+ dateRanges: [{ startDate: range.start, endDate: range.end }]
573
+ },
574
+ {
575
+ metrics: [{ name: "eventCount" }],
576
+ dimensions: [{ name: "eventName" }],
577
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
578
+ dimensionFilter: {
579
+ filter: {
580
+ fieldName: "eventName",
581
+ stringFilter: { matchType: "EXACT", value: CONSENT_EVENT }
582
+ }
583
+ }
584
+ }
585
+ ]);
586
+ if (views) ga4Pageviews = ok(sumFirstMetric(views));
587
+ if (sessions) ga4Sessions = ok(sumFirstMetric(sessions));
588
+ const consentCoverage = coverageForRange(config.eventCutovers, CONSENT_EVENT, range);
589
+ if (consentCoverage.status === "full" && consent && ga4Sessions.status === "ok" && ga4Sessions.value > 0) {
590
+ const rate = Math.min(100, sumFirstMetric(consent) / ga4Sessions.value * 100);
591
+ consentRate = ok(Math.round(rate * 10) / 10);
592
+ } else if (consentCoverage.status === "partial") {
593
+ consentRate = unavailable("before_cutover", `Instrumented from ${consentCoverage.cutover}`);
594
+ }
595
+ } catch (e) {
596
+ console.error("Visitor insights: GA4 measurement-health query failed:", e.message);
597
+ ga4Pageviews = unavailable("source_error");
598
+ ga4Sessions = unavailable("source_error");
599
+ }
600
+ }
601
+ let vercelPageviews = unavailable("source_error", "Vercel not configured");
602
+ if (vercel) {
603
+ try {
604
+ const result = await vercel.pageviews(range.start, range.end);
605
+ vercelPageviews = ok(result.total);
606
+ } catch (e) {
607
+ console.error("Visitor insights: Vercel query failed:", e.message);
608
+ vercelPageviews = unavailable("source_error");
609
+ }
610
+ }
611
+ let orders = unavailable("source_error", "Sanity not configured");
612
+ if (sanity) {
613
+ try {
614
+ const counts = await countOrders(
615
+ sanity,
616
+ config.orders.documentType,
617
+ range.start,
618
+ range.end,
619
+ config.orders.excludeFilter
620
+ );
621
+ orders = ok(counts.total);
622
+ } catch (e) {
623
+ console.error("Visitor insights: order count failed:", e.message);
624
+ orders = unavailable("source_error");
625
+ }
626
+ }
627
+ const shortfallRatio = ga4Pageviews.status !== "unavailable" && vercelPageviews.status !== "unavailable" && vercelPageviews.value > 0 ? (vercelPageviews.value - ga4Pageviews.value) / vercelPageviews.value : null;
628
+ return {
629
+ ga4Pageviews,
630
+ vercelPageviews,
631
+ shortfallRatio,
632
+ ga4Sessions,
633
+ orders,
634
+ consentRate,
635
+ interpretation: interpret(ga4Pageviews, vercelPageviews, shortfallRatio, consentRate)
636
+ };
637
+ }
638
+
639
+ // src/server/reports/acquisition.ts
640
+ var DESIGN_INDUSTRY_SOURCES = [
641
+ "fontsinuse.com",
642
+ "typewolf.com",
643
+ "typographica.org",
644
+ "fonts.google.com",
645
+ "behance.net",
646
+ "dribbble.com",
647
+ "itsnicethat.com"
648
+ ];
649
+ function isUnattributed(source) {
650
+ const normalised = source.toLowerCase();
651
+ return normalised === "(not set)" || normalised === "(direct)" || normalised === "(none)" || normalised === "";
652
+ }
653
+ function isDesignIndustry(source) {
654
+ const normalised = source.toLowerCase();
655
+ return DESIGN_INDUSTRY_SOURCES.some((known) => normalised === known || normalised.endsWith(`.${known}`));
656
+ }
657
+ async function acquisition(ga4, range, limit = 25) {
658
+ const report = await ga4.runReport({
659
+ dimensions: [{ name: "sessionSource" }, { name: "sessionDefaultChannelGroup" }],
660
+ metrics: [{ name: "sessions" }],
661
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
662
+ orderBys: [{ metric: { metricName: "sessions" }, desc: true }],
663
+ limit
664
+ });
665
+ const rows = report.rows.map((row) => {
666
+ const source = row.dimensions[0] ?? "(not set)";
667
+ const sessions = row.metrics[0];
668
+ return {
669
+ source,
670
+ channel: row.dimensions[1] ?? "Unassigned",
671
+ sessions: Number.isFinite(sessions) ? sessions : 0,
672
+ designIndustry: isDesignIndustry(source),
673
+ unattributed: isUnattributed(source)
674
+ };
675
+ });
676
+ const totalSessions = sumFirstMetric(report);
677
+ const sumWhere = (predicate) => rows.filter(predicate).reduce((total, row) => total + row.sessions, 0);
678
+ return {
679
+ rows,
680
+ totalSessions,
681
+ designIndustryShare: totalSessions > 0 ? sumWhere((r) => r.designIndustry) / totalSessions : null,
682
+ unattributedShare: totalSessions > 0 ? sumWhere((r) => r.unattributed) / totalSessions : null,
683
+ rowsWithheld: report.thresholded
684
+ };
685
+ }
686
+
687
+ // src/server/reports/journey.ts
688
+ var JOURNEY_STEPS = [
689
+ { key: "landed", label: "Landed", event: "page_view" },
690
+ { key: "viewed_typeface", label: "Viewed a typeface", event: "view_item" },
691
+ { key: "tested", label: "Used the type tester", event: "tester_engaged" },
692
+ { key: "added_to_cart", label: "Added to cart", event: "add_to_cart" },
693
+ { key: "began_checkout", label: "Began checkout", event: "begin_checkout" },
694
+ { key: "purchased", label: "Purchased", event: "purchase" }
695
+ ];
696
+ var APPROXIMATION_NOTE = "These are independent per-step totals, not tracked journeys. GA4 cannot report the actual path a visitor took, so a visitor counted at one step is not necessarily the same visitor counted at the next.";
697
+ async function journey(config, ga4, range) {
698
+ const coverages = JOURNEY_STEPS.map((step) => ({
699
+ step,
700
+ coverage: coverageForRange(config.eventCutovers, step.event, range)
701
+ }));
702
+ const queryable = coverages.filter((entry) => entry.coverage.status !== "none");
703
+ const reports = await ga4.batchRunReports(
704
+ queryable.map((entry) => ({
705
+ metrics: [{ name: "eventCount" }],
706
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
707
+ dimensionFilter: eventNameFilter(entry.step.event)
708
+ }))
709
+ );
710
+ const countsByEvent = /* @__PURE__ */ new Map();
711
+ queryable.forEach((entry, index) => {
712
+ const report = reports[index];
713
+ if (report) countsByEvent.set(entry.step.event, sumFirstMetric(report));
714
+ });
715
+ const steps = [];
716
+ let previousMeasurable = null;
717
+ for (const { step, coverage } of coverages) {
718
+ const raw = countsByEvent.has(step.event) ? countsByEvent.get(step.event) : null;
719
+ const count = applyCoverage(raw, coverage);
720
+ const current = count.status === "unavailable" ? null : count.value;
721
+ const conversionFromPrevious = previousMeasurable !== null && current !== null && previousMeasurable > 0 ? current / previousMeasurable : null;
722
+ steps.push({ key: step.key, label: step.label, event: step.event, count, conversionFromPrevious });
723
+ if (current !== null) previousMeasurable = current;
724
+ }
725
+ let topExitPages = [];
726
+ try {
727
+ const exits = await ga4.runReport({
728
+ dimensions: [{ name: "pagePath" }],
729
+ metrics: [{ name: "exits" }],
730
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
731
+ orderBys: [{ metric: { metricName: "exits" }, desc: true }],
732
+ limit: 10
733
+ });
734
+ topExitPages = exits.rows.map((row) => ({
735
+ path: row.dimensions[0] ?? "(unknown)",
736
+ exits: Number.isFinite(row.metrics[0]) ? row.metrics[0] : 0
737
+ }));
738
+ } catch (e) {
739
+ console.error("Visitor insights: exit-page query failed:", e.message);
740
+ }
741
+ return { steps, topExitPages, approximate: true, approximationNote: APPROXIMATION_NOTE };
742
+ }
743
+
744
+ // src/server/reports/typefaceInterest.ts
745
+ var VIEW_EVENT = "view_item";
746
+ var TEST_EVENT = "tester_engaged";
747
+ var INTERPRETATION_NOTE = "Aggregate interest per family, not individual journeys. The person who tests a typeface is often not the person who buys it, and may buy months later \u2014 so a low tested-to-bought ratio is not necessarily a conversion problem.";
748
+ async function countsByItem(config, ga4, range, eventName) {
749
+ if (coverageForRange(config.eventCutovers, eventName, range).status === "none") return null;
750
+ const report = await ga4.runReport({
751
+ dimensions: [{ name: "itemName" }],
752
+ metrics: [{ name: "eventCount" }],
753
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
754
+ dimensionFilter: eventNameFilter(eventName),
755
+ orderBys: [{ metric: { metricName: "eventCount" }, desc: true }],
756
+ limit: 100
757
+ });
758
+ const counts = /* @__PURE__ */ new Map();
759
+ for (const row of report.rows) {
760
+ const name = row.dimensions[0];
761
+ const value = row.metrics[0];
762
+ if (name && Number.isFinite(value)) counts.set(name, value);
763
+ }
764
+ return counts;
765
+ }
766
+ async function typefaceInterest(input) {
767
+ const { config, range, ga4, sanity } = input;
768
+ const [viewed, tested] = await Promise.all([
769
+ countsByItem(config, ga4, range, VIEW_EVENT),
770
+ countsByItem(config, ga4, range, TEST_EVENT)
771
+ ]);
772
+ let bought = null;
773
+ if (sanity) {
774
+ try {
775
+ const counts = await countOrdersByTypeface(
776
+ sanity,
777
+ config.orders.documentType,
778
+ config.orders.typefacesField,
779
+ range.start,
780
+ range.end,
781
+ config.orders.excludeFilter
782
+ );
783
+ if (counts) bought = new Map(Object.entries(counts.byTypeface));
784
+ } catch (e) {
785
+ console.error("Visitor insights: per-typeface order count failed:", e.message);
786
+ }
787
+ }
788
+ const families = /* @__PURE__ */ new Set([
789
+ ...viewed?.keys() ?? [],
790
+ ...tested?.keys() ?? [],
791
+ ...bought?.keys() ?? []
792
+ ]);
793
+ const metric = (counts, family, missingReason) => counts === null ? unavailable(missingReason) : ok(counts.get(family) ?? 0);
794
+ const rows = [...families].map((family) => {
795
+ const viewedMetric = metric(viewed, family, "not_instrumented");
796
+ const testedMetric = metric(tested, family, "not_instrumented");
797
+ const boughtMetric = bought === null ? unavailable("not_applicable", "Orders do not resolve to typefaces on this site") : ok(bought.get(family) ?? 0);
798
+ const testRate = viewedMetric.status !== "unavailable" && testedMetric.status !== "unavailable" && viewedMetric.value > 0 ? testedMetric.value / viewedMetric.value : null;
799
+ return { typeface: family, viewed: viewedMetric, tested: testedMetric, bought: boughtMetric, testRate };
800
+ });
801
+ rows.sort((a, b) => {
802
+ const av = a.viewed.status === "unavailable" ? -1 : a.viewed.value;
803
+ const bv = b.viewed.status === "unavailable" ? -1 : b.viewed.value;
804
+ return bv - av;
805
+ });
806
+ return { rows, interpretationNote: INTERPRETATION_NOTE, rowsWithheld: false };
807
+ }
808
+
809
+ // src/server/createHandler.ts
810
+ var ENV_VARS = {
811
+ /** Service-account JSON (raw or base64) with Viewer on the GA4 property. */
812
+ googleServiceAccount: "VISITOR_INSIGHTS_GA4_SERVICE_ACCOUNT",
813
+ /** Vercel API token with read access to the project. */
814
+ vercelToken: "VISITOR_INSIGHTS_VERCEL_TOKEN"
815
+ };
816
+ var RANGE_KEYS = ["week", "quarter", "year"];
817
+ function param(req, name) {
818
+ const raw = req.query?.[name];
819
+ return Array.isArray(raw) ? raw[0] : raw;
820
+ }
821
+ function createVisitorInsightsHandler(options) {
822
+ assertValidSiteConfig(options.config);
823
+ const config = options.config;
824
+ const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
825
+ return async function handler(req, res) {
826
+ if (applyCors(req, res, config.allowedStudioOrigins ?? [])) return;
827
+ if (req.method !== "GET") {
828
+ res.status(405).json({ error: "Method not allowed" });
829
+ return;
830
+ }
831
+ const sanityProjectId = options.sanityProjectId ?? process.env.SANITY_STUDIO_PROJECT_ID ?? "";
832
+ const user = await requireStudioUser(req, res, sanityProjectId);
833
+ if (!user) return;
834
+ const reportName = param(req, "report");
835
+ if (!isReportName(reportName)) {
836
+ res.status(400).json({ error: "Unknown report" });
837
+ return;
838
+ }
839
+ const rangeKey = param(req, "range") ?? "week";
840
+ if (!RANGE_KEYS.includes(rangeKey)) {
841
+ res.status(400).json({ error: "Unknown range" });
842
+ return;
843
+ }
844
+ const timezone = config.ga4?.timezone ?? "UTC";
845
+ const range = resolveRange(rangeKey, timezone);
846
+ const sources = {};
847
+ const serviceAccount = parseServiceAccountKey(process.env[ENV_VARS.googleServiceAccount]);
848
+ let ga4 = null;
849
+ if (!config.ga4) {
850
+ sources.ga4 = { status: "unconfigured" };
851
+ } else if (!serviceAccount) {
852
+ sources.ga4 = { status: "error", message: "Service account missing or unparseable" };
853
+ } else {
854
+ ga4 = createGa4Client(config.ga4.propertyId, serviceAccount);
855
+ sources.ga4 = { status: "ok" };
856
+ }
857
+ const vercelToken = process.env[ENV_VARS.vercelToken];
858
+ let vercel = null;
859
+ if (!config.vercel) {
860
+ sources.vercel = { status: "unconfigured" };
861
+ } else if (!vercelToken) {
862
+ sources.vercel = { status: "error", message: "Vercel token missing" };
863
+ } else {
864
+ vercel = createVercelClient(config.vercel.projectId, vercelToken, config.vercel.teamId);
865
+ sources.vercel = { status: "ok" };
866
+ }
867
+ const sanity = options.sanityClient ?? null;
868
+ sources.sanity = sanity ? { status: "ok" } : { status: "unconfigured" };
869
+ try {
870
+ const key = cacheKey(["vi", config.siteId, reportName, range.key, range.start, range.end]);
871
+ const envelope = await withCache(key, ttl, async () => {
872
+ const notices = [];
873
+ const provisional = provisionalNotice(range);
874
+ if (provisional) notices.push(provisional);
875
+ const data = await runReport(reportName, { config, range, ga4, vercel, sanity, notices });
876
+ return { report: reportName, range, sources, notices, data };
877
+ });
878
+ res.status(200).json(envelope);
879
+ } catch (e) {
880
+ console.error(`Visitor insights: report "${reportName}" failed:`, e.message);
881
+ res.status(502).json({ error: "Report failed", report: reportName });
882
+ }
883
+ };
884
+ }
885
+ async function runReport(report, ctx) {
886
+ const { config, range, ga4, vercel, sanity, notices } = ctx;
887
+ switch (report) {
888
+ case "measurement-health":
889
+ return measurementHealth({ config, range, ga4, vercel, sanity });
890
+ case "acquisition":
891
+ if (!ga4) throw new Error("GA4 is required for the acquisition report");
892
+ return acquisition(ga4, range);
893
+ case "journey": {
894
+ if (!ga4) throw new Error("GA4 is required for the journey report");
895
+ notices.push(...coverageNotices(config.eventCutovers, JOURNEY_STEPS.map((s) => s.event), range));
896
+ return journey(config, ga4, range);
897
+ }
898
+ case "typeface-interest":
899
+ if (!ga4) throw new Error("GA4 is required for the typeface-interest report");
900
+ return typefaceInterest({ config, range, ga4, sanity });
901
+ default:
902
+ throw new Error(`Unhandled report: ${report}`);
903
+ }
904
+ }
905
+ // Annotate the CommonJS export names for ESM import in node:
906
+ 0 && (module.exports = {
907
+ DESIGN_INDUSTRY_SOURCES,
908
+ ENV_VARS,
909
+ GA4_PROCESSING_LAG_DAYS,
910
+ JOURNEY_STEPS,
911
+ PREEXISTING,
912
+ REPORT_NAMES,
913
+ applyCors,
914
+ applyCoverage,
915
+ assertValidSiteConfig,
916
+ clearCache,
917
+ coverageForRange,
918
+ coverageNotices,
919
+ createVisitorInsightsHandler,
920
+ daysBetween,
921
+ formatInTimeZone,
922
+ isReportName,
923
+ isValidTimeZone,
924
+ ok,
925
+ partial,
926
+ previousRange,
927
+ provisionalDates,
928
+ provisionalNotice,
929
+ requireStudioUser,
930
+ resolveRange,
931
+ shiftDays,
932
+ unavailable,
933
+ validateSiteConfig,
934
+ valueOrNull,
935
+ verifyStudioRequest
936
+ });
937
+ //# sourceMappingURL=server.js.map