@liiift-studio/sanity-visitor-insights 0.14.0 → 0.16.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.
Files changed (40) hide show
  1. package/dist/index.d.mts +2 -2
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +98 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +97 -1
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{ranges-CEHxJQE6.d.mts → ranges-DvICo5H2.d.mts} +185 -2
  8. package/dist/{ranges-CEHxJQE6.d.ts → ranges-DvICo5H2.d.ts} +185 -2
  9. package/dist/server.d.mts +12 -4
  10. package/dist/server.d.ts +12 -4
  11. package/dist/server.js +317 -14
  12. package/dist/server.js.map +1 -1
  13. package/dist/server.mjs +308 -14
  14. package/dist/server.mjs.map +1 -1
  15. package/dist/testing.d.mts +20 -2
  16. package/dist/testing.d.ts +20 -2
  17. package/dist/testing.js +14 -0
  18. package/dist/testing.js.map +1 -1
  19. package/dist/testing.mjs +13 -0
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{vercel-CL2w9Jqs.d.mts → vercel-CU5PgGrJ.d.mts} +119 -1
  22. package/dist/{vercel-CL2w9Jqs.d.ts → vercel-CU5PgGrJ.d.ts} +119 -1
  23. package/package.json +1 -1
  24. package/src/core/capture.ts +206 -0
  25. package/src/core/core.test.ts +74 -0
  26. package/src/core/siteConfig.ts +24 -0
  27. package/src/index.ts +1 -0
  28. package/src/reportData.ts +45 -0
  29. package/src/server/createHandler.ts +30 -4
  30. package/src/server/mailchimp.ts +172 -0
  31. package/src/server/orders.ts +107 -0
  32. package/src/server/reports/measurementHealth.ts +110 -3
  33. package/src/server/reports/reports.test.ts +79 -0
  34. package/src/server.ts +6 -2
  35. package/src/studio/Figure.tsx +28 -0
  36. package/src/studio/VisitorInsightsTool.tsx +1 -0
  37. package/src/studio/panels.test.tsx +74 -0
  38. package/src/studio/panels.tsx +108 -0
  39. package/src/testing/fakes.ts +23 -0
  40. package/src/types.ts +22 -1
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-CEHxJQE6.mjs';
4
- export { C as CheckStatus, d as Coverage, e as DailyPoint, f as DateRange, g as DiagnosticCheck, E as EventCutover, h as JourneyOutcome, i as JourneyStep, L as LandingPage, P as PREEXISTING, j as REPORT_NAMES, k as ReportError, S as SiteAnalyticsConfig, l as SourceName, m as SourceRow, n as SourceStatus, o as TypefaceInterestRow, U as UnavailableReason, p as coverageForRange, q as isReportName, r as ok, s as partial, t as previousRange, u as resolveRange, v as unavailable, w as validateSiteConfig, x as valueOrNull } from './ranges-CEHxJQE6.mjs';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-DvICo5H2.mjs';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as DailyPoint, i as DateRange, j as DiagnosticCheck, E as EmailCampaign, k as EventCutover, l as JourneyOutcome, m as JourneyStep, L as LandingPage, P as PREEXISTING, n as REPORT_NAMES, o as ReportError, S as SiteAnalyticsConfig, p as SourceName, q as SourceRow, r as SourceStatus, s as TypefaceInterestRow, U as UnavailableReason, t as coverageForRange, u as estimated, v as isReportName, w as ok, x as partial, y as previousRange, z as resolveRange, B as unavailable, F as validateSiteConfig, G as valueOrNull } from './ranges-DvICo5H2.mjs';
5
5
 
6
6
  /**
7
7
  * The Visitor Insights Studio tool.
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-CEHxJQE6.js';
4
- export { C as CheckStatus, d as Coverage, e as DailyPoint, f as DateRange, g as DiagnosticCheck, E as EventCutover, h as JourneyOutcome, i as JourneyStep, L as LandingPage, P as PREEXISTING, j as REPORT_NAMES, k as ReportError, S as SiteAnalyticsConfig, l as SourceName, m as SourceRow, n as SourceStatus, o as TypefaceInterestRow, U as UnavailableReason, p as coverageForRange, q as isReportName, r as ok, s as partial, t as previousRange, u as resolveRange, v as unavailable, w as validateSiteConfig, x as valueOrNull } from './ranges-CEHxJQE6.js';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-DvICo5H2.js';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as DailyPoint, i as DateRange, j as DiagnosticCheck, E as EmailCampaign, k as EventCutover, l as JourneyOutcome, m as JourneyStep, L as LandingPage, P as PREEXISTING, n as REPORT_NAMES, o as ReportError, S as SiteAnalyticsConfig, p as SourceName, q as SourceRow, r as SourceStatus, s as TypefaceInterestRow, U as UnavailableReason, t as coverageForRange, u as estimated, v as isReportName, w as ok, x as partial, y as previousRange, z as resolveRange, B as unavailable, F as validateSiteConfig, G as valueOrNull } from './ranges-DvICo5H2.js';
5
5
 
6
6
  /**
7
7
  * The Visitor Insights Studio tool.
package/dist/index.js CHANGED
@@ -47,6 +47,7 @@ __export(index_exports, {
47
47
  VisitorInsightsTool: () => VisitorInsightsTool,
48
48
  coverageForRange: () => coverageForRange,
49
49
  default: () => index_default,
50
+ estimated: () => estimated,
50
51
  formatCount: () => formatCount,
51
52
  formatMoney: () => formatMoney,
52
53
  formatPercent: () => formatPercent,
@@ -213,6 +214,21 @@ function MetricFigure({ metric, label, size = 4, unit = "count" }) {
213
214
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Badge, { tone: "caution", fontSize: 0, children: "Partial" })
214
215
  ] });
215
216
  }
217
+ if (metric.status === "estimated") {
218
+ const range = unit === "percent" ? `${metric.low.toFixed(1)}% to ${metric.high.toFixed(1)}%` : `${formatCount(metric.low)} to ${formatCount(metric.high)}`;
219
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 2, children: [
220
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size, "aria-label": `${label}: estimated ${formatted}, between ${range}. ${metric.basis}`, children: [
221
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", children: "~" }),
222
+ formatted
223
+ ] }),
224
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: range }),
225
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Badge, { tone: "primary", fontSize: 0, children: "Estimated" })
226
+ ] });
227
+ }
228
+ if (metric.status !== "ok") {
229
+ const unhandled = metric;
230
+ void unhandled;
231
+ }
216
232
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size, "aria-label": `${label}: ${formatted}`, children: formatted });
217
233
  }
218
234
  function ComparisonBar({ label, metric, max, tone = "default" }) {
@@ -889,12 +905,71 @@ function MeasurementHealthPanel({ data, previous }) {
889
905
  data.shortfallRatio !== null && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, weight: "semibold", children: data.shortfallRatio >= 0 ? `GA4 saw ${formatPercent(data.shortfallRatio, 1)} fewer pageviews than Vercel` : `GA4 saw ${formatPercent(-data.shortfallRatio, 1)} more pageviews than Vercel` }),
890
906
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: data.interpretation })
891
907
  ] }) }),
908
+ ((data.capture?.estimates?.length ?? 0) > 0 || data.estimatedSessions?.status === "estimated") && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
909
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "How much GA4 is seeing" }),
910
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: cardGrid, children: (data.capture?.estimates ?? []).map((estimate) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
911
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: estimate.basis === "orders" ? "Measured against orders" : estimate.basis === "email" ? "Measured against email clicks" : "Measured against Vercel" }),
912
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 4, children: formatPercent(Math.min(1, estimate.rate), 0) }),
913
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: [
914
+ formatCount(estimate.observed),
915
+ " of ",
916
+ formatCount(estimate.actual),
917
+ ". ",
918
+ estimate.note
919
+ ] })
920
+ ] }) }, estimate.basis)) }),
921
+ data.capture?.discrepancy && /* @__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.jsxs)(import_sanity_ui_compat2.Stack, { space: 2, children: [
922
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, weight: "medium", children: "The sources disagree, and that is informative" }),
923
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: data.capture.discrepancy })
924
+ ] }) }),
925
+ data.estimatedSessions && data.estimatedSessions.status === "estimated" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
926
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Sessions, corrected for what GA4 misses" }),
927
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MetricFigure, { metric: data.estimatedSessions, label: "Estimated sessions" })
928
+ ] }) })
929
+ ] }),
892
930
  (data.daily?.length ?? 0) >= 3 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
893
931
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "Day by day" }),
894
932
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: "A gap that has always been there is consent and blocking. A gap that opens on one day is an incident." }),
895
933
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(TrendChart, { points: data.daily ?? [] }),
896
934
  data.vercelDailyUnavailable && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Vercel reports this range in weekly buckets, so only the GA4 line is plotted. Choose Week or Month to see both." })
897
935
  ] }),
936
+ (data.campaigns?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
937
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "Email campaigns" }),
938
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.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." }),
939
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
940
+ SortableTable,
941
+ {
942
+ caption: "Email campaigns by clicks",
943
+ initialSort: "clicks",
944
+ rows: data.campaigns ?? [],
945
+ rowKey: (c) => `${c.sentAt}-${c.title}`,
946
+ filterOn: (c) => `${c.title} ${c.subject}`,
947
+ filterPlaceholder: "Filter campaigns",
948
+ exportName: "email-campaigns",
949
+ columns: [
950
+ {
951
+ key: "title",
952
+ label: "Campaign",
953
+ sortValue: (c) => c.title,
954
+ render: (c) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 1, children: [
955
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: c.title }),
956
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: c.sentAt.slice(0, 10) })
957
+ ] })
958
+ },
959
+ { key: "sent", label: "Sent", numeric: true, sortValue: (c) => c.sent, render: (c) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: formatCount(c.sent) }) },
960
+ { key: "opens", label: "Opens", numeric: true, sortValue: (c) => c.opens, render: (c) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: formatCount(c.opens) }) },
961
+ { key: "clicks", label: "Clicks", numeric: true, sortValue: (c) => c.clicks, render: (c) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: formatCount(c.clicks) }) },
962
+ {
963
+ key: "unsub",
964
+ label: "Unsubscribed",
965
+ numeric: true,
966
+ sortValue: (c) => c.unsubscribed,
967
+ render: (c) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: formatCount(c.unsubscribed) })
968
+ }
969
+ ]
970
+ }
971
+ )
972
+ ] }),
898
973
  Object.keys(data.orderStatuses ?? {}).length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
899
974
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "Order statuses in this range" }),
900
975
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: "What the orders actually say, before any filtering. Use these values to set which statuses count as a sale." }),
@@ -907,6 +982,20 @@ function MeasurementHealthPanel({ data, previous }) {
907
982
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Heading, { size: 1, style: sectionHeading, children: "Context" }),
908
983
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: "Different units to the figures above, and to each other. Shown for scale, never differenced." }),
909
984
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: cardGrid, children: [
985
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
986
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Mailing list" }),
987
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: figureRow, children: [
988
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MetricFigure, { metric: metricOr(data.audience, OLDER_ROUTE), label: "Mailing list members" }),
989
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
990
+ Delta,
991
+ {
992
+ current: metricSortValue(data.audience),
993
+ previous: metricSortValue(previous?.audience)
994
+ }
995
+ )
996
+ ] }),
997
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Mailchimp\u2019s own count. Not consent-gated or blockable, so it replaces the site\u2019s subscribe event rather than sitting beside it." })
998
+ ] }) }),
910
999
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
911
1000
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Label, { size: 1, muted: true, children: "Revenue" }),
912
1001
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: figureRow, children: [
@@ -1448,7 +1537,8 @@ function RangeSelector({
1448
1537
  var SOURCE_LABEL = {
1449
1538
  ga4: "Google Analytics",
1450
1539
  vercel: "Vercel",
1451
- sanity: "Orders"
1540
+ sanity: "Orders",
1541
+ mailchimp: "Mailchimp"
1452
1542
  };
1453
1543
  function SourceStatusRow({ sources }) {
1454
1544
  const degraded = Object.entries(sources).filter(([, s]) => s.status !== "ok");
@@ -1598,6 +1688,9 @@ function VisitorInsightsTool(props) {
1598
1688
  }
1599
1689
 
1600
1690
  // src/types.ts
1691
+ function estimated(value, low, high, basis) {
1692
+ return { status: "estimated", value, low, high, basis };
1693
+ }
1601
1694
  function ok(value) {
1602
1695
  return { status: "ok", value };
1603
1696
  }
@@ -1686,6 +1779,9 @@ function validateSiteConfig(config) {
1686
1779
  ["orders.totalField", config.orders?.totalField],
1687
1780
  ["orders.typefacesField", config.orders?.typefacesField]
1688
1781
  ];
1782
+ for (const name of Object.keys(config.orders?.licenceFields ?? {})) {
1783
+ fieldSlots.push([`orders.licenceFields.${name}`, name]);
1784
+ }
1689
1785
  for (const [field, value] of fieldSlots) {
1690
1786
  if (!value) continue;
1691
1787
  if (!FIELD_PATH.test(value)) {
@@ -1840,6 +1936,7 @@ var index_default = visitorInsights;
1840
1936
  TypefaceInterestPanel,
1841
1937
  VisitorInsightsTool,
1842
1938
  coverageForRange,
1939
+ estimated,
1843
1940
  formatCount,
1844
1941
  formatMoney,
1845
1942
  formatPercent,