@liiift-studio/sanity-visitor-insights 0.45.0 → 0.47.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 +17 -1
- package/dist/index.d.mts +5 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +162 -35
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +162 -35
- package/dist/index.mjs.map +1 -1
- package/dist/{ranges-CWGvBi3C.d.mts → ranges-CeB8esIA.d.mts} +48 -2
- package/dist/{ranges-CWGvBi3C.d.ts → ranges-CeB8esIA.d.ts} +48 -2
- package/dist/server.d.mts +2 -2
- package/dist/server.d.ts +2 -2
- package/dist/server.js +71 -7
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +70 -7
- package/dist/server.mjs.map +1 -1
- package/package.json +1 -1
- package/src/core/core.test.ts +20 -0
- package/src/core/ranges.ts +37 -1
- package/src/reportData.ts +27 -0
- package/src/server/cache.test.ts +7 -0
- package/src/server/createHandler.ts +14 -3
- package/src/server/handler.test.ts +116 -0
- package/src/server/reports/acquisition.ts +18 -2
- package/src/server/reports/measurementHealth.ts +99 -3
- package/src/server/reports/reports.test.ts +132 -1
- package/src/studio/VisitorInsightsTool.tsx +64 -3
- package/src/studio/panels.test.tsx +179 -20
- package/src/studio/panels.tsx +168 -1
- package/src/studio/useReport.ts +11 -4
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -19,7 +19,23 @@ only as a conversion anchor and a count; no revenue, and no customer data.
|
|
|
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
|
|
|
22
|
-
Each panel offers a week, quarter or year range
|
|
22
|
+
Each panel offers a week, quarter or year range, a custom window, and a choice of what the changes
|
|
23
|
+
are measured against — the preceding window, or the same window a year earlier. Type sales are
|
|
24
|
+
seasonal, so a quiet August against a quiet July often says less than August against last August.
|
|
25
|
+
Both cost the same: one comparison window either way.
|
|
26
|
+
|
|
27
|
+
Four figures are derived from the ones above rather than measured separately:
|
|
28
|
+
|
|
29
|
+
| Figure | How it is arrived at |
|
|
30
|
+
|---|---|
|
|
31
|
+
| **Average order value** | Revenue over the orders that *carry an amount*, not over every counted order. Marked partial, naming both counts, whenever those differ. |
|
|
32
|
+
| **Visitors per order** | Vercel visitors over orders. Vercel because it is server-side and complete; GA4 sessions would flatter the ratio by roughly the shortfall. |
|
|
33
|
+
| **Orders after a send** | What the order book records in the three days following each email, ending early if another send lands first, so no order is counted twice. |
|
|
34
|
+
| **Revenue per 1,000 sent** | The same window's revenue scaled to a thousand addresses — per send it is fractions of a cent and every campaign renders as the same zero. |
|
|
35
|
+
|
|
36
|
+
The last two are **what happened next, not what the send caused**. They are built from send times and
|
|
37
|
+
order times, both of which are exact, rather than from GA4's campaign attribution, which at a
|
|
38
|
+
foundry's volumes is withheld for being too thin to divide revenue by.
|
|
23
39
|
|
|
24
40
|
---
|
|
25
41
|
|
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, 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-
|
|
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';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Data hook for the Studio panels.
|
|
@@ -51,6 +51,8 @@ interface UseReportOptions {
|
|
|
51
51
|
apiBaseUrl: string;
|
|
52
52
|
report: ReportName;
|
|
53
53
|
range: RangeKey;
|
|
54
|
+
/** Which baseline the deltas are measured against. Defaults to the preceding window. */
|
|
55
|
+
compare?: 'previous-period' | 'same-period-last-year';
|
|
54
56
|
/**
|
|
55
57
|
* Whether to fetch at all. Defaults to true.
|
|
56
58
|
*
|
|
@@ -64,7 +66,7 @@ interface UseReportOptions {
|
|
|
64
66
|
*
|
|
65
67
|
* @returns the current state plus a `reload` for manual refresh
|
|
66
68
|
*/
|
|
67
|
-
declare function useReport<T>({ apiBaseUrl, report, range, custom, enabled }: UseReportOptions): {
|
|
69
|
+
declare function useReport<T>({ apiBaseUrl, report, range, custom, compare, enabled }: UseReportOptions): {
|
|
68
70
|
state: ReportState<T>;
|
|
69
71
|
reload: () => void;
|
|
70
72
|
};
|
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, 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-
|
|
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';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Data hook for the Studio panels.
|
|
@@ -51,6 +51,8 @@ interface UseReportOptions {
|
|
|
51
51
|
apiBaseUrl: string;
|
|
52
52
|
report: ReportName;
|
|
53
53
|
range: RangeKey;
|
|
54
|
+
/** Which baseline the deltas are measured against. Defaults to the preceding window. */
|
|
55
|
+
compare?: 'previous-period' | 'same-period-last-year';
|
|
54
56
|
/**
|
|
55
57
|
* Whether to fetch at all. Defaults to true.
|
|
56
58
|
*
|
|
@@ -64,7 +66,7 @@ interface UseReportOptions {
|
|
|
64
66
|
*
|
|
65
67
|
* @returns the current state plus a `reload` for manual refresh
|
|
66
68
|
*/
|
|
67
|
-
declare function useReport<T>({ apiBaseUrl, report, range, custom, enabled }: UseReportOptions): {
|
|
69
|
+
declare function useReport<T>({ apiBaseUrl, report, range, custom, compare, enabled }: UseReportOptions): {
|
|
68
70
|
state: ReportState<T>;
|
|
69
71
|
reload: () => void;
|
|
70
72
|
};
|
package/dist/index.js
CHANGED
|
@@ -95,14 +95,14 @@ function rememberShortfall(base, range, custom, data) {
|
|
|
95
95
|
shortfallByWindow.set(`${base}|${windowKey(range, custom)}`, ratio);
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
-
function cacheKey(base, report, range, custom) {
|
|
99
|
-
return [base, report, range, custom?.start ?? "", custom?.end ?? ""].join("|");
|
|
98
|
+
function cacheKey(base, report, range, custom, compare) {
|
|
99
|
+
return [base, report, range, custom?.start ?? "", custom?.end ?? "", compare ?? "previous-period"].join("|");
|
|
100
100
|
}
|
|
101
101
|
function holdsPreviousAnswer(current, nextReport) {
|
|
102
102
|
return current.status === "ready" && current.envelope.report === nextReport;
|
|
103
103
|
}
|
|
104
104
|
var API_VERSION = "2024-03-01";
|
|
105
|
-
function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
|
|
105
|
+
function useReport({ apiBaseUrl, report, range, custom, compare, enabled = true }) {
|
|
106
106
|
const client = (0, import_sanity.useClient)({ apiVersion: API_VERSION });
|
|
107
107
|
const [state, setState] = (0, import_react.useState)({ status: "idle" });
|
|
108
108
|
const [nonce, setNonce] = (0, import_react.useState)(0);
|
|
@@ -113,7 +113,7 @@ function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
|
|
|
113
113
|
const requestId = requestIdRef.current + 1;
|
|
114
114
|
requestIdRef.current = requestId;
|
|
115
115
|
const controller = new AbortController();
|
|
116
|
-
const key = cacheKey(apiBaseUrl, report, range, custom);
|
|
116
|
+
const key = cacheKey(apiBaseUrl, report, range, custom, compare);
|
|
117
117
|
const cached = envelopeCache.get(key);
|
|
118
118
|
if (cached) setState({ status: "ready", envelope: cached, stale: true });
|
|
119
119
|
else {
|
|
@@ -131,6 +131,7 @@ function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
|
|
|
131
131
|
try {
|
|
132
132
|
const base = typeof apiBaseUrl === "string" ? apiBaseUrl.replace(/\/$/, "") : "";
|
|
133
133
|
const query = new URLSearchParams({ range });
|
|
134
|
+
if (compare && compare !== "previous-period") query.set("compare", compare);
|
|
134
135
|
if (range === "custom" && custom) {
|
|
135
136
|
query.set("start", custom.start);
|
|
136
137
|
query.set("end", custom.end);
|
|
@@ -243,7 +244,15 @@ function resolveRange(key, timezone, now = /* @__PURE__ */ new Date()) {
|
|
|
243
244
|
const start = shiftDays(end, -(RANGE_DAYS[key] - 1));
|
|
244
245
|
return { key, start, end, timezone };
|
|
245
246
|
}
|
|
246
|
-
function previousRange(range) {
|
|
247
|
+
function previousRange(range, basis = "previous-period") {
|
|
248
|
+
if (basis === "same-period-last-year") {
|
|
249
|
+
return {
|
|
250
|
+
key: range.key,
|
|
251
|
+
start: shiftDays(range.start, -364),
|
|
252
|
+
end: shiftDays(range.end, -364),
|
|
253
|
+
timezone: range.timezone
|
|
254
|
+
};
|
|
255
|
+
}
|
|
247
256
|
const span = daysBetween(range.start, range.end);
|
|
248
257
|
return {
|
|
249
258
|
key: range.key,
|
|
@@ -252,6 +261,7 @@ function previousRange(range) {
|
|
|
252
261
|
timezone: range.timezone
|
|
253
262
|
};
|
|
254
263
|
}
|
|
264
|
+
var SEND_WINDOW_DAYS = 3;
|
|
255
265
|
|
|
256
266
|
// src/studio/Figure.tsx
|
|
257
267
|
var import_react2 = __toESM(require("react"));
|
|
@@ -1494,6 +1504,33 @@ var legendRow = {
|
|
|
1494
1504
|
alignItems: "center"
|
|
1495
1505
|
};
|
|
1496
1506
|
|
|
1507
|
+
// src/types.ts
|
|
1508
|
+
function estimated(value, low, high, basis) {
|
|
1509
|
+
return { status: "estimated", value, low, high, basis };
|
|
1510
|
+
}
|
|
1511
|
+
function ok(value) {
|
|
1512
|
+
return { status: "ok", value };
|
|
1513
|
+
}
|
|
1514
|
+
function unavailable(reason, detail) {
|
|
1515
|
+
return { status: "unavailable", reason, detail };
|
|
1516
|
+
}
|
|
1517
|
+
function partial(value, coveredFrom, note) {
|
|
1518
|
+
return { status: "partial", value, coveredFrom, note };
|
|
1519
|
+
}
|
|
1520
|
+
function valueOrNull(metric) {
|
|
1521
|
+
return metric.status === "unavailable" ? null : metric.value;
|
|
1522
|
+
}
|
|
1523
|
+
var REPORT_NAMES = [
|
|
1524
|
+
"measurement-health",
|
|
1525
|
+
"acquisition",
|
|
1526
|
+
"journey",
|
|
1527
|
+
"typeface-interest",
|
|
1528
|
+
"diagnostics"
|
|
1529
|
+
];
|
|
1530
|
+
function isReportName(value) {
|
|
1531
|
+
return typeof value === "string" && REPORT_NAMES.includes(value);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1497
1534
|
// src/studio/panels.tsx
|
|
1498
1535
|
var import_jsx_runtime3 = require("react/jsx-runtime");
|
|
1499
1536
|
function metricSortValue(metric) {
|
|
@@ -1589,6 +1626,26 @@ function OverviewPanel({ data, previous, onBrush }) {
|
|
|
1589
1626
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Everyone subscribed today, not just this period. Mailchimp\u2019s own count, so it is not consent-gated or blockable." })
|
|
1590
1627
|
] }) })
|
|
1591
1628
|
] }),
|
|
1629
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: cardGrid, children: [
|
|
1630
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
|
|
1631
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Average order" }),
|
|
1632
|
+
(() => {
|
|
1633
|
+
const average = averageOrderValue(data);
|
|
1634
|
+
const value = metricSortValue(average);
|
|
1635
|
+
if (value === null) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: average, label: "Average order value" });
|
|
1636
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: figureRow, children: [
|
|
1637
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 4, children: formatMoney(value, data.currency ?? null) }),
|
|
1638
|
+
average.status === "partial" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "partial" })
|
|
1639
|
+
] });
|
|
1640
|
+
})(),
|
|
1641
|
+
/* @__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." })
|
|
1642
|
+
] }) }),
|
|
1643
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
|
|
1644
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Visitors per order" }),
|
|
1645
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: visitorsPerOrder(data), label: "Visitors per order" }),
|
|
1646
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Against Vercel\u2019s visitor count, not Google Analytics\u2019 \u2014 dividing by GA4 would flatter this by whatever share it is missing. Read it as a ceiling: some of those visitors are crawlers or repeat devices." })
|
|
1647
|
+
] }) })
|
|
1648
|
+
] }),
|
|
1592
1649
|
(data.crossSource?.length ?? 0) >= 3 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
|
|
1593
1650
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Everything, on one time axis" }),
|
|
1594
1651
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Each row has its own scale. Hover a day, or use the control below, to see what each source saw of it." }),
|
|
@@ -1776,6 +1833,11 @@ function OverviewPanel({ data, previous, onBrush }) {
|
|
|
1776
1833
|
(data.campaigns?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
|
|
1777
1834
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Email campaigns" }),
|
|
1778
1835
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Clicks are distinct subscribers. Opens are inflated by Apple Mail Privacy Protection, which fetches images on the recipient\u2019s behalf \u2014 so sort on clicks, not opens." }),
|
|
1836
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: [
|
|
1837
|
+
"The last two columns count what your order book records in the ",
|
|
1838
|
+
SEND_WINDOW_DAYS,
|
|
1839
|
+
" days after each send, ending early if another send lands first. They are what happened next, not what the send caused \u2014 but unlike anything GA4 can tell you about email, both the send times and the order times are exact."
|
|
1840
|
+
] }),
|
|
1779
1841
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
1780
1842
|
SortableTable,
|
|
1781
1843
|
{
|
|
@@ -1805,6 +1867,31 @@ function OverviewPanel({ data, previous, onBrush }) {
|
|
|
1805
1867
|
numeric: true,
|
|
1806
1868
|
sortValue: (c) => c.unsubscribed,
|
|
1807
1869
|
render: (c) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: formatCount(c.unsubscribed) })
|
|
1870
|
+
},
|
|
1871
|
+
{
|
|
1872
|
+
key: "ordersAfter",
|
|
1873
|
+
label: "Orders after",
|
|
1874
|
+
numeric: true,
|
|
1875
|
+
sortValue: (c) => c.ordersAfter ?? null,
|
|
1876
|
+
exportValue: (c) => c.ordersAfter ?? null,
|
|
1877
|
+
render: (c) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 1, children: [
|
|
1878
|
+
c.ordersAfter === null || c.ordersAfter === void 0 ? "\u2014" : formatCount(c.ordersAfter),
|
|
1879
|
+
c.windowComplete === false && c.ordersAfter !== null && c.ordersAfter !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { as: "span", size: 0, muted: true, children: " so far" })
|
|
1880
|
+
] })
|
|
1881
|
+
},
|
|
1882
|
+
{
|
|
1883
|
+
key: "revenuePerThousand",
|
|
1884
|
+
label: "Per 1,000 sent",
|
|
1885
|
+
numeric: true,
|
|
1886
|
+
// Per thousand, not per send: a foundry's sends run to thousands of addresses
|
|
1887
|
+
// against a handful of orders, so per-send lands at fractions of a cent and
|
|
1888
|
+
// every campaign renders as the same rounded zero.
|
|
1889
|
+
sortValue: (c) => revenuePerThousandSent(c),
|
|
1890
|
+
exportValue: (c) => revenuePerThousandSent(c),
|
|
1891
|
+
render: (c) => {
|
|
1892
|
+
const value = revenuePerThousandSent(c);
|
|
1893
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: value === null ? "\u2014" : formatMoney(value, data.currency ?? null) });
|
|
1894
|
+
}
|
|
1808
1895
|
}
|
|
1809
1896
|
]
|
|
1810
1897
|
}
|
|
@@ -2205,6 +2292,36 @@ function buyRateIndex(row, benchmark) {
|
|
|
2205
2292
|
if (views === null || views < MIN_FAMILY_VIEWS) return null;
|
|
2206
2293
|
return rate / benchmark;
|
|
2207
2294
|
}
|
|
2295
|
+
function revenuePerThousandSent(campaign) {
|
|
2296
|
+
if (campaign.revenueAfter === null || campaign.revenueAfter === void 0) return null;
|
|
2297
|
+
if (!Number.isFinite(campaign.sent) || campaign.sent <= 0) return null;
|
|
2298
|
+
return campaign.revenueAfter / campaign.sent * 1e3;
|
|
2299
|
+
}
|
|
2300
|
+
function averageOrderValue(data) {
|
|
2301
|
+
const revenueMetric = metricOr(data.revenue, OLDER_ROUTE);
|
|
2302
|
+
if (revenueMetric.status === "unavailable") return revenueMetric;
|
|
2303
|
+
const revenue = metricSortValue(revenueMetric);
|
|
2304
|
+
const orders = finiteOrNull(data.ordersWithTotal);
|
|
2305
|
+
if (revenue === null || orders === null || orders <= 0) {
|
|
2306
|
+
return unavailable("not_applicable", "No order in this range carried an amount to average");
|
|
2307
|
+
}
|
|
2308
|
+
const average = revenue / orders;
|
|
2309
|
+
const counted = finiteOrNull(metricSortValue(metricOr(data.orders, OLDER_ROUTE)));
|
|
2310
|
+
const overSubset = counted !== null && counted > orders;
|
|
2311
|
+
return overSubset || data.revenue.status === "partial" ? partial(average, "", `Averaged over the ${formatCount(orders)} of ${formatCount(counted ?? orders)} orders that carry an amount.`) : ok(average);
|
|
2312
|
+
}
|
|
2313
|
+
function visitorsPerOrder(data) {
|
|
2314
|
+
const visitorMetric = metricOr(data.vercelVisitors, OLDER_ROUTE);
|
|
2315
|
+
const orderMetric = metricOr(data.orders, OLDER_ROUTE);
|
|
2316
|
+
if (visitorMetric.status === "unavailable") return visitorMetric;
|
|
2317
|
+
if (orderMetric.status === "unavailable") return orderMetric;
|
|
2318
|
+
const visitors = metricSortValue(visitorMetric);
|
|
2319
|
+
const orders = metricSortValue(orderMetric);
|
|
2320
|
+
if (visitors === null || orders === null || orders <= 0) {
|
|
2321
|
+
return unavailable("not_applicable", "No order in this range to divide the visitors by");
|
|
2322
|
+
}
|
|
2323
|
+
return ok(Math.round(visitors / orders));
|
|
2324
|
+
}
|
|
2208
2325
|
function TypefaceInterestPanel({ data }) {
|
|
2209
2326
|
const benchmark = catalogueRate(data.rows ?? []);
|
|
2210
2327
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
|
|
@@ -2705,7 +2822,8 @@ function ReadyReport({
|
|
|
2705
2822
|
envelope.comparison.range.start,
|
|
2706
2823
|
" to ",
|
|
2707
2824
|
envelope.comparison.range.end,
|
|
2708
|
-
",
|
|
2825
|
+
",",
|
|
2826
|
+
envelope.comparison.basis === "same-period-last-year" ? " the same window a year earlier." : " the equivalent window immediately before this one.",
|
|
2709
2827
|
envelope.comparison.provisional && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_jsx_runtime4.Fragment, { children: " This window\u2019s last days are still being processed by GA4, so changes read low." })
|
|
2710
2828
|
] }),
|
|
2711
2829
|
GA4_ONLY_TABS.includes(tabId) && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(CoverageRibbon, { ratio: knownShortfall(apiBaseUrl, range, range === "custom" ? custom : void 0) }),
|
|
@@ -2738,15 +2856,46 @@ function ReadyReport({
|
|
|
2738
2856
|
] })
|
|
2739
2857
|
] });
|
|
2740
2858
|
}
|
|
2859
|
+
function BasisSelector({
|
|
2860
|
+
value,
|
|
2861
|
+
onChange
|
|
2862
|
+
}) {
|
|
2863
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: controlRow, children: [
|
|
2864
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: "Compared with" }),
|
|
2865
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
2866
|
+
"button",
|
|
2867
|
+
{
|
|
2868
|
+
type: "button",
|
|
2869
|
+
style: rangeButton(value === "previous-period"),
|
|
2870
|
+
"aria-pressed": value === "previous-period",
|
|
2871
|
+
title: "The equivalent window immediately before this one",
|
|
2872
|
+
onClick: () => onChange("previous-period"),
|
|
2873
|
+
children: "Previous period"
|
|
2874
|
+
}
|
|
2875
|
+
),
|
|
2876
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
2877
|
+
"button",
|
|
2878
|
+
{
|
|
2879
|
+
type: "button",
|
|
2880
|
+
style: rangeButton(value === "same-period-last-year"),
|
|
2881
|
+
"aria-pressed": value === "same-period-last-year",
|
|
2882
|
+
title: "The same window a year earlier, shifted by 364 days so the weekdays line up",
|
|
2883
|
+
onClick: () => onChange("same-period-last-year"),
|
|
2884
|
+
children: "Last year"
|
|
2885
|
+
}
|
|
2886
|
+
)
|
|
2887
|
+
] });
|
|
2888
|
+
}
|
|
2741
2889
|
function ReportPanel({
|
|
2742
2890
|
tabId,
|
|
2743
2891
|
report,
|
|
2744
2892
|
apiBaseUrl,
|
|
2745
2893
|
range,
|
|
2746
2894
|
custom,
|
|
2895
|
+
compare,
|
|
2747
2896
|
onBrush
|
|
2748
2897
|
}) {
|
|
2749
|
-
const { state, reload } = useReport({ apiBaseUrl, report, range, custom });
|
|
2898
|
+
const { state, reload } = useReport({ apiBaseUrl, report, range, custom, compare });
|
|
2750
2899
|
const diagnostics = useReport({
|
|
2751
2900
|
apiBaseUrl,
|
|
2752
2901
|
report: "diagnostics",
|
|
@@ -2840,6 +2989,7 @@ function VisitorInsightsTool(props) {
|
|
|
2840
2989
|
const [rangeBeforeBrush, setRangeBeforeBrush] = (0, import_react4.useState)(linkedRange ?? "month");
|
|
2841
2990
|
const [custom, setCustom] = (0, import_react4.useState)(() => linked.from && linked.to ? { start: linked.from, end: linked.to } : { start: isoDaysAgo(30), end: isoDaysAgo(0) });
|
|
2842
2991
|
const atPresent = custom.end >= isoDaysAgo(0);
|
|
2992
|
+
const [compare, setCompare] = (0, import_react4.useState)("previous-period");
|
|
2843
2993
|
const [activePanel, setActivePanel] = (0, import_react4.useState)(linkedTab ?? "overview");
|
|
2844
2994
|
import_react4.default.useEffect(() => {
|
|
2845
2995
|
if (typeof window === "undefined") return;
|
|
@@ -2861,7 +3011,10 @@ function VisitorInsightsTool(props) {
|
|
|
2861
3011
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Container, { width: 4, padding: 4, as: "section", "aria-label": siteLabel ? `Visitor insights for ${siteLabel}` : "Visitor insights", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 5, children: [
|
|
2862
3012
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Flex, { align: "flex-start", justify: "space-between", gap: 4, wrap: "wrap", children: [
|
|
2863
3013
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Heading, { size: 2, children: "Visitor insights" }),
|
|
2864
|
-
/* @__PURE__ */ (0, import_jsx_runtime4.
|
|
3014
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 2, children: [
|
|
3015
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(RangeSelector, { value: range, custom, onChange: setRange, onCustomChange: setCustom }),
|
|
3016
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(BasisSelector, { value: compare, onChange: setCompare })
|
|
3017
|
+
] })
|
|
2865
3018
|
] }),
|
|
2866
3019
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PanelTabs, { value: activePanel, onChange: setActivePanel }),
|
|
2867
3020
|
range === "custom" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: contextRow, children: [
|
|
@@ -2930,6 +3083,7 @@ function VisitorInsightsTool(props) {
|
|
|
2930
3083
|
apiBaseUrl,
|
|
2931
3084
|
range,
|
|
2932
3085
|
custom,
|
|
3086
|
+
compare,
|
|
2933
3087
|
onBrush: (start, end) => {
|
|
2934
3088
|
if (range !== "custom") setRangeBeforeBrush(range);
|
|
2935
3089
|
setCustom({ start, end });
|
|
@@ -2944,33 +3098,6 @@ function VisitorInsightsTool(props) {
|
|
|
2944
3098
|
);
|
|
2945
3099
|
}
|
|
2946
3100
|
|
|
2947
|
-
// src/types.ts
|
|
2948
|
-
function estimated(value, low, high, basis) {
|
|
2949
|
-
return { status: "estimated", value, low, high, basis };
|
|
2950
|
-
}
|
|
2951
|
-
function ok(value) {
|
|
2952
|
-
return { status: "ok", value };
|
|
2953
|
-
}
|
|
2954
|
-
function unavailable(reason, detail) {
|
|
2955
|
-
return { status: "unavailable", reason, detail };
|
|
2956
|
-
}
|
|
2957
|
-
function partial(value, coveredFrom, note) {
|
|
2958
|
-
return { status: "partial", value, coveredFrom, note };
|
|
2959
|
-
}
|
|
2960
|
-
function valueOrNull(metric) {
|
|
2961
|
-
return metric.status === "unavailable" ? null : metric.value;
|
|
2962
|
-
}
|
|
2963
|
-
var REPORT_NAMES = [
|
|
2964
|
-
"measurement-health",
|
|
2965
|
-
"acquisition",
|
|
2966
|
-
"journey",
|
|
2967
|
-
"typeface-interest",
|
|
2968
|
-
"diagnostics"
|
|
2969
|
-
];
|
|
2970
|
-
function isReportName(value) {
|
|
2971
|
-
return typeof value === "string" && REPORT_NAMES.includes(value);
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
3101
|
// src/core/cutover.ts
|
|
2975
3102
|
var PREEXISTING = "preexisting";
|
|
2976
3103
|
function toHistory(cutover) {
|