@liiift-studio/sanity-visitor-insights 0.10.0 → 0.12.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/README.md CHANGED
@@ -184,6 +184,7 @@ empty charts — pasting a measurement id here is caught by name.
184
184
 
185
185
  | Variable | Where | What |
186
186
  |---|---|---|
187
+ | `VISITOR_INSIGHTS_ENABLED` | Site (server) | **Required.** Any truthy value switches the route on |
187
188
  | `VISITOR_INSIGHTS_GA4_SERVICE_ACCOUNT` | Site (server) | Service-account JSON, raw or base64 |
188
189
  | `VISITOR_INSIGHTS_VERCEL_TOKEN` | Site (server) | Vercel API token with project read access |
189
190
  | `SANITY_STUDIO_PROJECT_ID` | Site (server) | Already set; used to verify Studio tokens |
@@ -191,6 +192,17 @@ empty charts — pasting a measurement id here is caught by name.
191
192
  The service account needs **Viewer** on each GA4 property. Nothing here is `NEXT_PUBLIC_`; none of
192
193
  it reaches the browser.
193
194
 
195
+ #### The master switch
196
+
197
+ `VISITOR_INSIGHTS_ENABLED` is an explicit opt-in. Unset, the route answers `503` with
198
+ `disabled: true` and does no Sanity or GA4 work, and the Studio tool shows a neutral "Switched off"
199
+ card rather than an error. Set it to anything that is not `false`, `0`, `off`, `no`, `disabled` or
200
+ empty — a word, a code, `true`, whatever the site's operator prefers.
201
+
202
+ This is deliberately the opposite default to `SALES_PORTAL_ENABLED`, which is on unless the value
203
+ is literally `"false"`. Mounting this route wires up analytics credentials, so it should not begin
204
+ answering on a site where nobody chose to switch it on.
205
+
194
206
  ---
195
207
 
196
208
  ## Before it will show anything useful
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as sanity from 'sanity';
2
2
  import React from 'react';
3
- import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-B1YwjGGt.mjs';
4
- export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-B1YwjGGt.mjs';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-YGStz1nR.mjs';
4
+ export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-YGStz1nR.mjs';
5
5
 
6
6
  /**
7
7
  * The Visitor Insights Studio tool.
@@ -54,9 +54,15 @@ type ReportState<T> = {
54
54
  } | {
55
55
  status: 'error';
56
56
  message: string;
57
+ disabled?: boolean;
57
58
  };
58
59
  /** Options for useReport. */
59
60
  interface UseReportOptions {
61
+ /** Explicit dates, required when `range` is 'custom' and ignored otherwise. */
62
+ custom?: {
63
+ start: string;
64
+ end: string;
65
+ };
60
66
  /** Base URL of the site serving the reports, e.g. `https://dardenstudio.com`. */
61
67
  apiBaseUrl: string;
62
68
  report: ReportName;
@@ -67,7 +73,7 @@ interface UseReportOptions {
67
73
  *
68
74
  * @returns the current state plus a `reload` for manual refresh
69
75
  */
70
- declare function useReport<T>({ apiBaseUrl, report, range }: UseReportOptions): {
76
+ declare function useReport<T>({ apiBaseUrl, report, range, custom }: UseReportOptions): {
71
77
  state: ReportState<T>;
72
78
  reload: () => void;
73
79
  };
@@ -165,7 +171,7 @@ declare function MeasurementHealthPanel({ data }: {
165
171
  declare function AcquisitionPanel({ data }: {
166
172
  data: AcquisitionData;
167
173
  }): React.ReactElement;
168
- /** Journey — per-step totals with adjacent drop-off, explicitly not a tracked path. */
174
+ /** Journey — a funnel, drawn as a tracked sequence or as independent totals, whichever the report used. */
169
175
  declare function JourneyPanel({ data }: {
170
176
  data: JourneyData;
171
177
  }): React.ReactElement;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as sanity from 'sanity';
2
2
  import React from 'react';
3
- import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-B1YwjGGt.js';
4
- export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-B1YwjGGt.js';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-YGStz1nR.js';
4
+ export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-YGStz1nR.js';
5
5
 
6
6
  /**
7
7
  * The Visitor Insights Studio tool.
@@ -54,9 +54,15 @@ type ReportState<T> = {
54
54
  } | {
55
55
  status: 'error';
56
56
  message: string;
57
+ disabled?: boolean;
57
58
  };
58
59
  /** Options for useReport. */
59
60
  interface UseReportOptions {
61
+ /** Explicit dates, required when `range` is 'custom' and ignored otherwise. */
62
+ custom?: {
63
+ start: string;
64
+ end: string;
65
+ };
60
66
  /** Base URL of the site serving the reports, e.g. `https://dardenstudio.com`. */
61
67
  apiBaseUrl: string;
62
68
  report: ReportName;
@@ -67,7 +73,7 @@ interface UseReportOptions {
67
73
  *
68
74
  * @returns the current state plus a `reload` for manual refresh
69
75
  */
70
- declare function useReport<T>({ apiBaseUrl, report, range }: UseReportOptions): {
76
+ declare function useReport<T>({ apiBaseUrl, report, range, custom }: UseReportOptions): {
71
77
  state: ReportState<T>;
72
78
  reload: () => void;
73
79
  };
@@ -165,7 +171,7 @@ declare function MeasurementHealthPanel({ data }: {
165
171
  declare function AcquisitionPanel({ data }: {
166
172
  data: AcquisitionData;
167
173
  }): React.ReactElement;
168
- /** Journey — per-step totals with adjacent drop-off, explicitly not a tracked path. */
174
+ /** Journey — a funnel, drawn as a tracked sequence or as independent totals, whichever the report used. */
169
175
  declare function JourneyPanel({ data }: {
170
176
  data: JourneyData;
171
177
  }): React.ReactElement;
package/dist/index.js CHANGED
@@ -68,7 +68,7 @@ var import_sanity_ui_compat3 = require("@liiift-studio/sanity-ui-compat");
68
68
  var import_react = require("react");
69
69
  var import_sanity = require("sanity");
70
70
  var API_VERSION = "2024-03-01";
71
- function useReport({ apiBaseUrl, report, range }) {
71
+ function useReport({ apiBaseUrl, report, range, custom }) {
72
72
  const client = (0, import_sanity.useClient)({ apiVersion: API_VERSION });
73
73
  const [state, setState] = (0, import_react.useState)({ status: "idle" });
74
74
  const [nonce, setNonce] = (0, import_react.useState)(0);
@@ -90,15 +90,27 @@ function useReport({ apiBaseUrl, report, range }) {
90
90
  }
91
91
  try {
92
92
  const base = typeof apiBaseUrl === "string" ? apiBaseUrl.replace(/\/$/, "") : "";
93
- const url = `${base}/api/visitor-insights/${report}?range=${range}`;
93
+ const query = new URLSearchParams({ range });
94
+ if (range === "custom" && custom) {
95
+ query.set("start", custom.start);
96
+ query.set("end", custom.end);
97
+ }
98
+ const url = `${base}/api/visitor-insights/${report}?${query.toString()}`;
94
99
  const response = await fetch(url, {
95
100
  headers: { Authorization: `Bearer ${token}` },
96
101
  signal: controller.signal
97
102
  });
98
103
  if (requestIdRef.current !== requestId) return;
99
104
  if (!response.ok) {
100
- const detail = response.status === 401 ? "Not authorised \u2014 sign in to the Studio again." : `Request failed (${response.status})`;
101
- setState({ status: "error", message: detail });
105
+ let detail = response.status === 401 ? "Not authorised \u2014 sign in to the Studio again." : `Request failed (${response.status})`;
106
+ let disabled = false;
107
+ if (response.status === 400 || response.status === 503) {
108
+ const body = await response.json().catch(() => null);
109
+ if (body?.error) detail = body.error;
110
+ disabled = body?.disabled === true;
111
+ }
112
+ if (requestIdRef.current !== requestId) return;
113
+ setState({ status: "error", message: detail, disabled });
102
114
  return;
103
115
  }
104
116
  const envelope = await response.json();
@@ -111,7 +123,7 @@ function useReport({ apiBaseUrl, report, range }) {
111
123
  }
112
124
  void run();
113
125
  return () => controller.abort();
114
- }, [client, apiBaseUrl, report, range, nonce]);
126
+ }, [client, apiBaseUrl, report, range, custom?.start, custom?.end, nonce]);
115
127
  return { state, reload };
116
128
  }
117
129
 
@@ -175,6 +187,83 @@ function ComparisonBar({ label, metric, max, tone = "default" }) {
175
187
  )
176
188
  ] });
177
189
  }
190
+ function FunnelChart({ stages, measurement }) {
191
+ const [activeKey, setActiveKey] = (0, import_react2.useState)(null);
192
+ const refs = (0, import_react2.useRef)([]);
193
+ const entry = stages[0]?.value ?? 0;
194
+ if (stages.length === 0) return null;
195
+ const onKeyDown = (event, index) => {
196
+ let next = null;
197
+ if (event.key === "ArrowDown" || event.key === "ArrowRight") next = Math.min(index + 1, stages.length - 1);
198
+ if (event.key === "ArrowUp" || event.key === "ArrowLeft") next = Math.max(index - 1, 0);
199
+ if (event.key === "Home") next = 0;
200
+ if (event.key === "End") next = stages.length - 1;
201
+ if (next === null) return;
202
+ event.preventDefault();
203
+ refs.current[next]?.focus();
204
+ };
205
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ol", { style: funnelList, children: stages.map((stage, index) => {
206
+ const previous = index > 0 ? stages[index - 1] : null;
207
+ const share = entry > 0 ? stage.value / entry : 0;
208
+ const width = Math.max(1.5, Math.min(1, share) * 100);
209
+ const active = activeKey === stage.key;
210
+ const delta = previous ? previous.value - stage.value : 0;
211
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { style: funnelItem, children: [
212
+ 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) }) }),
213
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
214
+ "div",
215
+ {
216
+ ref: (el) => {
217
+ refs.current[index] = el;
218
+ },
219
+ tabIndex: 0,
220
+ role: "listitem",
221
+ style: funnelStage(active),
222
+ onMouseEnter: () => setActiveKey(stage.key),
223
+ onMouseLeave: () => setActiveKey(null),
224
+ onFocus: () => setActiveKey(stage.key),
225
+ onBlur: () => setActiveKey(null),
226
+ onKeyDown: (e) => onKeyDown(e, index),
227
+ children: [
228
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: barHeader, children: [
229
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "medium", children: stage.label }),
230
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "semibold", children: formatCount(stage.value) })
231
+ ] }),
232
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { "aria-hidden": "true", radius: 2, tone: "transparent", border: true, style: funnelTrack, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { tone: "primary", radius: 2, style: { width: `${width}%`, height: "100%" } }) }),
233
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
234
+ index === 0 ? "entry step" : `${formatPercent(share, 1)} of ${stages[0]?.label.toLowerCase()}`,
235
+ index > 1 && stage.conversionFromPrevious !== null && previous ? ` \xB7 ${formatPercent(stage.conversionFromPrevious, 1)} of ${previous.label.toLowerCase()}` : ""
236
+ ] })
237
+ ]
238
+ }
239
+ )
240
+ ] }, stage.key);
241
+ }) });
242
+ }
243
+ function gapLabel(delta, measurement) {
244
+ if (delta === 0) return measurement === "sequence" ? "no drop-off" : "no difference";
245
+ if (delta > 0) return measurement === "sequence" ? `\u2212${formatCount(delta)} did not continue` : `${formatCount(delta)} fewer`;
246
+ return `${formatCount(-delta)} more \u2014 not a subset of the step above`;
247
+ }
248
+ var funnelList = { listStyle: "none", margin: 0, padding: 0 };
249
+ var funnelItem = { display: "grid", gap: 4 };
250
+ var funnelGap = {
251
+ display: "flex",
252
+ justifyContent: "flex-end",
253
+ padding: "4px 2px"
254
+ };
255
+ function funnelStage(active) {
256
+ return {
257
+ display: "grid",
258
+ gap: 6,
259
+ padding: "8px 10px",
260
+ borderRadius: 3,
261
+ border: `1px solid ${active ? "currentColor" : "transparent"}`,
262
+ background: active ? "var(--card-bg-color, rgba(128,128,128,0.08))" : "transparent",
263
+ cursor: "default"
264
+ };
265
+ }
266
+ var funnelTrack = { height: 10, overflow: "hidden" };
178
267
  var chartFrame = { position: "relative", width: "100%" };
179
268
  var readout = {
180
269
  position: "absolute",
@@ -663,17 +752,16 @@ function JourneyPanel({ data }) {
663
752
  const allSteps = data.steps ?? [];
664
753
  const shown = allSteps.filter((step) => step.count.status !== "unavailable");
665
754
  const hiddenSteps = allSteps.filter((step) => step.count.status === "unavailable");
666
- const max = maxOf(shown.map((step) => step.count));
755
+ const stages = shown.flatMap(
756
+ (step) => step.count.status === "unavailable" ? [] : [{ key: step.key, label: step.label, value: step.count.value, conversionFromPrevious: step.conversionFromPrevious }]
757
+ );
758
+ const tracked = data.measurement === "sequence";
667
759
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 4, children: [
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 }) }),
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: [
670
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ComparisonBar, { label: step.label, metric: step.count, max, tone: "primary" }),
671
- step.conversionFromPrevious !== null && index > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: [
672
- formatPercent(step.conversionFromPrevious, 1),
673
- " of ",
674
- shown[index - 1]?.label.toLowerCase()
675
- ] })
676
- ] }, step.key)) }),
760
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: tracked ? "transparent" : "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 2, children: [
761
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, weight: "medium", children: tracked ? "Tracked funnel" : "Independent per-step totals" }),
762
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: tracked, children: data.approximationNote })
763
+ ] }) }),
764
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(FunnelChart, { stages, measurement: data.measurement ?? "independent-totals" }),
677
765
  hiddenSteps.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: [
678
766
  "Not shown: ",
679
767
  hiddenSteps.map((step) => step.label.toLowerCase()).join(", "),
@@ -794,6 +882,12 @@ function DiagnosticsPanel({ data }) {
794
882
 
795
883
  // src/studio/VisitorInsightsTool.tsx
796
884
  var import_jsx_runtime3 = require("react/jsx-runtime");
885
+ var controlRow = {
886
+ display: "flex",
887
+ gap: 6,
888
+ alignItems: "center",
889
+ flexWrap: "wrap"
890
+ };
797
891
  var tabStrip = {
798
892
  display: "flex",
799
893
  gap: 2,
@@ -817,10 +911,39 @@ function tabStyle(selected) {
817
911
  whiteSpace: "nowrap"
818
912
  };
819
913
  }
914
+ function rangeButton(selected) {
915
+ return {
916
+ appearance: "none",
917
+ background: "transparent",
918
+ border: `1px solid ${selected ? "currentColor" : "var(--card-border-color, rgba(128,128,128,0.3))"}`,
919
+ borderRadius: 3,
920
+ color: "inherit",
921
+ opacity: selected ? 1 : 0.7,
922
+ font: "inherit",
923
+ fontSize: "0.85em",
924
+ fontWeight: selected ? 600 : 400,
925
+ padding: "5px 10px",
926
+ cursor: "pointer",
927
+ whiteSpace: "nowrap"
928
+ };
929
+ }
930
+ var pickerRow = { display: "flex", gap: 10, alignItems: "flex-end", flexWrap: "wrap" };
931
+ var pickerField = { display: "grid", gap: 2 };
932
+ var dateInput = {
933
+ font: "inherit",
934
+ fontSize: "0.85em",
935
+ padding: "4px 6px",
936
+ borderRadius: 3,
937
+ border: "1px solid var(--card-border-color, rgba(128,128,128,0.3))",
938
+ background: "transparent",
939
+ color: "inherit",
940
+ colorScheme: "light dark"
941
+ };
820
942
  var RANGES = [
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" }
943
+ { key: "week", label: "Week", span: "the last 7 days" },
944
+ { key: "month", label: "Month", span: "the last 30 days" },
945
+ { key: "quarter", label: "Quarter", span: "the last 91 days" },
946
+ { key: "year", label: "Year", span: "the last 365 days" }
824
947
  ];
825
948
  var PANELS = [
826
949
  { report: "acquisition", label: "Acquisition", blurb: "Where visitors come from" },
@@ -829,8 +952,14 @@ var PANELS = [
829
952
  { report: "measurement-health", label: "Measurement health", blurb: "How much of reality each source actually sees" },
830
953
  { report: "diagnostics", label: "Diagnostics", blurb: "What to fix before trusting the numbers above" }
831
954
  ];
832
- function RangeSelector({ value, onChange }) {
955
+ function RangeSelector({
956
+ value,
957
+ custom,
958
+ onChange,
959
+ onCustomChange
960
+ }) {
833
961
  const refs = (0, import_react3.useRef)([]);
962
+ const [pickerOpen, setPickerOpen] = (0, import_react3.useState)(value === "custom");
834
963
  const onKeyDown = (0, import_react3.useCallback)(
835
964
  (event) => {
836
965
  const currentIndex = RANGES.findIndex((r) => r.key === value);
@@ -843,33 +972,92 @@ function RangeSelector({ value, onChange }) {
843
972
  event.preventDefault();
844
973
  const next = RANGES[nextIndex];
845
974
  if (!next) return;
975
+ setPickerOpen(false);
846
976
  onChange(next.key);
847
977
  refs.current[nextIndex]?.focus();
848
978
  },
849
979
  [value, onChange]
850
980
  );
851
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Flex, { role: "radiogroup", "aria-label": "Date range", gap: 1, onKeyDown, children: RANGES.map((range, index) => {
852
- const selected = range.key === value;
853
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
854
- import_sanity_ui_compat3.Button,
855
- {
856
- ref: (el) => {
857
- refs.current[index] = el;
858
- },
859
- role: "radio",
860
- "aria-checked": selected,
861
- tabIndex: selected ? 0 : -1,
862
- mode: selected ? "default" : "bleed",
863
- tone: selected ? "primary" : "default",
864
- text: range.label,
865
- title: `Ending today, covering ${range.span}`,
866
- onClick: () => onChange(range.key),
867
- fontSize: 1,
868
- padding: 3
869
- },
870
- range.key
871
- );
872
- }) });
981
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 2, children: [
982
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { role: "radiogroup", "aria-label": "Date range", style: controlRow, onKeyDown, children: [
983
+ RANGES.map((range, index) => {
984
+ const selected = range.key === value;
985
+ return (
986
+ // Plain buttons rather than the UI kit's: the compat shim's DOM fallback does not
987
+ // forward `text`, which renders the whole selector as blank boxes on any Studio
988
+ // version where it cannot resolve Button.
989
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
990
+ "button",
991
+ {
992
+ type: "button",
993
+ ref: (el) => {
994
+ refs.current[index] = el;
995
+ },
996
+ role: "radio",
997
+ "aria-checked": selected,
998
+ tabIndex: selected ? 0 : -1,
999
+ title: `Ending today, covering ${range.span}`,
1000
+ style: rangeButton(selected),
1001
+ onClick: () => {
1002
+ setPickerOpen(false);
1003
+ onChange(range.key);
1004
+ },
1005
+ children: range.label
1006
+ },
1007
+ range.key
1008
+ )
1009
+ );
1010
+ }),
1011
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1012
+ "button",
1013
+ {
1014
+ type: "button",
1015
+ style: rangeButton(value === "custom"),
1016
+ "aria-expanded": pickerOpen,
1017
+ onClick: () => setPickerOpen((open) => !open),
1018
+ children: value === "custom" ? `${custom.start} to ${custom.end}` : "Custom\u2026"
1019
+ }
1020
+ )
1021
+ ] }),
1022
+ pickerOpen && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: pickerRow, children: [
1023
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { style: pickerField, children: [
1024
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "From" }),
1025
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1026
+ "input",
1027
+ {
1028
+ type: "date",
1029
+ value: custom.start,
1030
+ max: custom.end || void 0,
1031
+ style: dateInput,
1032
+ onChange: (e) => onCustomChange({ ...custom, start: e.currentTarget.value })
1033
+ }
1034
+ )
1035
+ ] }),
1036
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { style: pickerField, children: [
1037
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "To" }),
1038
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1039
+ "input",
1040
+ {
1041
+ type: "date",
1042
+ value: custom.end,
1043
+ min: custom.start || void 0,
1044
+ style: dateInput,
1045
+ onChange: (e) => onCustomChange({ ...custom, end: e.currentTarget.value })
1046
+ }
1047
+ )
1048
+ ] }),
1049
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1050
+ "button",
1051
+ {
1052
+ type: "button",
1053
+ style: rangeButton(true),
1054
+ disabled: !custom.start || !custom.end,
1055
+ onClick: () => onChange("custom"),
1056
+ children: "Apply"
1057
+ }
1058
+ )
1059
+ ] })
1060
+ ] });
873
1061
  }
874
1062
  var SOURCE_LABEL = {
875
1063
  ga4: "Google Analytics",
@@ -933,18 +1121,27 @@ function PanelTabs({ value, onChange }) {
933
1121
  );
934
1122
  }) });
935
1123
  }
936
- function ReportPanel({ report, apiBaseUrl, range }) {
937
- const { state, reload } = useReport({ apiBaseUrl, report, range });
938
- const liveMessage = state.status === "loading" ? "Loading report" : state.status === "ready" ? `${report} updated for the selected ${range}` : state.status === "error" ? `Report failed: ${state.message}` : "";
1124
+ function ReportPanel({
1125
+ report,
1126
+ apiBaseUrl,
1127
+ range,
1128
+ custom
1129
+ }) {
1130
+ const { state, reload } = useReport({ apiBaseUrl, report, range, custom });
1131
+ const liveMessage = state.status === "loading" ? "Loading report" : state.status === "ready" ? `${report} updated for ${range === "custom" ? `${custom.start} to ${custom.end}` : `the selected ${range}`}` : state.status === "error" ? state.disabled ? "Visitor insights is switched off for this site" : `Report failed: ${state.message}` : "";
939
1132
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
940
1133
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Box, { "aria-live": "polite", style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }, children: liveMessage }),
941
1134
  state.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Flex, { align: "center", gap: 3, padding: 4, children: [
942
1135
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Spinner, { muted: true }),
943
1136
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Loading\u2026" })
944
1137
  ] }),
945
- state.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 4, radius: 2, tone: "critical", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
946
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: state.message }),
947
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Button, { text: "Try again", mode: "ghost", fontSize: 1, onClick: reload }) })
1138
+ state.status === "error" && // A switched-off site is a configuration state, not a fault: it gets a neutral card
1139
+ // and no retry, because retrying cannot change the answer. Everything else keeps
1140
+ // the critical tone and the retry.
1141
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 4, radius: 2, tone: state.disabled ? "transparent" : "critical", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1142
+ state.disabled && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, weight: "medium", children: "Switched off" }),
1143
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: state.disabled, children: state.message }),
1144
+ !state.disabled && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Button, { text: "Try again", mode: "ghost", fontSize: 1, onClick: reload }) })
948
1145
  ] }) }),
949
1146
  state.status === "ready" && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
950
1147
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SourceStatusRow, { sources: state.envelope.sources }),
@@ -965,36 +1162,44 @@ function ReportPanel({ report, apiBaseUrl, range }) {
965
1162
  ] })
966
1163
  ] });
967
1164
  }
1165
+ function isoDaysAgo(days) {
1166
+ const date = /* @__PURE__ */ new Date();
1167
+ date.setDate(date.getDate() - days);
1168
+ return date.toISOString().slice(0, 10);
1169
+ }
968
1170
  function VisitorInsightsTool(props) {
969
1171
  const apiBaseUrl = props.tool?.options?.apiBaseUrl ?? props.apiBaseUrl ?? "";
970
1172
  const siteLabel = props.tool?.options?.siteLabel ?? props.siteLabel ?? "";
971
1173
  const [range, setRange] = (0, import_react3.useState)("week");
1174
+ const [custom, setCustom] = (0, import_react3.useState)(() => ({ start: isoDaysAgo(30), end: isoDaysAgo(0) }));
972
1175
  const [activePanel, setActivePanel] = (0, import_react3.useState)("acquisition");
973
1176
  const active = PANELS.find((p) => p.report === activePanel) ?? PANELS[0];
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: [
975
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Flex, { align: "flex-start", justify: "space-between", gap: 4, wrap: "wrap", children: [
976
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 2, children: [
1177
+ return (
1178
+ // Named for assistive technology even though the name is not printed: a Studio user with
1179
+ // several tabs open hears which site's figures these are, without the heading repeating
1180
+ // what the Studio navigation already says.
1181
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Container, { width: 4, padding: 4, as: "section", "aria-label": siteLabel ? `Visitor insights for ${siteLabel}` : "Visitor insights", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 5, children: [
1182
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Flex, { align: "flex-start", justify: "space-between", gap: 4, wrap: "wrap", children: [
977
1183
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 2, children: "Visitor insights" }),
978
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: siteLabel })
1184
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RangeSelector, { value: range, custom, onChange: setRange, onCustomChange: setCustom })
979
1185
  ] }),
980
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RangeSelector, { value: range, onChange: setRange })
981
- ] }),
982
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PanelTabs, { value: activePanel, onChange: setActivePanel }),
983
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
984
- import_sanity_ui_compat3.Box,
985
- {
986
- role: "tabpanel",
987
- id: `panel-${activePanel}`,
988
- "aria-labelledby": `tab-${activePanel}`,
989
- tabIndex: 0,
990
- style: { position: "relative" },
991
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
992
- active && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: active.blurb }),
993
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ReportPanel, { report: activePanel, apiBaseUrl, range })
994
- ] })
995
- }
996
- )
997
- ] }) });
1186
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PanelTabs, { value: activePanel, onChange: setActivePanel }),
1187
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1188
+ import_sanity_ui_compat3.Box,
1189
+ {
1190
+ role: "tabpanel",
1191
+ id: `panel-${activePanel}`,
1192
+ "aria-labelledby": `tab-${activePanel}`,
1193
+ tabIndex: 0,
1194
+ style: { position: "relative" },
1195
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
1196
+ active && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: active.blurb }),
1197
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ReportPanel, { report: activePanel, apiBaseUrl, range, custom })
1198
+ ] })
1199
+ }
1200
+ )
1201
+ ] }) })
1202
+ );
998
1203
  }
999
1204
 
1000
1205
  // src/types.ts
@@ -1128,6 +1333,7 @@ function daysBetween(start, end) {
1128
1333
  }
1129
1334
  var RANGE_DAYS = {
1130
1335
  week: 7,
1336
+ month: 30,
1131
1337
  quarter: 91,
1132
1338
  year: 365
1133
1339
  };