@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 +12 -0
- package/dist/index.d.mts +10 -4
- package/dist/index.d.ts +10 -4
- package/dist/index.js +275 -69
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +281 -75
- package/dist/index.mjs.map +1 -1
- package/dist/{ranges-B1YwjGGt.d.mts → ranges-YGStz1nR.d.mts} +51 -5
- package/dist/{ranges-B1YwjGGt.d.ts → ranges-YGStz1nR.d.ts} +51 -5
- package/dist/server.d.mts +22 -11
- package/dist/server.d.ts +22 -11
- package/dist/server.js +156 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +153 -6
- package/dist/server.mjs.map +1 -1
- package/dist/testing.d.mts +14 -1
- package/dist/testing.d.ts +14 -1
- package/dist/testing.js +12 -0
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +12 -0
- package/dist/testing.mjs.map +1 -1
- package/dist/{vercel-BnuGVKWS.d.mts → vercel-C597eCC_.d.mts} +31 -1
- package/dist/{vercel-BnuGVKWS.d.ts → vercel-C597eCC_.d.ts} +31 -1
- package/package.json +3 -3
- package/src/core/core.test.ts +89 -1
- package/src/core/ranges.ts +64 -2
- package/src/reportData.ts +11 -2
- package/src/server/createHandler.ts +56 -3
- package/src/server/ga4.ts +139 -2
- package/src/server/reports/journey.ts +79 -8
- package/src/server/reports/reports.test.ts +267 -0
- package/src/studio/Figure.tsx +162 -1
- package/src/studio/VisitorInsightsTool.tsx +184 -46
- package/src/studio/panels.test.tsx +78 -9
- package/src/studio/panels.tsx +23 -22
- package/src/studio/useReport.ts +33 -6
- package/src/testing/fakes.ts +18 -1
- package/src/types.ts +8 -1
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-
|
|
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-
|
|
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 —
|
|
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-
|
|
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-
|
|
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 —
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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.
|
|
669
|
-
|
|
670
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
671
|
-
|
|
672
|
-
|
|
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: "
|
|
822
|
-
{ key: "
|
|
823
|
-
{ key: "
|
|
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({
|
|
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.
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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({
|
|
937
|
-
|
|
938
|
-
|
|
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" &&
|
|
946
|
-
|
|
947
|
-
|
|
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
|
|
975
|
-
|
|
976
|
-
|
|
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)(
|
|
1184
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RangeSelector, { value: range, custom, onChange: setRange, onCustomChange: setCustom })
|
|
979
1185
|
] }),
|
|
980
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
};
|