@liiift-studio/sanity-visitor-insights 0.8.2 → 0.10.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/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -51,7 +61,7 @@ var import_sanity2 = require("sanity");
51
61
  var import_icons = require("@liiift-studio/sanity-ui-compat/icons");
52
62
 
53
63
  // src/studio/VisitorInsightsTool.tsx
54
- var import_react2 = require("react");
64
+ var import_react3 = require("react");
55
65
  var import_sanity_ui_compat3 = require("@liiift-studio/sanity-ui-compat");
56
66
 
57
67
  // src/studio/useReport.ts
@@ -106,6 +116,7 @@ function useReport({ apiBaseUrl, report, range }) {
106
116
  }
107
117
 
108
118
  // src/studio/Figure.tsx
119
+ var import_react2 = __toESM(require("react"));
109
120
  var import_sanity_ui_compat = require("@liiift-studio/sanity-ui-compat");
110
121
  var import_jsx_runtime = require("react/jsx-runtime");
111
122
  var REASON_TEXT = {
@@ -164,6 +175,18 @@ function ComparisonBar({ label, metric, max, tone = "default" }) {
164
175
  )
165
176
  ] });
166
177
  }
178
+ var chartFrame = { position: "relative", width: "100%" };
179
+ var readout = {
180
+ position: "absolute",
181
+ top: 0,
182
+ right: 0,
183
+ padding: "6px 10px",
184
+ borderRadius: 3,
185
+ background: "var(--card-bg-color, rgba(0,0,0,0.55))",
186
+ border: "1px solid var(--card-border-color, rgba(128,128,128,0.35))",
187
+ pointerEvents: "none",
188
+ maxWidth: "70%"
189
+ };
167
190
  var barHeader = {
168
191
  display: "flex",
169
192
  alignItems: "center",
@@ -177,6 +200,21 @@ var legendRow = {
177
200
  alignItems: "center",
178
201
  flexWrap: "wrap"
179
202
  };
203
+ var disclosure = {
204
+ appearance: "none",
205
+ background: "transparent",
206
+ border: "none",
207
+ color: "inherit",
208
+ opacity: 0.75,
209
+ font: "inherit",
210
+ fontSize: "0.85em",
211
+ padding: "2px 0",
212
+ textDecoration: "underline",
213
+ textUnderlineOffset: 3,
214
+ cursor: "pointer",
215
+ justifySelf: "start",
216
+ width: "fit-content"
217
+ };
180
218
  var noticeRow = {
181
219
  display: "flex",
182
220
  gap: 12,
@@ -185,23 +223,55 @@ var noticeRow = {
185
223
  };
186
224
  var noticeBadge = { flex: "0 0 auto" };
187
225
  function NoticeList({ notices }) {
226
+ const [expanded, setExpanded] = import_react2.default.useState(false);
188
227
  if (notices.length === 0) return null;
189
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 8 }, children: notices.map((notice) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { padding: 3, radius: 2, tone: "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: noticeRow, children: [
228
+ const alwaysShown = notices.slice(0, 2);
229
+ const hidden = notices.slice(2);
230
+ const item = (notice) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { padding: 3, radius: 2, tone: "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: noticeRow, children: [
190
231
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: noticeBadge, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Badge, { tone: "caution", fontSize: 0, children: "Caveat" }) }),
191
232
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, children: notice })
192
- ] }) }) }, notice)) });
233
+ ] }) }) }, notice);
234
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 2, children: [
235
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 8 }, children: [
236
+ alwaysShown.map(item),
237
+ expanded && hidden.map(item)
238
+ ] }),
239
+ hidden.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
240
+ "button",
241
+ {
242
+ type: "button",
243
+ style: disclosure,
244
+ onClick: () => setExpanded((open) => !open),
245
+ "aria-expanded": expanded,
246
+ children: expanded ? "Show fewer caveats" : `${hidden.length} more ${hidden.length === 1 ? "caveat" : "caveats"}`
247
+ }
248
+ )
249
+ ] });
250
+ }
251
+ var CHART = { w: 760, h: 240, left: 52, right: 12, top: 16, bottom: 30 };
252
+ function niceCeiling(value) {
253
+ if (value <= 0) return 1;
254
+ const magnitude = 10 ** Math.floor(Math.log10(value));
255
+ const normalised = value / magnitude;
256
+ const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10;
257
+ return step * magnitude;
258
+ }
259
+ function shortDate(iso) {
260
+ const parsed = /* @__PURE__ */ new Date(`${iso}T00:00:00Z`);
261
+ if (Number.isNaN(parsed.getTime())) return iso;
262
+ return `${parsed.getUTCDate()} ${parsed.toLocaleString("en-GB", { month: "short", timeZone: "UTC" })}`;
193
263
  }
194
264
  function TrendChart({ points, label = "Daily pageviews, GA4 against Vercel" }) {
265
+ const [hovered, setHovered] = import_react2.default.useState(null);
195
266
  if (points.length < 3) return null;
196
- const W = 720;
197
- const H = 132;
198
- const PAD = 4;
199
267
  const values = points.flatMap((p) => [p.ga4, p.vercel]).filter((v) => typeof v === "number");
200
268
  if (values.length === 0) return null;
201
- const max = Math.max(...values, 1);
202
- const x = (i) => PAD + i / Math.max(1, points.length - 1) * (W - PAD * 2);
203
- const y = (v) => H - PAD - v / max * (H - PAD * 2);
204
- const path = (pick) => {
269
+ const max = niceCeiling(Math.max(...values, 1));
270
+ const plotW = CHART.w - CHART.left - CHART.right;
271
+ const plotH = CHART.h - CHART.top - CHART.bottom;
272
+ const x = (i) => CHART.left + i / Math.max(1, points.length - 1) * plotW;
273
+ const y = (v) => CHART.top + plotH - v / max * plotH;
274
+ const line = (pick) => {
205
275
  let d = "";
206
276
  let penDown = false;
207
277
  points.forEach((p, i) => {
@@ -215,43 +285,246 @@ function TrendChart({ points, label = "Daily pageviews, GA4 against Vercel" }) {
215
285
  });
216
286
  return d.trim();
217
287
  };
218
- const vercelPath = path((p) => p.vercel);
219
- const ga4Path = path((p) => p.ga4);
220
- const first = points[0]?.date ?? "";
221
- const last = points[points.length - 1]?.date ?? "";
222
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 2, children: [
223
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
224
- "svg",
225
- {
226
- viewBox: `0 0 ${W} ${H}`,
227
- width: "100%",
228
- height: H,
229
- role: "img",
230
- "aria-label": `${label}, ${first} to ${last}. Peak ${Math.round(max)} pageviews in a day.`,
231
- preserveAspectRatio: "none",
232
- style: { display: "block", overflow: "visible" },
233
- children: [
234
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: vercelPath, fill: "none", stroke: "currentColor", strokeOpacity: "0.85", strokeWidth: "2", vectorEffect: "non-scaling-stroke" }),
235
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: ga4Path, fill: "none", stroke: "currentColor", strokeOpacity: "0.85", strokeWidth: "2", strokeDasharray: "5 3", vectorEffect: "non-scaling-stroke" })
236
- ]
237
- }
238
- ) }),
288
+ const bands = [];
289
+ let run = [];
290
+ const flushRun = () => {
291
+ if (run.length > 1) {
292
+ const top = run.map((i) => `${x(i).toFixed(1)} ${y(points[i].vercel).toFixed(1)}`);
293
+ const bottom = [...run].reverse().map((i) => `${x(i).toFixed(1)} ${y(points[i].ga4).toFixed(1)}`);
294
+ bands.push(`M${top.join(" L")} L${bottom.join(" L")} Z`);
295
+ }
296
+ run = [];
297
+ };
298
+ points.forEach((p, i) => {
299
+ if (typeof p.ga4 === "number" && typeof p.vercel === "number") run.push(i);
300
+ else flushRun();
301
+ });
302
+ flushRun();
303
+ const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ value: max * f, y: y(max * f) }));
304
+ const labelEvery = Math.max(1, Math.ceil(points.length / 6));
305
+ const dateTicks = points.map((p, i) => ({ i, date: p.date })).filter(({ i }) => i % labelEvery === 0 || i === points.length - 1);
306
+ const active = hovered !== null ? points[hovered] : null;
307
+ const activeGap = active && typeof active.ga4 === "number" && typeof active.vercel === "number" && active.vercel > 0 ? (active.vercel - active.ga4) / active.vercel : null;
308
+ const onKeyDown = (event) => {
309
+ const last = points.length - 1;
310
+ const current = hovered ?? 0;
311
+ const move = (next) => {
312
+ event.preventDefault();
313
+ setHovered(Math.max(0, Math.min(last, next)));
314
+ };
315
+ if (event.key === "ArrowRight") return move(hovered === null ? 0 : current + 1);
316
+ if (event.key === "ArrowLeft") return move(hovered === null ? last : current - 1);
317
+ if (event.key === "Home") return move(0);
318
+ if (event.key === "End") return move(last);
319
+ if (event.key === "Escape") {
320
+ setHovered(null);
321
+ return;
322
+ }
323
+ };
324
+ const onMove = (event) => {
325
+ const box = event.currentTarget.getBoundingClientRect();
326
+ if (box.width === 0) return;
327
+ const svgX = (event.clientX - box.left) / box.width * CHART.w;
328
+ const ratio = (svgX - CHART.left) / plotW;
329
+ const index = Math.round(ratio * (points.length - 1));
330
+ setHovered(index >= 0 && index < points.length ? index : null);
331
+ };
332
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 3, children: [
333
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: chartFrame, children: [
334
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
335
+ "svg",
336
+ {
337
+ viewBox: `0 0 ${CHART.w} ${CHART.h}`,
338
+ width: "100%",
339
+ style: { display: "block", height: "auto" },
340
+ role: "img",
341
+ "aria-label": `${label}, ${points[0]?.date} to ${points[points.length - 1]?.date}. Peak ${formatCount(max)} pageviews in a day.`,
342
+ onMouseMove: onMove,
343
+ onMouseLeave: () => setHovered(null),
344
+ onKeyDown,
345
+ onBlur: () => setHovered(null),
346
+ tabIndex: 0,
347
+ "aria-describedby": active ? "trend-readout" : void 0,
348
+ children: [
349
+ ticks.map((tick) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", { children: [
350
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
351
+ "line",
352
+ {
353
+ x1: CHART.left,
354
+ x2: CHART.w - CHART.right,
355
+ y1: tick.y,
356
+ y2: tick.y,
357
+ stroke: "currentColor",
358
+ strokeOpacity: tick.value === 0 ? 0.35 : 0.12,
359
+ strokeWidth: "1"
360
+ }
361
+ ),
362
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
363
+ "text",
364
+ {
365
+ x: CHART.left - 8,
366
+ y: tick.y + 4,
367
+ textAnchor: "end",
368
+ fontSize: "11",
369
+ fill: "currentColor",
370
+ fillOpacity: "0.55",
371
+ children: formatCount(tick.value)
372
+ }
373
+ )
374
+ ] }, tick.value)),
375
+ dateTicks.map(({ i, date }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
376
+ "text",
377
+ {
378
+ x: x(i),
379
+ y: CHART.h - 10,
380
+ textAnchor: "middle",
381
+ fontSize: "11",
382
+ fill: "currentColor",
383
+ fillOpacity: "0.55",
384
+ children: shortDate(date)
385
+ },
386
+ date
387
+ )),
388
+ bands.map((d) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d, fill: "currentColor", fillOpacity: "0.09", stroke: "none" }, d.slice(0, 24))),
389
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: line((p) => p.vercel), fill: "none", stroke: "currentColor", strokeOpacity: "0.9", strokeWidth: "2" }),
390
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: line((p) => p.ga4), fill: "none", stroke: "currentColor", strokeOpacity: "0.9", strokeWidth: "2", strokeDasharray: "5 3" }),
391
+ hovered !== null && active && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", { children: [
392
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
393
+ "line",
394
+ {
395
+ x1: x(hovered),
396
+ x2: x(hovered),
397
+ y1: CHART.top,
398
+ y2: CHART.top + plotH,
399
+ stroke: "currentColor",
400
+ strokeOpacity: "0.4",
401
+ strokeWidth: "1"
402
+ }
403
+ ),
404
+ typeof active.vercel === "number" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: x(hovered), cy: y(active.vercel), r: "4", fill: "currentColor" }),
405
+ typeof active.ga4 === "number" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: x(hovered), cy: y(active.ga4), r: "4", fill: "currentColor" })
406
+ ] })
407
+ ]
408
+ }
409
+ ),
410
+ active && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: readout, id: "trend-readout", role: "status", "aria-live": "polite", children: [
411
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "medium", children: shortDate(active.date) }),
412
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 1, muted: true, children: [
413
+ "Vercel ",
414
+ typeof active.vercel === "number" ? formatCount(active.vercel) : "\u2014",
415
+ " \xB7 ",
416
+ "GA4 ",
417
+ typeof active.ga4 === "number" ? formatCount(active.ga4) : "\u2014",
418
+ activeGap !== null ? ` \xB7 gap ${formatPercent(activeGap, 0)}` : ""
419
+ ] })
420
+ ] })
421
+ ] }),
239
422
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: legendRow, children: [
240
423
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "\u2501\u2501 Vercel" }),
241
424
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "\u2504\u2504 GA4" }),
242
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
243
- first,
244
- " to ",
245
- last
246
- ] }),
247
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
248
- "peak ",
249
- formatCount(max),
250
- "/day"
251
- ] })
425
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "shaded band is the gap" }),
426
+ !active && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "hover, or focus and use arrow keys, for a day" })
252
427
  ] })
253
428
  ] });
254
429
  }
430
+ function SortableTable({ caption, columns, rows, rowKey, initialSort }) {
431
+ const [sort, setSort] = import_react2.default.useState(
432
+ initialSort ? { key: initialSort, desc: true } : null
433
+ );
434
+ const active = sort ? columns.find((c) => c.key === sort.key) : void 0;
435
+ const ordered = import_react2.default.useMemo(() => {
436
+ if (!active || !sort) return rows;
437
+ const copy = [...rows];
438
+ copy.sort((a, b) => {
439
+ const av = active.sortValue(a);
440
+ const bv = active.sortValue(b);
441
+ if (av === null && bv === null) return 0;
442
+ if (av === null) return 1;
443
+ if (bv === null) return -1;
444
+ const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
445
+ return sort.desc ? -cmp : cmp;
446
+ });
447
+ return copy;
448
+ }, [rows, active, sort]);
449
+ const toggle = (column) => {
450
+ setSort((current) => {
451
+ if (current?.key !== column.key) return { key: column.key, desc: Boolean(column.numeric) };
452
+ return { key: column.key, desc: !current.desc };
453
+ });
454
+ };
455
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { radius: 2, tone: "transparent", border: true, style: tableWrapper, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("table", { style: tableBase, children: [
456
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("caption", { style: visuallyHidden, children: caption }),
457
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tr", { children: columns.map((column) => {
458
+ const isActive = sort?.key === column.key;
459
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
460
+ "th",
461
+ {
462
+ scope: "col",
463
+ style: column.numeric ? headCellNumeric : headCell,
464
+ "aria-sort": isActive ? sort?.desc ? "descending" : "ascending" : "none",
465
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", style: sortButton(Boolean(column.numeric)), onClick: () => toggle(column), children: [
466
+ column.label,
467
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", style: sortMark, children: isActive ? sort?.desc ? "\u25BC" : "\u25B2" : "\u2195" })
468
+ ] })
469
+ },
470
+ column.key
471
+ );
472
+ }) }) }),
473
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tbody", { children: ordered.map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tr", { children: columns.map((column, index) => {
474
+ const content = column.render(row);
475
+ return index === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { scope: "row", style: bodyCell, children: content }, column.key) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: column.numeric ? bodyCellNumeric : bodyCell, children: content }, column.key);
476
+ }) }, rowKey(row))) })
477
+ ] }) });
478
+ }
479
+ var tableWrapper = { overflowX: "auto", width: "100%" };
480
+ var tableBase = { width: "100%", borderCollapse: "collapse", minWidth: 420 };
481
+ var headCell = {
482
+ padding: 0,
483
+ textAlign: "left",
484
+ borderBottom: "1px solid var(--card-border-color, rgba(128,128,128,0.3))",
485
+ whiteSpace: "nowrap"
486
+ };
487
+ var headCellNumeric = { ...headCell, textAlign: "right" };
488
+ var bodyCell = {
489
+ padding: "8px 12px",
490
+ textAlign: "left",
491
+ fontWeight: 400,
492
+ borderBottom: "1px solid var(--card-border-color, rgba(128,128,128,0.18))"
493
+ };
494
+ var bodyCellNumeric = {
495
+ ...bodyCell,
496
+ textAlign: "right",
497
+ fontVariantNumeric: "tabular-nums"
498
+ };
499
+ function sortButton(numeric) {
500
+ return {
501
+ appearance: "none",
502
+ background: "transparent",
503
+ border: "none",
504
+ color: "inherit",
505
+ font: "inherit",
506
+ fontSize: "0.78em",
507
+ letterSpacing: "0.06em",
508
+ textTransform: "uppercase",
509
+ opacity: 0.7,
510
+ padding: "8px 12px",
511
+ width: "100%",
512
+ display: "flex",
513
+ gap: 6,
514
+ alignItems: "center",
515
+ justifyContent: numeric ? "flex-end" : "flex-start",
516
+ cursor: "pointer"
517
+ };
518
+ }
519
+ var sortMark = { opacity: 0.7, fontSize: "0.9em" };
520
+ var visuallyHidden = {
521
+ position: "absolute",
522
+ width: 1,
523
+ height: 1,
524
+ overflow: "hidden",
525
+ clip: "rect(0 0 0 0)",
526
+ whiteSpace: "nowrap"
527
+ };
255
528
 
256
529
  // src/studio/VisitorInsightsTool.tsx
257
530
  var import_sanity_ui_compat4 = require("@liiift-studio/sanity-ui-compat");
@@ -259,23 +532,34 @@ var import_sanity_ui_compat4 = require("@liiift-studio/sanity-ui-compat");
259
532
  // src/studio/panels.tsx
260
533
  var import_sanity_ui_compat2 = require("@liiift-studio/sanity-ui-compat");
261
534
  var import_jsx_runtime2 = require("react/jsx-runtime");
535
+ function metricSortValue(metric) {
536
+ return metric.status === "unavailable" ? null : metric.value;
537
+ }
262
538
  function maxOf(metrics) {
263
539
  return metrics.reduce((max, metric) => metric.status === "unavailable" ? max : Math.max(max, metric.value), 0);
264
540
  }
265
- var tableWrap = { overflowX: "auto", width: "100%" };
541
+ var sectionHeading = { margin: "0 0 2px", lineHeight: 1.3 };
542
+ var sourceLink = {
543
+ color: "inherit",
544
+ textDecoration: "underline",
545
+ textUnderlineOffset: 3,
546
+ textDecorationThickness: 1
547
+ };
548
+ function isLinkableHost(source) {
549
+ return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(source);
550
+ }
266
551
  var cardGrid = {
267
552
  display: "grid",
268
553
  gridTemplateColumns: "repeat(auto-fit, minmax(min(14rem, 100%), 1fr))",
269
554
  gap: 16
270
555
  };
271
- var table = { width: "100%", borderCollapse: "collapse", minWidth: 420 };
272
556
  var cell = { padding: "8px 12px", textAlign: "left", borderBottom: "1px solid var(--card-border-color)" };
273
557
  var numericCell = { ...cell, textAlign: "right" };
274
558
  function MeasurementHealthPanel({ data }) {
275
559
  const pageviewMax = maxOf([data.ga4Pageviews, data.vercelPageviews]);
276
560
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 4, children: [
277
561
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
278
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, children: "Pageviews, source against source" }),
562
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "Pageviews, source against source" }),
279
563
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: "The same unit on both sides. Vercel is cookieless and ungated; GA4 is consent-gated and blockable, so GA4 seeing fewer is expected." }),
280
564
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ComparisonBar, { label: "Vercel pageviews", metric: data.vercelPageviews, max: pageviewMax, tone: "primary" }),
281
565
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ComparisonBar, { label: "GA4 pageviews", metric: data.ga4Pageviews, max: pageviewMax, tone: "default" })
@@ -285,12 +569,12 @@ function MeasurementHealthPanel({ data }) {
285
569
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: data.interpretation })
286
570
  ] }) }),
287
571
  (data.daily?.length ?? 0) >= 3 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
288
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, children: "Day by day" }),
572
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "Day by day" }),
289
573
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: "A gap that has always been there is consent and blocking. A gap that opens on one day is an incident." }),
290
574
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(TrendChart, { points: data.daily ?? [] })
291
575
  ] }),
292
576
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
293
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, children: "Context" }),
577
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "Context" }),
294
578
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: "Different units to the figures above, and to each other. Shown for scale, never differenced." }),
295
579
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: cardGrid, children: [
296
580
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
@@ -325,72 +609,157 @@ function AcquisitionPanel({ data }) {
325
609
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 4, children: formatPercent(data.unattributedShare, 1) })
326
610
  ] }) })
327
611
  ] }),
328
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { radius: 2, tone: "transparent", border: true, style: tableWrap, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("table", { style: table, children: [
329
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("caption", { style: { textAlign: "left", paddingBottom: 8 }, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: "Traffic sources by sessions" }) }),
330
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("tr", { children: [
331
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "col", style: cell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Source" }) }),
332
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "col", style: cell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Channel" }) }),
333
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "col", style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Sessions" }) })
334
- ] }) }),
335
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("tbody", { children: (data.rows ?? []).map((row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("tr", { children: [
336
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "row", style: cell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }, children: [
337
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: row.source }),
338
- row.designIndustry && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Badge, { tone: "primary", fontSize: 0, children: "design industry" }),
339
- row.unattributed && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Badge, { tone: "caution", fontSize: 0, children: "unattributed" })
340
- ] }) }),
341
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: cell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: row.channel }) }),
342
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: formatCount(row.sessions) }) })
343
- ] }, `${row.source}-${row.channel}`)) })
344
- ] }) }),
612
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
613
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "Traffic sources" }),
614
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: "Sort by any column. Sessions is the default." }),
615
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
616
+ SortableTable,
617
+ {
618
+ caption: "Traffic sources by sessions",
619
+ initialSort: "sessions",
620
+ rows: data.rows ?? [],
621
+ rowKey: (row) => `${row.source}-${row.channel}`,
622
+ columns: [
623
+ {
624
+ key: "source",
625
+ label: "Source",
626
+ sortValue: (row) => row.source,
627
+ render: (row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }, children: [
628
+ isLinkableHost(row.source) ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
629
+ "a",
630
+ {
631
+ href: `https://${row.source}`,
632
+ target: "_blank",
633
+ rel: "noopener noreferrer",
634
+ style: sourceLink,
635
+ children: row.source
636
+ }
637
+ ) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: row.source }),
638
+ row.designIndustry && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Badge, { tone: "primary", fontSize: 0, children: "Design" }),
639
+ row.unattributed && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Badge, { tone: "caution", fontSize: 0, children: "Unattributed" })
640
+ ] })
641
+ },
642
+ {
643
+ key: "channel",
644
+ label: "Channel",
645
+ sortValue: (row) => row.channel,
646
+ render: (row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: row.channel })
647
+ },
648
+ {
649
+ key: "sessions",
650
+ label: "Sessions",
651
+ numeric: true,
652
+ sortValue: (row) => row.sessions,
653
+ render: (row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: formatCount(row.sessions) })
654
+ }
655
+ ]
656
+ }
657
+ )
658
+ ] }),
345
659
  data.rowsWithheld && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(NoticeList, { notices: ["GA4 withheld some low-traffic rows for privacy, so this list is shorter than reality."] })
346
660
  ] });
347
661
  }
348
662
  function JourneyPanel({ data }) {
349
- const max = maxOf((data.steps ?? []).map((step) => step.count));
663
+ const allSteps = data.steps ?? [];
664
+ const shown = allSteps.filter((step) => step.count.status !== "unavailable");
665
+ const hiddenSteps = allSteps.filter((step) => step.count.status === "unavailable");
666
+ const max = maxOf(shown.map((step) => step.count));
350
667
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 4, children: [
351
668
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: data.approximationNote }) }),
352
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Stack, { space: 3, children: (data.steps ?? []).map((step) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 2, children: [
669
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Stack, { space: 3, children: shown.map((step, index) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 2, children: [
353
670
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ComparisonBar, { label: step.label, metric: step.count, max, tone: "primary" }),
354
- step.conversionFromPrevious !== null && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: [
671
+ step.conversionFromPrevious !== null && index > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: [
355
672
  formatPercent(step.conversionFromPrevious, 1),
356
- " of the previous measurable step"
673
+ " of ",
674
+ shown[index - 1]?.label.toLowerCase()
357
675
  ] })
358
676
  ] }, step.key)) }),
677
+ hiddenSteps.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: [
678
+ "Not shown: ",
679
+ hiddenSteps.map((step) => step.label.toLowerCase()).join(", "),
680
+ " \u2014 not instrumented on this site, so there is no figure to place in the funnel."
681
+ ] }),
359
682
  (data.topLandingPages?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
360
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, children: "Where sessions began" }),
361
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { radius: 2, tone: "transparent", border: true, style: tableWrap, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("table", { style: table, children: [
362
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("tr", { children: [
363
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "col", style: cell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Page" }) }),
364
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "col", style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Sessions" }) })
365
- ] }) }),
366
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("tbody", { children: (data.topLandingPages ?? []).map((page) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("tr", { children: [
367
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "row", style: cell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: page.path }) }),
368
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: formatCount(page.sessions) }) })
369
- ] }, page.path)) })
370
- ] }) })
683
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "Where sessions began" }),
684
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
685
+ SortableTable,
686
+ {
687
+ caption: "Landing pages by sessions",
688
+ initialSort: "sessions",
689
+ rows: data.topLandingPages ?? [],
690
+ rowKey: (page) => page.path,
691
+ columns: [
692
+ {
693
+ key: "path",
694
+ label: "Page",
695
+ sortValue: (page) => page.path,
696
+ render: (page) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: page.path })
697
+ },
698
+ {
699
+ key: "sessions",
700
+ label: "Sessions",
701
+ numeric: true,
702
+ sortValue: (page) => page.sessions,
703
+ render: (page) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: formatCount(page.sessions) })
704
+ }
705
+ ]
706
+ }
707
+ )
371
708
  ] })
372
709
  ] });
373
710
  }
374
711
  function TypefaceInterestPanel({ data }) {
375
712
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 4, children: [
376
713
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: data.interpretationNote }) }),
377
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { radius: 2, tone: "transparent", border: true, style: tableWrap, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("table", { style: table, children: [
378
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("caption", { style: { textAlign: "left", paddingBottom: 8 }, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: "Engagement by typeface" }) }),
379
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("tr", { children: [
380
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "col", style: cell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Typeface" }) }),
381
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "col", style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Viewed" }) }),
382
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "col", style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Tested" }) }),
383
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "col", style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Bought" }) }),
384
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "col", style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Test rate" }) })
385
- ] }) }),
386
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("tbody", { children: (data.rows ?? []).map((row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("tr", { children: [
387
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("th", { scope: "row", style: cell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: row.typeface }) }),
388
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MetricFigure, { metric: row.viewed, label: `${row.typeface} viewed`, size: 1 }) }),
389
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MetricFigure, { metric: row.tested, label: `${row.typeface} tested`, size: 1 }) }),
390
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MetricFigure, { metric: row.bought, label: `${row.typeface} bought`, size: 1 }) }),
391
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: numericCell, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: row.testRate === null ? "\u2014" : formatPercent(row.testRate, 1) }) })
392
- ] }, row.typeface)) })
393
- ] }) })
714
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
715
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "Engagement by typeface" }),
716
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: "Sort by any column. Views is the default." }),
717
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
718
+ SortableTable,
719
+ {
720
+ caption: "Engagement by typeface",
721
+ initialSort: "viewed",
722
+ rows: data.rows ?? [],
723
+ rowKey: (row) => row.typeface,
724
+ columns: [
725
+ {
726
+ key: "typeface",
727
+ label: "Typeface",
728
+ sortValue: (row) => row.typeface,
729
+ render: (row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: row.typeface })
730
+ },
731
+ {
732
+ key: "viewed",
733
+ label: "Viewed",
734
+ numeric: true,
735
+ sortValue: (row) => metricSortValue(row.viewed),
736
+ render: (row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MetricFigure, { metric: row.viewed, label: `${row.typeface} viewed`, size: 1 })
737
+ },
738
+ {
739
+ key: "tested",
740
+ label: "Tested",
741
+ numeric: true,
742
+ sortValue: (row) => metricSortValue(row.tested),
743
+ render: (row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MetricFigure, { metric: row.tested, label: `${row.typeface} tested`, size: 1 })
744
+ },
745
+ {
746
+ key: "bought",
747
+ label: "Bought",
748
+ numeric: true,
749
+ sortValue: (row) => metricSortValue(row.bought),
750
+ render: (row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MetricFigure, { metric: row.bought, label: `${row.typeface} bought`, size: 1 })
751
+ },
752
+ {
753
+ key: "testRate",
754
+ label: "Test rate",
755
+ numeric: true,
756
+ sortValue: (row) => row.testRate,
757
+ render: (row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, "aria-label": row.testRate === null ? `${row.typeface} test rate unavailable` : void 0, children: row.testRate === null ? "\u2014" : formatPercent(row.testRate, 1) })
758
+ }
759
+ ]
760
+ }
761
+ )
762
+ ] })
394
763
  ] });
395
764
  }
396
765
  var CHECK_TONE = {
@@ -406,15 +775,16 @@ var CHECK_WORD = {
406
775
  skipped: "Skipped"
407
776
  };
408
777
  function DiagnosticsPanel({ data }) {
409
- const failing = data.checks.filter((c) => c.status === "fail").length;
410
- const warning = data.checks.filter((c) => c.status === "warn").length;
411
- const summary = data.verdict === "pass" ? "Everything checked out. The figures in the other panels can be taken at face value." : `${failing} failing, ${warning} worth a look. Panels depending on these will be wrong or incomplete until they are resolved.`;
778
+ const checks = data.checks ?? [];
779
+ const failing = checks.filter((c) => c.status === "fail").length;
780
+ const warning = checks.filter((c) => c.status === "warn").length;
781
+ const summary = checks.length === 0 ? "No checks ran. Nothing here has been verified either way." : data.verdict === "pass" ? "Configuration and credentials check out. That covers the wiring, not whether the figures are worth trusting \u2014 the caveats on each panel still apply." : `${failing} failing, ${warning} worth a look. Panels depending on these will be wrong or incomplete until they are resolved.`;
412
782
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 4, children: [
413
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: CHECK_TONE[data.verdict], border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: summary }) }),
414
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Stack, { space: 3, children: data.checks.map((item) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
783
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: CHECK_TONE[data.verdict] ?? "default", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: summary }) }),
784
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Stack, { space: 3, children: checks.map((item) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
415
785
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Flex, { align: "center", justify: "space-between", gap: 3, children: [
416
786
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, weight: "semibold", children: item.label }),
417
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Badge, { tone: CHECK_TONE[item.status], fontSize: 0, children: CHECK_WORD[item.status] })
787
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Badge, { tone: CHECK_TONE[item.status] ?? "default", fontSize: 0, children: CHECK_WORD[item.status] ?? item.status })
418
788
  ] }),
419
789
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: item.detail }),
420
790
  item.remedy && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: item.remedy })
@@ -424,21 +794,44 @@ function DiagnosticsPanel({ data }) {
424
794
 
425
795
  // src/studio/VisitorInsightsTool.tsx
426
796
  var import_jsx_runtime3 = require("react/jsx-runtime");
797
+ var tabStrip = {
798
+ display: "flex",
799
+ gap: 2,
800
+ alignItems: "stretch",
801
+ flexWrap: "wrap",
802
+ borderBottom: "1px solid var(--card-border-color, rgba(128,128,128,0.25))"
803
+ };
804
+ function tabStyle(selected) {
805
+ return {
806
+ appearance: "none",
807
+ background: "transparent",
808
+ border: "none",
809
+ borderBottom: `2px solid ${selected ? "currentColor" : "transparent"}`,
810
+ color: "inherit",
811
+ opacity: selected ? 1 : 0.62,
812
+ font: "inherit",
813
+ fontWeight: selected ? 600 : 400,
814
+ padding: "8px 12px",
815
+ marginBottom: -1,
816
+ cursor: "pointer",
817
+ whiteSpace: "nowrap"
818
+ };
819
+ }
427
820
  var RANGES = [
428
- { key: "week", label: "Week" },
429
- { key: "quarter", label: "Quarter" },
430
- { key: "year", label: "Year" }
821
+ { key: "week", label: "7 days", span: "the last 7 days" },
822
+ { key: "quarter", label: "91 days", span: "the last 91 days" },
823
+ { key: "year", label: "365 days", span: "the last 365 days" }
431
824
  ];
432
825
  var PANELS = [
433
- { report: "measurement-health", label: "Measurement health", blurb: "How much of reality each source actually sees" },
434
826
  { report: "acquisition", label: "Acquisition", blurb: "Where visitors come from" },
435
- { report: "journey", label: "Journey", blurb: "How far visitors get, and where they stop" },
827
+ { report: "journey", label: "Journey", blurb: "How far visitors get" },
436
828
  { report: "typeface-interest", label: "Typeface interest", blurb: "Viewed, tested and bought, by family" },
829
+ { report: "measurement-health", label: "Measurement health", blurb: "How much of reality each source actually sees" },
437
830
  { report: "diagnostics", label: "Diagnostics", blurb: "What to fix before trusting the numbers above" }
438
831
  ];
439
832
  function RangeSelector({ value, onChange }) {
440
- const refs = (0, import_react2.useRef)([]);
441
- const onKeyDown = (0, import_react2.useCallback)(
833
+ const refs = (0, import_react3.useRef)([]);
834
+ const onKeyDown = (0, import_react3.useCallback)(
442
835
  (event) => {
443
836
  const currentIndex = RANGES.findIndex((r) => r.key === value);
444
837
  let nextIndex = null;
@@ -469,6 +862,7 @@ function RangeSelector({ value, onChange }) {
469
862
  mode: selected ? "default" : "bleed",
470
863
  tone: selected ? "primary" : "default",
471
864
  text: range.label,
865
+ title: `Ending today, covering ${range.span}`,
472
866
  onClick: () => onChange(range.key),
473
867
  fontSize: 1,
474
868
  padding: 3
@@ -494,8 +888,8 @@ function SourceStatusRow({ sources }) {
494
888
  ] }) });
495
889
  }
496
890
  function PanelTabs({ value, onChange }) {
497
- const refs = (0, import_react2.useRef)([]);
498
- const onKeyDown = (0, import_react2.useCallback)(
891
+ const refs = (0, import_react3.useRef)([]);
892
+ const onKeyDown = (0, import_react3.useCallback)(
499
893
  (event) => {
500
894
  const currentIndex = PANELS.findIndex((p) => p.report === value);
501
895
  let nextIndex = null;
@@ -512,27 +906,30 @@ function PanelTabs({ value, onChange }) {
512
906
  },
513
907
  [value, onChange]
514
908
  );
515
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Flex, { role: "tablist", "aria-label": "Report", gap: 1, wrap: "wrap", onKeyDown, children: PANELS.map((panel, index) => {
909
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "tablist", "aria-label": "Report", style: tabStrip, onKeyDown, children: PANELS.map((panel, index) => {
516
910
  const selected = panel.report === value;
517
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
518
- import_sanity_ui_compat3.Button,
519
- {
520
- ref: (el) => {
521
- refs.current[index] = el;
911
+ return (
912
+ // A plain button rather than the UI kit's, so the label is real children and
913
+ // cannot vanish: the compat shim's DOM fallback does not forward `text`, which
914
+ // would leave five blank tabs on any Studio version where it falls back.
915
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
916
+ "button",
917
+ {
918
+ type: "button",
919
+ ref: (el) => {
920
+ refs.current[index] = el;
921
+ },
922
+ id: `tab-${panel.report}`,
923
+ role: "tab",
924
+ "aria-selected": selected,
925
+ "aria-controls": `panel-${panel.report}`,
926
+ tabIndex: selected ? 0 : -1,
927
+ style: tabStyle(selected),
928
+ onClick: () => onChange(panel.report),
929
+ children: panel.label
522
930
  },
523
- id: `tab-${panel.report}`,
524
- role: "tab",
525
- "aria-selected": selected,
526
- "aria-controls": `panel-${panel.report}`,
527
- tabIndex: selected ? 0 : -1,
528
- mode: selected ? "default" : "bleed",
529
- tone: selected ? "primary" : "default",
530
- text: panel.label,
531
- onClick: () => onChange(panel.report),
532
- fontSize: 1,
533
- padding: 3
534
- },
535
- panel.report
931
+ panel.report
932
+ )
536
933
  );
537
934
  }) });
538
935
  }
@@ -558,12 +955,12 @@ function ReportPanel({ report, apiBaseUrl, range }) {
558
955
  report === "typeface-interest" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(TypefaceInterestPanel, { data: state.envelope.data }),
559
956
  report === "diagnostics" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(DiagnosticsPanel, { data: state.envelope.data }),
560
957
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: [
958
+ "Figures cover ",
561
959
  state.envelope.range.start,
562
960
  " to ",
563
961
  state.envelope.range.end,
564
- " (",
565
- state.envelope.range.timezone,
566
- ")"
962
+ ", in ",
963
+ state.envelope.range.timezone
567
964
  ] })
568
965
  ] })
569
966
  ] });
@@ -571,8 +968,8 @@ function ReportPanel({ report, apiBaseUrl, range }) {
571
968
  function VisitorInsightsTool(props) {
572
969
  const apiBaseUrl = props.tool?.options?.apiBaseUrl ?? props.apiBaseUrl ?? "";
573
970
  const siteLabel = props.tool?.options?.siteLabel ?? props.siteLabel ?? "";
574
- const [range, setRange] = (0, import_react2.useState)("week");
575
- const [activePanel, setActivePanel] = (0, import_react2.useState)("measurement-health");
971
+ const [range, setRange] = (0, import_react3.useState)("week");
972
+ const [activePanel, setActivePanel] = (0, import_react3.useState)("acquisition");
576
973
  const active = PANELS.find((p) => p.report === activePanel) ?? PANELS[0];
577
974
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Container, { width: 4, padding: 4, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 5, children: [
578
975
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Flex, { align: "flex-start", justify: "space-between", gap: 4, wrap: "wrap", children: [