@liiift-studio/sanity-visitor-insights 0.51.0 → 0.53.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 +1 -1
- package/dist/index.d.mts +2 -14
- package/dist/index.d.ts +2 -14
- package/dist/index.js +158 -46
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +146 -34
- package/dist/index.mjs.map +1 -1
- package/dist/{ranges-CeB8esIA.d.mts → ranges-DBbZ0tg7.d.mts} +30 -1
- package/dist/{ranges-CeB8esIA.d.ts → ranges-DBbZ0tg7.d.ts} +30 -1
- package/dist/server.d.mts +4 -4
- package/dist/server.d.ts +4 -4
- package/dist/server.js +60 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +60 -6
- package/dist/server.mjs.map +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/{vercel-CGDmz9Bt.d.mts → vercel-pqyL1I1o.d.mts} +18 -1
- package/dist/{vercel-CGDmz9Bt.d.ts → vercel-pqyL1I1o.d.ts} +18 -1
- package/package.json +1 -1
- package/src/reportData.ts +30 -0
- package/src/server/ga4.ts +44 -6
- package/src/server/reports/journey.ts +59 -1
- package/src/server/reports/reports.test.ts +6 -5
- package/src/studio/Figure.tsx +65 -24
- package/src/studio/palette.test.ts +59 -1
- package/src/studio/palette.ts +83 -0
- package/src/studio/panels.test.tsx +118 -6
- package/src/studio/panels.tsx +153 -5
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,
|
|
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-
|
|
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,
|
|
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.
|
|
@@ -190,17 +190,6 @@ interface DeltaProps {
|
|
|
190
190
|
/** Render as a percentage-point change rather than a percentage change of a percentage. */
|
|
191
191
|
unit?: 'count' | 'percent';
|
|
192
192
|
}
|
|
193
|
-
/**
|
|
194
|
-
* A period-over-period change.
|
|
195
|
-
*
|
|
196
|
-
* Every figure in this tool used to be a bare level, which at these volumes is close to
|
|
197
|
-
* meaningless: "389 sessions" is neither good nor bad without last week beside it, and the
|
|
198
|
-
* 24 August collapse would have announced itself on every panel as a delta while going unnoticed
|
|
199
|
-
* for over a week as a level.
|
|
200
|
-
*
|
|
201
|
-
* Renders nothing at all when there is no comparison — an absent delta must never be drawn as
|
|
202
|
-
* "no change", which is a different and much more reassuring claim.
|
|
203
|
-
*/
|
|
204
193
|
declare function Delta({ current, previous, riseIsGood, unit }: DeltaProps): React.ReactElement | null;
|
|
205
194
|
interface MetricFigureProps {
|
|
206
195
|
metric: MetricValue;
|
|
@@ -606,7 +595,6 @@ declare function AcquisitionPanel({ data, previous }: {
|
|
|
606
595
|
data: AcquisitionData;
|
|
607
596
|
previous?: AcquisitionData;
|
|
608
597
|
}): React.ReactElement;
|
|
609
|
-
/** Journey — a funnel, drawn as a tracked sequence or as independent totals, whichever the report used. */
|
|
610
598
|
declare function JourneyPanel({ data }: {
|
|
611
599
|
data: JourneyData;
|
|
612
600
|
}): 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-
|
|
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,
|
|
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.
|
|
@@ -190,17 +190,6 @@ interface DeltaProps {
|
|
|
190
190
|
/** Render as a percentage-point change rather than a percentage change of a percentage. */
|
|
191
191
|
unit?: 'count' | 'percent';
|
|
192
192
|
}
|
|
193
|
-
/**
|
|
194
|
-
* A period-over-period change.
|
|
195
|
-
*
|
|
196
|
-
* Every figure in this tool used to be a bare level, which at these volumes is close to
|
|
197
|
-
* meaningless: "389 sessions" is neither good nor bad without last week beside it, and the
|
|
198
|
-
* 24 August collapse would have announced itself on every panel as a delta while going unnoticed
|
|
199
|
-
* for over a week as a level.
|
|
200
|
-
*
|
|
201
|
-
* Renders nothing at all when there is no comparison — an absent delta must never be drawn as
|
|
202
|
-
* "no change", which is a different and much more reassuring claim.
|
|
203
|
-
*/
|
|
204
193
|
declare function Delta({ current, previous, riseIsGood, unit }: DeltaProps): React.ReactElement | null;
|
|
205
194
|
interface MetricFigureProps {
|
|
206
195
|
metric: MetricValue;
|
|
@@ -606,7 +595,6 @@ declare function AcquisitionPanel({ data, previous }: {
|
|
|
606
595
|
data: AcquisitionData;
|
|
607
596
|
previous?: AcquisitionData;
|
|
608
597
|
}): React.ReactElement;
|
|
609
|
-
/** Journey — a funnel, drawn as a tracked sequence or as independent totals, whichever the report used. */
|
|
610
598
|
declare function JourneyPanel({ data }: {
|
|
611
599
|
data: JourneyData;
|
|
612
600
|
}): 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
|
|
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
|
|
@@ -288,6 +288,32 @@ function seriesFill(key, alpha = REGION_ALPHA) {
|
|
|
288
288
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
289
289
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
290
290
|
}
|
|
291
|
+
var MARKS = {
|
|
292
|
+
"chart.line": { key: "vercel", alpha: 1, role: "data", note: "A source series in the timeline" },
|
|
293
|
+
"chart.lossyLine": { key: "ga4Pageviews", alpha: 1, role: "data", note: "The lossier source, dashed" },
|
|
294
|
+
"chart.coverage": { key: "ga4Sessions", alpha: 1, role: "data", note: "Share one source saw of another" },
|
|
295
|
+
"chart.stem": { key: "orders", alpha: 1, role: "data", note: "One day that had orders" },
|
|
296
|
+
"chart.zeroTick": { key: "orders", alpha: 1, role: "data", note: "A day MEASURED at zero \u2014 the package's founding distinction" },
|
|
297
|
+
"chart.regionEdge": { key: "ga4Pageviews", alpha: 1, role: "data", note: "The upper boundary of the shortfall region" },
|
|
298
|
+
"chart.region": {
|
|
299
|
+
key: "ga4Pageviews",
|
|
300
|
+
alpha: 0.22,
|
|
301
|
+
role: "fill",
|
|
302
|
+
boundary: "chart.regionEdge",
|
|
303
|
+
note: "What the lossier source missed. Light on purpose \u2014 the line it covers must stay readable"
|
|
304
|
+
},
|
|
305
|
+
"chart.ghost": { key: "neutral", alpha: 0.55, role: "furniture", note: "The same window last period, behind everything" },
|
|
306
|
+
"chart.grid": { key: "neutral", alpha: 0.18, role: "furniture", note: "Grid rules" },
|
|
307
|
+
"bar.fill": { key: "vercel", alpha: 1, role: "data", note: "Every proportion, comparison and funnel bar" },
|
|
308
|
+
"bar.track": { key: "neutral", alpha: 0.12, role: "furniture", note: "The rail a bar sits in" },
|
|
309
|
+
"bar.lost": { key: "revenue", alpha: 1, role: "data", note: "People who did not continue past a funnel rung" }
|
|
310
|
+
};
|
|
311
|
+
function mark(name) {
|
|
312
|
+
const m = MARKS[name];
|
|
313
|
+
if (!m) throw new Error(`Unknown mark: ${name}`);
|
|
314
|
+
if (m.key === "neutral") return `rgba(127, 127, 127, ${m.alpha})`;
|
|
315
|
+
return seriesFill(m.key, m.alpha);
|
|
316
|
+
}
|
|
291
317
|
|
|
292
318
|
// src/studio/Figure.tsx
|
|
293
319
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
@@ -328,6 +354,7 @@ function formatMoney(value, currency, fractionDigits = 0) {
|
|
|
328
354
|
return `${formatCount(Math.round(value))} ${currency}`;
|
|
329
355
|
}
|
|
330
356
|
}
|
|
357
|
+
var MIN_DELTA_BASE = 25;
|
|
331
358
|
function Delta({ current, previous, riseIsGood = true, unit = "count" }) {
|
|
332
359
|
if (!Number.isFinite(current) || !Number.isFinite(previous)) return null;
|
|
333
360
|
const now = current;
|
|
@@ -342,7 +369,8 @@ function Delta({ current, previous, riseIsGood = true, unit = "count" }) {
|
|
|
342
369
|
const tone = rising === riseIsGood ? "good" : "bad";
|
|
343
370
|
const arrow = rising ? "\u2191" : "\u2193";
|
|
344
371
|
const baseline = unit === "percent" ? `${before.toFixed(1)}%` : formatCount(before);
|
|
345
|
-
const
|
|
372
|
+
const tooFewToRate = change !== null && unit !== "percent" && Math.abs(before) < MIN_DELTA_BASE;
|
|
373
|
+
const magnitude = change === null ? rising ? "new" : "gone" : tooFewToRate ? "" : unit === "percent" ? `${rising ? "+" : ""}${absolute.toFixed(1)} pts` : `${rising ? "+" : ""}${formatPercent(change, 0)}`;
|
|
346
374
|
return (
|
|
347
375
|
// The baseline is printed, not hidden in a `title`. At seven orders a quarter an 18% move
|
|
348
376
|
// might be one order, so "from 6" is the fact and the percentage is the decoration — and a
|
|
@@ -548,9 +576,20 @@ function FunnelChart({ stages, measurement }) {
|
|
|
548
576
|
const active = activeKey === stage.key;
|
|
549
577
|
const delta = previous ? previous.value - stage.value : 0;
|
|
550
578
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { style: funnelItem, children: [
|
|
551
|
-
previous && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: funnelGap,
|
|
552
|
-
measurement === "sequence" && delta > 0 && entry > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
553
|
-
|
|
579
|
+
previous && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: funnelGap, children: [
|
|
580
|
+
measurement === "sequence" && delta > 0 && entry > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-hidden": "true", style: { ...barTrack, height: 6, flex: 1 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
581
|
+
"div",
|
|
582
|
+
{
|
|
583
|
+
style: {
|
|
584
|
+
marginLeft: "auto",
|
|
585
|
+
height: "100%",
|
|
586
|
+
borderRadius: 2,
|
|
587
|
+
background: mark("bar.lost"),
|
|
588
|
+
width: `${Math.max(1, Math.min(1, delta / entry) * 100)}%`
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
) }),
|
|
592
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: gapLabel(delta, measurement, entry) })
|
|
554
593
|
] }),
|
|
555
594
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
556
595
|
"div",
|
|
@@ -582,9 +621,13 @@ function FunnelChart({ stages, measurement }) {
|
|
|
582
621
|
] }, stage.key);
|
|
583
622
|
}) });
|
|
584
623
|
}
|
|
585
|
-
function gapLabel(delta, measurement) {
|
|
624
|
+
function gapLabel(delta, measurement, entry = 0) {
|
|
586
625
|
if (delta === 0) return measurement === "sequence" ? "no drop-off" : "no difference";
|
|
587
|
-
if (delta > 0)
|
|
626
|
+
if (delta > 0) {
|
|
627
|
+
if (measurement !== "sequence") return `${formatCount(delta)} fewer`;
|
|
628
|
+
const share = entry > 0 ? ` \u2014 ${formatPercent(delta / entry, 0)} of everyone who landed` : "";
|
|
629
|
+
return `\u2212${formatCount(delta)} did not continue${share}`;
|
|
630
|
+
}
|
|
588
631
|
return `${formatCount(-delta)} more \u2014 not a subset of the step above`;
|
|
589
632
|
}
|
|
590
633
|
var funnelList = { listStyle: "none", margin: 0, padding: 0 };
|
|
@@ -596,19 +639,6 @@ var funnelGap = {
|
|
|
596
639
|
gap: 8,
|
|
597
640
|
padding: "4px 2px"
|
|
598
641
|
};
|
|
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
642
|
var lostFill = {
|
|
613
643
|
height: "100%",
|
|
614
644
|
borderRadius: 2,
|
|
@@ -629,18 +659,18 @@ function funnelStage(active) {
|
|
|
629
659
|
var barFill = {
|
|
630
660
|
height: "100%",
|
|
631
661
|
borderRadius: 2,
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
background:
|
|
636
|
-
opacity: 0.85
|
|
662
|
+
// Through the registry, not a hex plus an opacity chosen here. Drawn at 0.85 this sat at 2.77:1
|
|
663
|
+
// on the light theme while palette.test.ts asserted 3.42:1 against the bare constant — the test
|
|
664
|
+
// measured a colour that was never drawn. MARKS is what both now read.
|
|
665
|
+
background: mark("bar.fill")
|
|
637
666
|
};
|
|
638
667
|
var barTrack = {
|
|
639
668
|
height: 8,
|
|
640
669
|
borderRadius: 2,
|
|
641
670
|
overflow: "hidden",
|
|
642
|
-
|
|
643
|
-
|
|
671
|
+
// Furniture, not data: a rail is not a value, so 3:1 does not apply to it — but it is declared
|
|
672
|
+
// in MARKS so that exemption is stated somewhere a reader can check rather than assumed.
|
|
673
|
+
background: mark("bar.track")
|
|
644
674
|
};
|
|
645
675
|
var barHeader = {
|
|
646
676
|
display: "flex",
|
|
@@ -1067,6 +1097,7 @@ var visuallyHidden = {
|
|
|
1067
1097
|
var import_sanity_ui_compat5 = require("@liiift-studio/sanity-ui-compat");
|
|
1068
1098
|
|
|
1069
1099
|
// src/studio/panels.tsx
|
|
1100
|
+
var import_react4 = __toESM(require("react"));
|
|
1070
1101
|
var import_sanity_ui_compat3 = require("@liiift-studio/sanity-ui-compat");
|
|
1071
1102
|
|
|
1072
1103
|
// src/studio/CrossSourceTimeline.tsx
|
|
@@ -1803,6 +1834,24 @@ var figureRow = {
|
|
|
1803
1834
|
gap: 10,
|
|
1804
1835
|
flexWrap: "wrap"
|
|
1805
1836
|
};
|
|
1837
|
+
var segmentRow = { display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" };
|
|
1838
|
+
function segmentButton(selected) {
|
|
1839
|
+
return {
|
|
1840
|
+
appearance: "none",
|
|
1841
|
+
background: selected ? "var(--card-bg-color, rgba(128,128,128,0.12))" : "transparent",
|
|
1842
|
+
border: `1px solid ${selected ? "currentColor" : "var(--card-border-color, rgba(128,128,128,0.3))"}`,
|
|
1843
|
+
borderRadius: 3,
|
|
1844
|
+
color: "inherit",
|
|
1845
|
+
font: "inherit",
|
|
1846
|
+
fontSize: "0.85em",
|
|
1847
|
+
padding: "4px 10px",
|
|
1848
|
+
cursor: "pointer",
|
|
1849
|
+
display: "flex",
|
|
1850
|
+
alignItems: "baseline",
|
|
1851
|
+
gap: 7
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
var segmentRate = { opacity: 0.65, fontVariantNumeric: "tabular-nums" };
|
|
1806
1855
|
var sourceLink = {
|
|
1807
1856
|
color: "inherit",
|
|
1808
1857
|
textDecoration: "underline",
|
|
@@ -1878,9 +1927,9 @@ function OverviewPanel({ data, previous, onBrush }) {
|
|
|
1878
1927
|
const average = averageOrderValue(data);
|
|
1879
1928
|
const value = metricSortValue(average);
|
|
1880
1929
|
if (value === null) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: average, label: "Average order value" });
|
|
1881
|
-
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
1882
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 4, children: formatMoney(value, data.currency ?? null) }),
|
|
1883
|
-
average.status === "partial" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children:
|
|
1930
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 2, children: [
|
|
1931
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: figureRow, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 4, children: formatMoney(value, data.currency ?? null) }) }),
|
|
1932
|
+
average.status === "partial" && average.note && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: average.note })
|
|
1884
1933
|
] });
|
|
1885
1934
|
})(),
|
|
1886
1935
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "From your own orders, so it is exact \u2014 but only across the orders that carry an amount, which is what the revenue figure covers." })
|
|
@@ -2455,8 +2504,33 @@ function AcquisitionPanel({ data, previous }) {
|
|
|
2455
2504
|
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
2505
|
] });
|
|
2457
2506
|
}
|
|
2507
|
+
function firstStepRate(steps) {
|
|
2508
|
+
const second = steps[1];
|
|
2509
|
+
if (!second) return null;
|
|
2510
|
+
const rate = second.conversionFromPrevious;
|
|
2511
|
+
return typeof rate === "number" && Number.isFinite(rate) ? rate : null;
|
|
2512
|
+
}
|
|
2513
|
+
function spread(segments) {
|
|
2514
|
+
const MIN_USERS = 200;
|
|
2515
|
+
const RATIO = 3;
|
|
2516
|
+
const rated = segments.map((segment) => ({
|
|
2517
|
+
label: segment.label,
|
|
2518
|
+
rate: firstStepRate(segment.steps),
|
|
2519
|
+
users: metricSortValue(segment.steps[0]?.count) ?? 0
|
|
2520
|
+
})).filter((row) => row.rate !== null && row.rate > 0 && row.users >= MIN_USERS);
|
|
2521
|
+
if (rated.length < 2) return null;
|
|
2522
|
+
const sorted = [...rated].sort((a, b) => b.rate - a.rate);
|
|
2523
|
+
const best = sorted[0];
|
|
2524
|
+
const worst = sorted[sorted.length - 1];
|
|
2525
|
+
const ratio = best.rate / worst.rate;
|
|
2526
|
+
if (ratio < RATIO) return null;
|
|
2527
|
+
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.`;
|
|
2528
|
+
}
|
|
2458
2529
|
function JourneyPanel({ data }) {
|
|
2459
|
-
const
|
|
2530
|
+
const [segmentKey, setSegmentKey] = import_react4.default.useState(null);
|
|
2531
|
+
const segments = data.segments ?? [];
|
|
2532
|
+
const segment = segmentKey === null ? null : segments.find((row) => row.key === segmentKey) ?? null;
|
|
2533
|
+
const allSteps = segment ? segment.steps : data.steps ?? [];
|
|
2460
2534
|
const shown = allSteps.filter((step) => step.count.status !== "unavailable");
|
|
2461
2535
|
const hiddenSteps = allSteps.filter((step) => step.count.status === "unavailable");
|
|
2462
2536
|
const stages = shown.flatMap(
|
|
@@ -2468,6 +2542,44 @@ function JourneyPanel({ data }) {
|
|
|
2468
2542
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, weight: "medium", children: tracked ? "Tracked funnel" : "Independent per-step totals" }),
|
|
2469
2543
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: tracked, children: data.approximationNote })
|
|
2470
2544
|
] }) }),
|
|
2545
|
+
segments.length > 1 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
|
|
2546
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: segmentRow, role: "radiogroup", "aria-label": `Funnel by ${data.segmentDimension ?? "segment"}`, children: [
|
|
2547
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2548
|
+
"button",
|
|
2549
|
+
{
|
|
2550
|
+
type: "button",
|
|
2551
|
+
role: "radio",
|
|
2552
|
+
"aria-checked": segmentKey === null,
|
|
2553
|
+
style: segmentButton(segmentKey === null),
|
|
2554
|
+
onClick: () => setSegmentKey(null),
|
|
2555
|
+
children: "Everyone"
|
|
2556
|
+
}
|
|
2557
|
+
),
|
|
2558
|
+
segments.map((row) => {
|
|
2559
|
+
const rate = firstStepRate(row.steps);
|
|
2560
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
2561
|
+
"button",
|
|
2562
|
+
{
|
|
2563
|
+
type: "button",
|
|
2564
|
+
role: "radio",
|
|
2565
|
+
"aria-checked": segmentKey === row.key,
|
|
2566
|
+
style: segmentButton(segmentKey === row.key),
|
|
2567
|
+
onClick: () => setSegmentKey(row.key),
|
|
2568
|
+
children: [
|
|
2569
|
+
row.label,
|
|
2570
|
+
rate !== null && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: segmentRate, children: formatPercent(rate, 2) })
|
|
2571
|
+
]
|
|
2572
|
+
},
|
|
2573
|
+
row.key
|
|
2574
|
+
);
|
|
2575
|
+
})
|
|
2576
|
+
] }),
|
|
2577
|
+
spread(segments) && /* Said in words, once, where the funnel is. The whole reason to split a funnel
|
|
2578
|
+
is that the halves differ; when they differ by a multiple rather than a
|
|
2579
|
+
margin, the undivided funnel above describes nobody and the reader should
|
|
2580
|
+
be told rather than left to press the buttons and notice. */
|
|
2581
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: spread(segments) })
|
|
2582
|
+
] }),
|
|
2471
2583
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(FunnelChart, { stages, measurement: data.measurement ?? "independent-totals" }),
|
|
2472
2584
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: splitGrid, children: [
|
|
2473
2585
|
(data.outcomes?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
@@ -2878,13 +2990,13 @@ function RangeSelector({
|
|
|
2878
2990
|
onChange,
|
|
2879
2991
|
onCustomChange
|
|
2880
2992
|
}) {
|
|
2881
|
-
const refs = (0,
|
|
2882
|
-
const [pickerOpen, setPickerOpen] = (0,
|
|
2883
|
-
const [draft, setDraft] = (0,
|
|
2884
|
-
|
|
2993
|
+
const refs = (0, import_react5.useRef)([]);
|
|
2994
|
+
const [pickerOpen, setPickerOpen] = (0, import_react5.useState)(value === "custom");
|
|
2995
|
+
const [draft, setDraft] = (0, import_react5.useState)(custom);
|
|
2996
|
+
import_react5.default.useEffect(() => {
|
|
2885
2997
|
setDraft(custom);
|
|
2886
2998
|
}, [custom.start, custom.end]);
|
|
2887
|
-
const onKeyDown = (0,
|
|
2999
|
+
const onKeyDown = (0, import_react5.useCallback)(
|
|
2888
3000
|
(event) => {
|
|
2889
3001
|
const currentIndex = RANGES.findIndex((r) => r.key === value);
|
|
2890
3002
|
let nextIndex = null;
|
|
@@ -3004,8 +3116,8 @@ function SourceStatusRow({ sources }) {
|
|
|
3004
3116
|
] }) });
|
|
3005
3117
|
}
|
|
3006
3118
|
function PanelTabs({ value, onChange }) {
|
|
3007
|
-
const refs = (0,
|
|
3008
|
-
const onKeyDown = (0,
|
|
3119
|
+
const refs = (0, import_react5.useRef)([]);
|
|
3120
|
+
const onKeyDown = (0, import_react5.useCallback)(
|
|
3009
3121
|
(event) => {
|
|
3010
3122
|
const currentIndex = PANELS.findIndex((p) => p.id === value);
|
|
3011
3123
|
let nextIndex = null;
|
|
@@ -3209,7 +3321,7 @@ function isoDaysAgo(days) {
|
|
|
3209
3321
|
return new Date(utcMidnight - days * 864e5).toISOString().slice(0, 10);
|
|
3210
3322
|
}
|
|
3211
3323
|
var COMPARED_TABS = ["overview", "acquisition"];
|
|
3212
|
-
var PanelBoundary = class extends
|
|
3324
|
+
var PanelBoundary = class extends import_react5.default.Component {
|
|
3213
3325
|
constructor(props) {
|
|
3214
3326
|
super(props);
|
|
3215
3327
|
this.state = { error: null, shownFor: props.resetKey };
|
|
@@ -3248,16 +3360,16 @@ var PanelBoundary = class extends import_react4.default.Component {
|
|
|
3248
3360
|
function VisitorInsightsTool(props) {
|
|
3249
3361
|
const apiBaseUrl = props.tool?.options?.apiBaseUrl ?? props.apiBaseUrl ?? "";
|
|
3250
3362
|
const siteLabel = props.tool?.options?.siteLabel ?? props.siteLabel ?? "";
|
|
3251
|
-
const [linked] = (0,
|
|
3363
|
+
const [linked] = (0, import_react5.useState)(() => typeof window === "undefined" ? {} : decodeView(window.location.hash));
|
|
3252
3364
|
const linkedRange = RANGES.some((r) => r.key === linked.range) ? linked.range : null;
|
|
3253
3365
|
const linkedTab = PANELS.some((p) => p.id === linked.tab) ? linked.tab : null;
|
|
3254
|
-
const [range, setRange] = (0,
|
|
3255
|
-
const [rangeBeforeBrush, setRangeBeforeBrush] = (0,
|
|
3256
|
-
const [custom, setCustom] = (0,
|
|
3366
|
+
const [range, setRange] = (0, import_react5.useState)(linkedRange ?? "month");
|
|
3367
|
+
const [rangeBeforeBrush, setRangeBeforeBrush] = (0, import_react5.useState)(linkedRange ?? "month");
|
|
3368
|
+
const [custom, setCustom] = (0, import_react5.useState)(() => linked.from && linked.to ? { start: linked.from, end: linked.to } : { start: isoDaysAgo(30), end: isoDaysAgo(0) });
|
|
3257
3369
|
const atPresent = custom.end >= isoDaysAgo(0);
|
|
3258
|
-
const [compare, setCompare] = (0,
|
|
3259
|
-
const [activePanel, setActivePanel] = (0,
|
|
3260
|
-
|
|
3370
|
+
const [compare, setCompare] = (0, import_react5.useState)("previous-period");
|
|
3371
|
+
const [activePanel, setActivePanel] = (0, import_react5.useState)(linkedTab ?? "overview");
|
|
3372
|
+
import_react5.default.useEffect(() => {
|
|
3261
3373
|
if (typeof window === "undefined") return;
|
|
3262
3374
|
const next = mergeIntoHash(window.location.hash, {
|
|
3263
3375
|
tab: activePanel,
|