@liiift-studio/sanity-visitor-insights 0.51.0 → 0.52.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
@@ -15,7 +15,7 @@ only as a conversion anchor and a count; no revenue, and no customer data.
15
15
  |---|---|
16
16
  | **Measurement health** | How much of reality does each source actually see? |
17
17
  | **Acquisition** | Where do visitors come from, and how many are design-industry referrals? |
18
- | **Journey** | How far do visitors get, and where do they stop? |
18
+ | **Journey** | How far do visitors get, where do they stop, and is it different on mobile? |
19
19
  | **Typeface interest** | Which families get viewed, tested and bought? |
20
20
  | **Diagnostics** | What to fix before trusting any of the above? |
21
21
 
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, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-CeB8esIA.mjs';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-CeB8esIA.mjs';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-DBbZ0tg7.mjs';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneySegment, o as JourneyStep, L as LandingPage, p as LicenceTierRow, P as PREEXISTING, q as REPORT_NAMES, r as ReportError, S as SiteAnalyticsConfig, s as SourceName, t as SourceRow, u as SourceStatus, v as TimelineEvent, w as TypefaceInterestRow, U as UnavailableReason, x as coverageForRange, y as estimated, z as isReportName, B as ok, F as partial, G as previousRange, H as resolveRange, I as unavailable, K as validateSiteConfig, N as valueOrNull } from './ranges-DBbZ0tg7.mjs';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -606,7 +606,6 @@ declare function AcquisitionPanel({ data, previous }: {
606
606
  data: AcquisitionData;
607
607
  previous?: AcquisitionData;
608
608
  }): React.ReactElement;
609
- /** Journey — a funnel, drawn as a tracked sequence or as independent totals, whichever the report used. */
610
609
  declare function JourneyPanel({ data }: {
611
610
  data: JourneyData;
612
611
  }): 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, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-CeB8esIA.js';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-CeB8esIA.js';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-DBbZ0tg7.js';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneySegment, o as JourneyStep, L as LandingPage, p as LicenceTierRow, P as PREEXISTING, q as REPORT_NAMES, r as ReportError, S as SiteAnalyticsConfig, s as SourceName, t as SourceRow, u as SourceStatus, v as TimelineEvent, w as TypefaceInterestRow, U as UnavailableReason, x as coverageForRange, y as estimated, z as isReportName, B as ok, F as partial, G as previousRange, H as resolveRange, I as unavailable, K as validateSiteConfig, N as valueOrNull } from './ranges-DBbZ0tg7.js';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -606,7 +606,6 @@ declare function AcquisitionPanel({ data, previous }: {
606
606
  data: AcquisitionData;
607
607
  previous?: AcquisitionData;
608
608
  }): React.ReactElement;
609
- /** Journey — a funnel, drawn as a tracked sequence or as independent totals, whichever the report used. */
610
609
  declare function JourneyPanel({ data }: {
611
610
  data: JourneyData;
612
611
  }): React.ReactElement;
package/dist/index.js CHANGED
@@ -75,7 +75,7 @@ var import_sanity2 = require("sanity");
75
75
  var import_icons = require("@liiift-studio/sanity-ui-compat/icons");
76
76
 
77
77
  // src/studio/VisitorInsightsTool.tsx
78
- var import_react4 = __toESM(require("react"));
78
+ var import_react5 = __toESM(require("react"));
79
79
  var import_sanity_ui_compat4 = require("@liiift-studio/sanity-ui-compat");
80
80
 
81
81
  // src/studio/useReport.ts
@@ -1067,6 +1067,7 @@ var visuallyHidden = {
1067
1067
  var import_sanity_ui_compat5 = require("@liiift-studio/sanity-ui-compat");
1068
1068
 
1069
1069
  // src/studio/panels.tsx
1070
+ var import_react4 = __toESM(require("react"));
1070
1071
  var import_sanity_ui_compat3 = require("@liiift-studio/sanity-ui-compat");
1071
1072
 
1072
1073
  // src/studio/CrossSourceTimeline.tsx
@@ -1803,6 +1804,24 @@ var figureRow = {
1803
1804
  gap: 10,
1804
1805
  flexWrap: "wrap"
1805
1806
  };
1807
+ var segmentRow = { display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" };
1808
+ function segmentButton(selected) {
1809
+ return {
1810
+ appearance: "none",
1811
+ background: selected ? "var(--card-bg-color, rgba(128,128,128,0.12))" : "transparent",
1812
+ border: `1px solid ${selected ? "currentColor" : "var(--card-border-color, rgba(128,128,128,0.3))"}`,
1813
+ borderRadius: 3,
1814
+ color: "inherit",
1815
+ font: "inherit",
1816
+ fontSize: "0.85em",
1817
+ padding: "4px 10px",
1818
+ cursor: "pointer",
1819
+ display: "flex",
1820
+ alignItems: "baseline",
1821
+ gap: 7
1822
+ };
1823
+ }
1824
+ var segmentRate = { opacity: 0.65, fontVariantNumeric: "tabular-nums" };
1806
1825
  var sourceLink = {
1807
1826
  color: "inherit",
1808
1827
  textDecoration: "underline",
@@ -2455,8 +2474,33 @@ function AcquisitionPanel({ data, previous }) {
2455
2474
  data.rowsWithheld && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(NoticeList, { notices: ["GA4 withheld some low-traffic rows for privacy, so this list is shorter than reality."] })
2456
2475
  ] });
2457
2476
  }
2477
+ function firstStepRate(steps) {
2478
+ const second = steps[1];
2479
+ if (!second) return null;
2480
+ const rate = second.conversionFromPrevious;
2481
+ return typeof rate === "number" && Number.isFinite(rate) ? rate : null;
2482
+ }
2483
+ function spread(segments) {
2484
+ const MIN_USERS = 200;
2485
+ const RATIO = 3;
2486
+ const rated = segments.map((segment) => ({
2487
+ label: segment.label,
2488
+ rate: firstStepRate(segment.steps),
2489
+ users: metricSortValue(segment.steps[0]?.count) ?? 0
2490
+ })).filter((row) => row.rate !== null && row.rate > 0 && row.users >= MIN_USERS);
2491
+ if (rated.length < 2) return null;
2492
+ const sorted = [...rated].sort((a, b) => b.rate - a.rate);
2493
+ const best = sorted[0];
2494
+ const worst = sorted[sorted.length - 1];
2495
+ const ratio = best.rate / worst.rate;
2496
+ if (ratio < RATIO) return null;
2497
+ return `${worst.label} reaches the second step at ${formatPercent(worst.rate, 2)} against ${best.label}'s ${formatPercent(best.rate, 2)} \u2014 about ${Math.round(ratio)} times worse, on ${formatCount(worst.users)} visitors. The combined funnel above averages the two and describes neither.`;
2498
+ }
2458
2499
  function JourneyPanel({ data }) {
2459
- const allSteps = data.steps ?? [];
2500
+ const [segmentKey, setSegmentKey] = import_react4.default.useState(null);
2501
+ const segments = data.segments ?? [];
2502
+ const segment = segmentKey === null ? null : segments.find((row) => row.key === segmentKey) ?? null;
2503
+ const allSteps = segment ? segment.steps : data.steps ?? [];
2460
2504
  const shown = allSteps.filter((step) => step.count.status !== "unavailable");
2461
2505
  const hiddenSteps = allSteps.filter((step) => step.count.status === "unavailable");
2462
2506
  const stages = shown.flatMap(
@@ -2468,6 +2512,44 @@ function JourneyPanel({ data }) {
2468
2512
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, weight: "medium", children: tracked ? "Tracked funnel" : "Independent per-step totals" }),
2469
2513
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: tracked, children: data.approximationNote })
2470
2514
  ] }) }),
2515
+ segments.length > 1 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2516
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: segmentRow, role: "radiogroup", "aria-label": `Funnel by ${data.segmentDimension ?? "segment"}`, children: [
2517
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2518
+ "button",
2519
+ {
2520
+ type: "button",
2521
+ role: "radio",
2522
+ "aria-checked": segmentKey === null,
2523
+ style: segmentButton(segmentKey === null),
2524
+ onClick: () => setSegmentKey(null),
2525
+ children: "Everyone"
2526
+ }
2527
+ ),
2528
+ segments.map((row) => {
2529
+ const rate = firstStepRate(row.steps);
2530
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
2531
+ "button",
2532
+ {
2533
+ type: "button",
2534
+ role: "radio",
2535
+ "aria-checked": segmentKey === row.key,
2536
+ style: segmentButton(segmentKey === row.key),
2537
+ onClick: () => setSegmentKey(row.key),
2538
+ children: [
2539
+ row.label,
2540
+ rate !== null && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: segmentRate, children: formatPercent(rate, 2) })
2541
+ ]
2542
+ },
2543
+ row.key
2544
+ );
2545
+ })
2546
+ ] }),
2547
+ spread(segments) && /* Said in words, once, where the funnel is. The whole reason to split a funnel
2548
+ is that the halves differ; when they differ by a multiple rather than a
2549
+ margin, the undivided funnel above describes nobody and the reader should
2550
+ be told rather than left to press the buttons and notice. */
2551
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: spread(segments) })
2552
+ ] }),
2471
2553
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(FunnelChart, { stages, measurement: data.measurement ?? "independent-totals" }),
2472
2554
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: splitGrid, children: [
2473
2555
  (data.outcomes?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
@@ -2878,13 +2960,13 @@ function RangeSelector({
2878
2960
  onChange,
2879
2961
  onCustomChange
2880
2962
  }) {
2881
- const refs = (0, import_react4.useRef)([]);
2882
- const [pickerOpen, setPickerOpen] = (0, import_react4.useState)(value === "custom");
2883
- const [draft, setDraft] = (0, import_react4.useState)(custom);
2884
- import_react4.default.useEffect(() => {
2963
+ const refs = (0, import_react5.useRef)([]);
2964
+ const [pickerOpen, setPickerOpen] = (0, import_react5.useState)(value === "custom");
2965
+ const [draft, setDraft] = (0, import_react5.useState)(custom);
2966
+ import_react5.default.useEffect(() => {
2885
2967
  setDraft(custom);
2886
2968
  }, [custom.start, custom.end]);
2887
- const onKeyDown = (0, import_react4.useCallback)(
2969
+ const onKeyDown = (0, import_react5.useCallback)(
2888
2970
  (event) => {
2889
2971
  const currentIndex = RANGES.findIndex((r) => r.key === value);
2890
2972
  let nextIndex = null;
@@ -3004,8 +3086,8 @@ function SourceStatusRow({ sources }) {
3004
3086
  ] }) });
3005
3087
  }
3006
3088
  function PanelTabs({ value, onChange }) {
3007
- const refs = (0, import_react4.useRef)([]);
3008
- const onKeyDown = (0, import_react4.useCallback)(
3089
+ const refs = (0, import_react5.useRef)([]);
3090
+ const onKeyDown = (0, import_react5.useCallback)(
3009
3091
  (event) => {
3010
3092
  const currentIndex = PANELS.findIndex((p) => p.id === value);
3011
3093
  let nextIndex = null;
@@ -3209,7 +3291,7 @@ function isoDaysAgo(days) {
3209
3291
  return new Date(utcMidnight - days * 864e5).toISOString().slice(0, 10);
3210
3292
  }
3211
3293
  var COMPARED_TABS = ["overview", "acquisition"];
3212
- var PanelBoundary = class extends import_react4.default.Component {
3294
+ var PanelBoundary = class extends import_react5.default.Component {
3213
3295
  constructor(props) {
3214
3296
  super(props);
3215
3297
  this.state = { error: null, shownFor: props.resetKey };
@@ -3248,16 +3330,16 @@ var PanelBoundary = class extends import_react4.default.Component {
3248
3330
  function VisitorInsightsTool(props) {
3249
3331
  const apiBaseUrl = props.tool?.options?.apiBaseUrl ?? props.apiBaseUrl ?? "";
3250
3332
  const siteLabel = props.tool?.options?.siteLabel ?? props.siteLabel ?? "";
3251
- const [linked] = (0, import_react4.useState)(() => typeof window === "undefined" ? {} : decodeView(window.location.hash));
3333
+ const [linked] = (0, import_react5.useState)(() => typeof window === "undefined" ? {} : decodeView(window.location.hash));
3252
3334
  const linkedRange = RANGES.some((r) => r.key === linked.range) ? linked.range : null;
3253
3335
  const linkedTab = PANELS.some((p) => p.id === linked.tab) ? linked.tab : null;
3254
- const [range, setRange] = (0, import_react4.useState)(linkedRange ?? "month");
3255
- const [rangeBeforeBrush, setRangeBeforeBrush] = (0, import_react4.useState)(linkedRange ?? "month");
3256
- const [custom, setCustom] = (0, import_react4.useState)(() => linked.from && linked.to ? { start: linked.from, end: linked.to } : { start: isoDaysAgo(30), end: isoDaysAgo(0) });
3336
+ const [range, setRange] = (0, import_react5.useState)(linkedRange ?? "month");
3337
+ const [rangeBeforeBrush, setRangeBeforeBrush] = (0, import_react5.useState)(linkedRange ?? "month");
3338
+ const [custom, setCustom] = (0, import_react5.useState)(() => linked.from && linked.to ? { start: linked.from, end: linked.to } : { start: isoDaysAgo(30), end: isoDaysAgo(0) });
3257
3339
  const atPresent = custom.end >= isoDaysAgo(0);
3258
- const [compare, setCompare] = (0, import_react4.useState)("previous-period");
3259
- const [activePanel, setActivePanel] = (0, import_react4.useState)(linkedTab ?? "overview");
3260
- import_react4.default.useEffect(() => {
3340
+ const [compare, setCompare] = (0, import_react5.useState)("previous-period");
3341
+ const [activePanel, setActivePanel] = (0, import_react5.useState)(linkedTab ?? "overview");
3342
+ import_react5.default.useEffect(() => {
3261
3343
  if (typeof window === "undefined") return;
3262
3344
  const next = mergeIntoHash(window.location.hash, {
3263
3345
  tab: activePanel,