@liiift-studio/sanity-visitor-insights 0.49.0 → 0.51.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
@@ -266,6 +266,30 @@ var SEND_WINDOW_DAYS = 3;
266
266
  // src/studio/Figure.tsx
267
267
  var import_react2 = __toESM(require("react"));
268
268
  var import_sanity_ui_compat = require("@liiift-studio/sanity-ui-compat");
269
+
270
+ // src/studio/palette.ts
271
+ var SERIES = {
272
+ /** The complete, server-side count. The reference every other line is read against. */
273
+ vercel: "#4C8FD0",
274
+ /** GA4's pageviews — the like-for-like counterpart to Vercel's. */
275
+ ga4Pageviews: "#DD6B3F",
276
+ /** GA4's sessions. Warm, like its sibling above: same instrument, different unit. */
277
+ ga4Sessions: "#BC861F",
278
+ /** Orders, from the foundry's own records. Exact. */
279
+ orders: "#2E9E6B",
280
+ /** Revenue, same source, different unit. */
281
+ revenue: "#BF5D9B"
282
+ };
283
+ var REGION_ALPHA = 0.18;
284
+ function seriesFill(key, alpha = REGION_ALPHA) {
285
+ const hex = SERIES[key];
286
+ const r = parseInt(hex.slice(1, 3), 16);
287
+ const g = parseInt(hex.slice(3, 5), 16);
288
+ const b = parseInt(hex.slice(5, 7), 16);
289
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
290
+ }
291
+
292
+ // src/studio/Figure.tsx
269
293
  var import_jsx_runtime = require("react/jsx-runtime");
270
294
  var REASON_TEXT = {
271
295
  not_instrumented: "Not tracked on this site",
@@ -524,7 +548,10 @@ function FunnelChart({ stages, measurement }) {
524
548
  const active = activeKey === stage.key;
525
549
  const delta = previous ? previous.value - stage.value : 0;
526
550
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { style: funnelItem, children: [
527
- previous && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: funnelGap, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: gapLabel(delta, measurement) }) }),
551
+ previous && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: funnelGap, "aria-hidden": "true", children: [
552
+ measurement === "sequence" && delta > 0 && entry > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: lostTrack, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...lostFill, width: `${Math.max(1, Math.min(1, delta / entry) * 100)}%` } }) }),
553
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: gapLabel(delta, measurement) })
554
+ ] }),
528
555
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
529
556
  "div",
530
557
  {
@@ -564,9 +591,30 @@ var funnelList = { listStyle: "none", margin: 0, padding: 0 };
564
591
  var funnelItem = { display: "grid", gap: 4 };
565
592
  var funnelGap = {
566
593
  display: "flex",
594
+ alignItems: "center",
567
595
  justifyContent: "flex-end",
596
+ gap: 8,
568
597
  padding: "4px 2px"
569
598
  };
599
+ var lostTrack = {
600
+ width: "38%",
601
+ maxWidth: 200,
602
+ height: 6,
603
+ borderRadius: 2,
604
+ overflow: "hidden",
605
+ background: "currentColor",
606
+ opacity: 0.1,
607
+ // Right to left: the bar grows back toward the funnel it came out of, which reads as leaving
608
+ // rather than as another quantity accumulating alongside.
609
+ display: "flex",
610
+ justifyContent: "flex-end"
611
+ };
612
+ var lostFill = {
613
+ height: "100%",
614
+ borderRadius: 2,
615
+ background: SERIES.ga4Pageviews,
616
+ opacity: 0.75
617
+ };
570
618
  function funnelStage(active) {
571
619
  return {
572
620
  display: "grid",
@@ -581,10 +629,11 @@ function funnelStage(active) {
581
629
  var barFill = {
582
630
  height: "100%",
583
631
  borderRadius: 2,
584
- // currentColor at an alpha that clears 3:1 against the card in both themes, so the mark works
585
- // from one declaration rather than needing a palette.
586
- background: "currentColor",
587
- opacity: 0.55
632
+ // The reference blue, as everywhere else the tool draws "people who were here". Previously
633
+ // currentColor at 0.55, which made the funnel a stack of grey bars whose only variable was
634
+ // length — legible, and giving the eye nothing to hold on to across five rungs.
635
+ background: SERIES.vercel,
636
+ opacity: 0.85
588
637
  };
589
638
  var barTrack = {
590
639
  height: 8,
@@ -656,6 +705,44 @@ function columnIsEmpty(column, rows) {
656
705
  return value === 0 || value === "";
657
706
  });
658
707
  }
708
+ function Section({
709
+ title,
710
+ subtitle,
711
+ tone = "primary",
712
+ collapsible = false,
713
+ defaultOpen = true,
714
+ children
715
+ }) {
716
+ const [open, setOpen] = import_react2.default.useState(defaultOpen);
717
+ const bodyId = import_react2.default.useId();
718
+ const shown = !collapsible || open;
719
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("section", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 3, children: [
720
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: sectionHeader, children: [
721
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 1, children: [
722
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Heading, { size: tone === "primary" ? 1 : 0, style: tone === "primary" ? primaryTitle : secondaryTitle, children: title }),
723
+ subtitle && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, muted: true, children: subtitle })
724
+ ] }),
725
+ collapsible && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
726
+ "button",
727
+ {
728
+ type: "button",
729
+ style: sectionToggle,
730
+ "aria-expanded": open,
731
+ "aria-controls": bodyId,
732
+ onClick: () => setOpen((was) => !was),
733
+ children: open ? "Hide" : "Show"
734
+ }
735
+ )
736
+ ] }),
737
+ shown && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { id: bodyId, children })
738
+ ] }) });
739
+ }
740
+ function SectionTitle({
741
+ title,
742
+ tone = "primary"
743
+ }) {
744
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: sectionHeader, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Heading, { size: tone === "primary" ? 1 : 0, style: tone === "primary" ? primaryTitle : secondaryTitle, children: title }) });
745
+ }
659
746
  function SortableTable({
660
747
  caption,
661
748
  columns,
@@ -839,6 +926,40 @@ var tableControls = {
839
926
  gap: 10,
840
927
  flexWrap: "wrap"
841
928
  };
929
+ var sectionHeader = {
930
+ display: "flex",
931
+ alignItems: "flex-start",
932
+ justifyContent: "space-between",
933
+ gap: 12,
934
+ borderBottom: "1px solid var(--card-border-color, rgba(128,128,128,0.22))",
935
+ paddingBottom: 6
936
+ };
937
+ var primaryTitle = { margin: 0, lineHeight: 1.3 };
938
+ var secondaryTitle = {
939
+ margin: 0,
940
+ lineHeight: 1.3,
941
+ textTransform: "uppercase",
942
+ letterSpacing: "0.07em",
943
+ opacity: 0.75
944
+ };
945
+ var sectionToggle = {
946
+ appearance: "none",
947
+ background: "transparent",
948
+ border: "1px solid var(--card-border-color, rgba(128,128,128,0.3))",
949
+ borderRadius: 3,
950
+ color: "inherit",
951
+ font: "inherit",
952
+ fontSize: "0.8em",
953
+ padding: "2px 9px",
954
+ cursor: "pointer",
955
+ flex: "0 0 auto"
956
+ };
957
+ var splitGrid = {
958
+ display: "grid",
959
+ gridTemplateColumns: "repeat(auto-fit, minmax(min(26rem, 100%), 1fr))",
960
+ gap: 24,
961
+ alignItems: "start"
962
+ };
842
963
  var filterInput = {
843
964
  font: "inherit",
844
965
  fontSize: "0.85em",
@@ -954,30 +1075,6 @@ var import_sanity_ui_compat2 = require("@liiift-studio/sanity-ui-compat");
954
1075
  var import_d3_scale = require("d3-scale");
955
1076
  var import_d3_shape = require("d3-shape");
956
1077
  var import_d3_array = require("d3-array");
957
-
958
- // src/studio/palette.ts
959
- var SERIES = {
960
- /** The complete, server-side count. The reference every other line is read against. */
961
- vercel: "#4C8FD0",
962
- /** GA4's pageviews — the like-for-like counterpart to Vercel's. */
963
- ga4Pageviews: "#DD6B3F",
964
- /** GA4's sessions. Warm, like its sibling above: same instrument, different unit. */
965
- ga4Sessions: "#BC861F",
966
- /** Orders, from the foundry's own records. Exact. */
967
- orders: "#2E9E6B",
968
- /** Revenue, same source, different unit. */
969
- revenue: "#BF5D9B"
970
- };
971
- var REGION_ALPHA = 0.18;
972
- function seriesFill(key, alpha = REGION_ALPHA) {
973
- const hex = SERIES[key];
974
- const r = parseInt(hex.slice(1, 3), 16);
975
- const g = parseInt(hex.slice(3, 5), 16);
976
- const b = parseInt(hex.slice(5, 7), 16);
977
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
978
- }
979
-
980
- // src/studio/CrossSourceTimeline.tsx
981
1078
  var import_jsx_runtime2 = require("react/jsx-runtime");
982
1079
  function colorFor(source, unit, explicit) {
983
1080
  if (explicit) return explicit;
@@ -1694,7 +1791,6 @@ var OLDER_ROUTE = "This site\u2019s analytics route is older than this figure \u
1694
1791
  function maxOf(metrics) {
1695
1792
  return metrics.reduce((max, metric) => metric.status === "unavailable" ? max : Math.max(max, metric.value), 0);
1696
1793
  }
1697
- var sectionHeading = { margin: "0 0 2px", lineHeight: 1.3 };
1698
1794
  function finiteOrNull(value) {
1699
1795
  return typeof value === "number" && Number.isFinite(value) ? value : null;
1700
1796
  }
@@ -1796,7 +1892,7 @@ function OverviewPanel({ data, previous, onBrush }) {
1796
1892
  ] }) })
1797
1893
  ] }),
1798
1894
  (data.crossSource?.length ?? 0) >= 3 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1799
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Everything, on one time axis" }),
1895
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SectionTitle, { title: "Everything, on one time axis" }),
1800
1896
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Each row has its own scale. Hover a day, or use the control below, to see what each source saw of it." }),
1801
1897
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1802
1898
  CrossSourceTimeline,
@@ -1980,7 +2076,7 @@ function OverviewPanel({ data, previous, onBrush }) {
1980
2076
  )
1981
2077
  ] }),
1982
2078
  (data.campaigns?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1983
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Email campaigns" }),
2079
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SectionTitle, { title: "Email campaigns" }),
1984
2080
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Clicks are distinct subscribers. Opens are inflated by Apple Mail Privacy Protection, which fetches images on the recipient\u2019s behalf \u2014 so sort on clicks, not opens." }),
1985
2081
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: [
1986
2082
  "The last two columns count what your order book records in the ",
@@ -2094,7 +2190,7 @@ function DataHealthPanel({ data, diagnostics }) {
2094
2190
  const pageviewMax = maxOf([data.ga4Pageviews, data.vercelPageviews]);
2095
2191
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
2096
2192
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2097
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Pageviews, source against source" }),
2193
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SectionTitle, { title: "Pageviews, source against source" }),
2098
2194
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.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." }),
2099
2195
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ComparisonBar, { label: "Vercel pageviews", metric: data.vercelPageviews, max: pageviewMax, outOf: "Blocked less than GA4, but not immune to it." }),
2100
2196
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ComparisonBar, { label: "GA4 pageviews", metric: data.ga4Pageviews, max: pageviewMax, outOf: "A subset of the bar above, not a rival measurement." })
@@ -2104,7 +2200,7 @@ function DataHealthPanel({ data, diagnostics }) {
2104
2200
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: data.interpretation })
2105
2201
  ] }) }),
2106
2202
  ((data.capture?.estimates?.length ?? 0) > 0 || data.estimatedSessions?.status === "estimated") && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2107
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "How much GA4 is seeing" }),
2203
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SectionTitle, { title: "How much GA4 is seeing" }),
2108
2204
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: cardGrid, children: (data.capture?.estimates ?? []).map((estimate) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2109
2205
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: estimate.basis === "orders" ? "Measured against orders" : estimate.basis === "email" ? "Measured against email clicks" : "Measured against Vercel" }),
2110
2206
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 4, children: formatPercent(estimate.rate, 0) }),
@@ -2127,36 +2223,52 @@ function DataHealthPanel({ data, diagnostics }) {
2127
2223
  ] }) })
2128
2224
  ] }),
2129
2225
  Object.keys(data.orderStatuses ?? {}).length > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2130
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Order statuses in this range" }),
2226
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SectionTitle, { title: "Order statuses in this range", tone: "secondary" }),
2131
2227
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "What the orders actually say, before any filtering. Use these values to set which statuses count as a sale." }),
2132
2228
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: cardGrid, children: Object.entries(data.orderStatuses ?? {}).sort((a, b) => b[1] - a[1]).map(([status, count]) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2133
2229
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: status }),
2134
2230
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 3, children: formatCount(count) })
2135
2231
  ] }) }, status)) })
2136
2232
  ] }),
2137
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2138
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Context" }),
2139
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Different units to the figures above, and to each other." }),
2140
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: cardGrid, children: [
2141
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2142
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Vercel visitors" }),
2143
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: metricOr(data.vercelVisitors, OLDER_ROUTE), label: "Vercel visitors" }),
2144
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Vercel\u2019s own counter. Blocked far less often than Google Analytics, but not never \u2014 so read it as a floor on your real traffic." })
2145
- ] }) }),
2146
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2147
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "GA4 sessions" }),
2148
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: data.ga4Sessions, label: "GA4 sessions" })
2149
- ] }) }),
2150
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2151
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Consent granted" }),
2152
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: data.consentRate, label: "Consent granted, share of visitors GA4 saw", unit: "percent" })
2153
- ] }) })
2154
- ] })
2155
- ] }),
2156
- diagnostics && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2157
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Configuration" }),
2158
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(DiagnosticsPanel, { data: diagnostics })
2159
- ] })
2233
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2234
+ Section,
2235
+ {
2236
+ title: "Context",
2237
+ tone: "secondary",
2238
+ subtitle: "Different units to the figures above, and to each other.",
2239
+ collapsible: true,
2240
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: cardGrid, children: [
2241
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2242
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Vercel visitors" }),
2243
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: metricOr(data.vercelVisitors, OLDER_ROUTE), label: "Vercel visitors" }),
2244
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Vercel\u2019s own counter. Blocked far less often than Google Analytics, but not never \u2014 so read it as a floor on your real traffic." })
2245
+ ] }) }),
2246
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2247
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "GA4 sessions" }),
2248
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: data.ga4Sessions, label: "GA4 sessions" })
2249
+ ] }) }),
2250
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2251
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Consent granted" }),
2252
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: data.consentRate, label: "Consent granted, share of visitors GA4 saw", unit: "percent" })
2253
+ ] }) })
2254
+ ] })
2255
+ }
2256
+ ),
2257
+ diagnostics && /* Open, and primary. This is the longest block on the tab and the obvious candidate
2258
+ for folding away — but the tab it sits on exists to answer "what should I fix
2259
+ before trusting any of this", and these checks ARE that answer. Folding it would
2260
+ have left Data health opening on a summary of the problem with the solution behind
2261
+ a click. It stays collapsible so a reader who has read it can get it out of the
2262
+ way. */
2263
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2264
+ Section,
2265
+ {
2266
+ title: "Configuration",
2267
+ subtitle: "What this site has wired up, and what it is missing.",
2268
+ collapsible: true,
2269
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(DiagnosticsPanel, { data: diagnostics })
2270
+ }
2271
+ )
2160
2272
  ] });
2161
2273
  }
2162
2274
  function AcquisitionPanel({ data, previous }) {
@@ -2211,7 +2323,7 @@ function AcquisitionPanel({ data, previous }) {
2211
2323
  ] }) })
2212
2324
  ] }),
2213
2325
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2214
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Traffic sources" }),
2326
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SectionTitle, { title: "Traffic sources" }),
2215
2327
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Sort, filter, or exclude a row to see what the rest looks like." }),
2216
2328
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2217
2329
  SortableTable,
@@ -2357,69 +2469,74 @@ function JourneyPanel({ data }) {
2357
2469
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: tracked, children: data.approximationNote })
2358
2470
  ] }) }),
2359
2471
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(FunnelChart, { stages, measurement: data.measurement ?? "independent-totals" }),
2360
- (data.outcomes?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2361
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Other outcomes" }),
2362
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Successful outcomes that are not a licence sale. Counted over the same window as the funnel, but not a step within it." }),
2363
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: cardGrid, children: (data.outcomes ?? []).map((outcome) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2364
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: outcome.label }),
2365
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: outcome.count, label: outcome.label, size: 4 }),
2366
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: outcome.note })
2367
- ] }) }, outcome.key)) })
2472
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: splitGrid, children: [
2473
+ (data.outcomes?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2474
+ Section,
2475
+ {
2476
+ title: "Other outcomes",
2477
+ subtitle: "Successful outcomes that are not a licence sale, counted over the same window as the funnel but not a step within it.",
2478
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: cardGrid, children: (data.outcomes ?? []).map((outcome) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2479
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: outcome.label }),
2480
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: outcome.count, label: outcome.label, size: 4 }),
2481
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: outcome.note })
2482
+ ] }) }, outcome.key)) })
2483
+ }
2484
+ ),
2485
+ (data.topLandingPages?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Section, { title: "Where sessions began", subtitle: "The page a visit started on, busiest first.", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(LandingPagesTable, { rows: data.topLandingPages ?? [] }) })
2368
2486
  ] }),
2369
2487
  hiddenSteps.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: [
2370
2488
  "Not shown: ",
2371
2489
  hiddenSteps.map((step) => step.label.toLowerCase()).join(", "),
2372
2490
  " \u2014 not instrumented on this site, so there is no figure to place in the funnel."
2373
- ] }),
2374
- (data.topLandingPages?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2375
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Where sessions began" }),
2376
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2377
- SortableTable,
2378
- {
2379
- caption: "Landing pages by sessions",
2380
- initialSort: "sessions",
2381
- rows: data.topLandingPages ?? [],
2382
- rowKey: (page) => page.path,
2383
- filterOn: (page) => page.path,
2384
- filterPlaceholder: "Filter pages",
2385
- exportName: "landing-pages",
2386
- columns: [
2387
- {
2388
- key: "path",
2389
- label: "Page",
2390
- sortValue: (page) => page.path,
2391
- // The comment here used to describe this fix without applying it: it said
2392
- // the foundry's own URLs were the one table that did not link while
2393
- // referrer hosts did, and then rendered plain text. A comment asserting
2394
- // something the code does not do is worse than no comment.
2395
- render: (page) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("a", { href: page.path, target: "_blank", rel: "noopener noreferrer", style: sourceLink, children: page.path }) })
2396
- },
2397
- {
2398
- key: "sessions",
2399
- label: "Sessions",
2400
- numeric: true,
2401
- sortValue: (page) => page.sessions,
2402
- render: (page) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: formatCount(page.sessions) })
2403
- },
2404
- {
2405
- key: "engagement",
2406
- label: "Engaged",
2407
- numeric: true,
2408
- sortValue: (page) => finiteOrNull(page.engagementRate),
2409
- exportValue: (page) => {
2410
- const r = finiteOrNull(page.engagementRate);
2411
- return r === null ? null : formatPercent(r, 0);
2412
- },
2413
- // Volume alone could not distinguish a page that delivers 200 arrivals
2414
- // which leave from one that feeds the shop.
2415
- render: (page) => finiteOrNull(page.engagementRate) === null ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "\u2014" }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: formatPercent(page.engagementRate, 0) })
2416
- }
2417
- ]
2418
- }
2419
- )
2420
2491
  ] })
2421
2492
  ] });
2422
2493
  }
2494
+ function LandingPagesTable({ rows }) {
2495
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2496
+ SortableTable,
2497
+ {
2498
+ caption: "Landing pages by sessions",
2499
+ initialSort: "sessions",
2500
+ rows,
2501
+ rowKey: (page) => page.path,
2502
+ filterOn: (page) => page.path,
2503
+ filterPlaceholder: "Filter pages",
2504
+ exportName: "landing-pages",
2505
+ columns: [
2506
+ {
2507
+ key: "path",
2508
+ label: "Page",
2509
+ sortValue: (page) => page.path,
2510
+ // The comment here used to describe this fix without applying it: it said
2511
+ // the foundry's own URLs were the one table that did not link while
2512
+ // referrer hosts did, and then rendered plain text. A comment asserting
2513
+ // something the code does not do is worse than no comment.
2514
+ render: (page) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("a", { href: page.path, target: "_blank", rel: "noopener noreferrer", style: sourceLink, children: page.path }) })
2515
+ },
2516
+ {
2517
+ key: "sessions",
2518
+ label: "Sessions",
2519
+ numeric: true,
2520
+ sortValue: (page) => page.sessions,
2521
+ render: (page) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: formatCount(page.sessions) })
2522
+ },
2523
+ {
2524
+ key: "engagement",
2525
+ label: "Engaged",
2526
+ numeric: true,
2527
+ sortValue: (page) => finiteOrNull(page.engagementRate),
2528
+ exportValue: (page) => {
2529
+ const r = finiteOrNull(page.engagementRate);
2530
+ return r === null ? null : formatPercent(r, 0);
2531
+ },
2532
+ // Volume alone could not distinguish a page that delivers 200 arrivals
2533
+ // which leave from one that feeds the shop.
2534
+ render: (page) => finiteOrNull(page.engagementRate) === null ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "\u2014" }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: formatPercent(page.engagementRate, 0) })
2535
+ }
2536
+ ]
2537
+ }
2538
+ );
2539
+ }
2423
2540
  var MIN_FAMILY_VIEWS = 30;
2424
2541
  function catalogueRate(rows) {
2425
2542
  let views = 0;
@@ -2476,7 +2593,7 @@ function TypefaceInterestPanel({ data }) {
2476
2593
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
2477
2594
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: data.interpretationNote }) }),
2478
2595
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2479
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Engagement by typeface" }),
2596
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SectionTitle, { title: "Engagement by typeface" }),
2480
2597
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: [
2481
2598
  "Sort by any column. \u201CSells vs catalogue\u201D compares each family\u2019s sales-per-view against the catalogue as a whole \u2014 1.0\xD7 is average, and sorting up finds the families that are looked at and do not sell. It is a comparison between your families, not the share of visitors who buy: Google Analytics sees only a fraction of the views, which moves every family together and so cancels out here. With one or two sales a family will still swing a long way, so read it alongside the order counts beside it.",
2482
2599
  " ",
@@ -2611,7 +2728,7 @@ function TypefaceInterestPanel({ data }) {
2611
2728
  )
2612
2729
  ] }),
2613
2730
  (data.licences?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2614
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "How licences sell" }),
2731
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SectionTitle, { title: "How licences sell" }),
2615
2732
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "From the orders themselves, so this is exact and unaffected by whatever GA4 is doing. Tier and term are separate rows, because they are the two variables in the pricing question and a tier that sells well at one year may not at perpetual." }),
2616
2733
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2617
2734
  ProportionChart,