@liiift-studio/sanity-visitor-insights 0.26.0 → 0.27.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/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-BUZuS1c-.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-BUZuS1c-.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-_SBvGBJx.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-_SBvGBJx.mjs';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
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-BUZuS1c-.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-BUZuS1c-.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-_SBvGBJx.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-_SBvGBJx.js';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
package/dist/index.js CHANGED
@@ -1595,7 +1595,7 @@ function OverviewPanel({ data, previous, onBrush }) {
1595
1595
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Pageviews, counted server-side." })
1596
1596
  ] }) }),
1597
1597
  /* @__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: [
1598
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Mailing list" }),
1598
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Mailing list, total" }),
1599
1599
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: figureRow, children: [
1600
1600
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: metricOr(data.audience, OLDER_ROUTE), label: "Mailing list members" }),
1601
1601
  (() => {
@@ -1608,7 +1608,7 @@ function OverviewPanel({ data, previous, onBrush }) {
1608
1608
  ] });
1609
1609
  })()
1610
1610
  ] }),
1611
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Mailchimp\u2019s own count \u2014 not consent-gated or blockable." })
1611
+ /* @__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." })
1612
1612
  ] }) })
1613
1613
  ] }),
1614
1614
  (data.crossSource?.length ?? 0) >= 3 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
@@ -2017,10 +2017,54 @@ function AcquisitionPanel({ data, previous }) {
2017
2017
  // The quality signal. Ranked by volume alone, 27 Display sessions looked
2018
2018
  // equal to 27 from Search, which flatters the worst line of spend.
2019
2019
  render: (row) => finiteOrNull(row.engagementRate) === null ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "\u2014" }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: formatPercent(row.engagementRate, 0) })
2020
+ },
2021
+ {
2022
+ /*
2023
+ * What the channel BROUGHT, which is the question a foundry opens this tool
2024
+ * with and the one it could not answer. Sessions and engagement rank
2025
+ * channels by attention; nothing ranked them by money, so "is the design
2026
+ * press worth chasing" and "did the newsletter sell anything" were
2027
+ * unaskable while revenue sat one tab away, split by typeface and by
2028
+ * licence — the two cuts already available from the order list.
2029
+ *
2030
+ * Sorted on the SHARE, so the ordering holds even when Sanity supplies no
2031
+ * total and the money column is empty.
2032
+ */
2033
+ key: "brought",
2034
+ label: "Revenue (est.)",
2035
+ numeric: true,
2036
+ sortValue: (row) => finiteOrNull(row.revenueShare),
2037
+ exportValue: (row) => row.apportionedRevenue === null ? null : formatMoney(row.apportionedRevenue, data.currency ?? null),
2038
+ render: (row) => {
2039
+ if (row.revenueShare === null) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "\u2014" });
2040
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 1, children: [
2041
+ row.apportionedRevenue !== null && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: formatMoney(row.apportionedRevenue, data.currency ?? null) }),
2042
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: [
2043
+ formatPercent(row.revenueShare, 0),
2044
+ " of tracked sales"
2045
+ ] })
2046
+ ] });
2047
+ }
2020
2048
  }
2021
2049
  ]
2022
2050
  }
2023
- )
2051
+ ),
2052
+ data.trackedPurchases > 0 && data.actualRevenue !== null && // Says exactly what was combined and what was estimated. The money in that column
2053
+ // is Sanity's real total spread across GA4's split — right in scale, and derived,
2054
+ // which is a different claim from measured. Saying so here is what makes the
2055
+ // column usable rather than another figure to distrust.
2056
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: [
2057
+ "Revenue is ",
2058
+ formatMoney(data.actualRevenue, data.currency ?? null),
2059
+ " from",
2060
+ " ",
2061
+ data.actualOrders === null ? "your orders" : `${formatCount(data.actualOrders)} orders`,
2062
+ " in Sanity, split across the ",
2063
+ formatCount(data.trackedPurchases),
2064
+ " purchase",
2065
+ data.trackedPurchases === 1 ? "" : "s",
2066
+ " Google Analytics attributed to a source. Google Analytics sees a fraction of your traffic, but it loses it fairly evenly across channels \u2014 so the split is far more trustworthy than its own totals. Treat these as proportions with a real total behind them, not as measured takings per channel."
2067
+ ] })
2024
2068
  ] }),
2025
2069
  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."] })
2026
2070
  ] });
@@ -2102,12 +2146,24 @@ function JourneyPanel({ data }) {
2102
2146
  ] })
2103
2147
  ] });
2104
2148
  }
2149
+ function medianOf(values) {
2150
+ const usable = values.filter((v) => v !== null && Number.isFinite(v) && v > 0).sort((a, b) => a - b);
2151
+ if (usable.length < 3) return null;
2152
+ const middle = Math.floor(usable.length / 2);
2153
+ return usable.length % 2 === 0 ? (usable[middle - 1] + usable[middle]) / 2 : usable[middle];
2154
+ }
2155
+ function buyRateIndex(row, median) {
2156
+ const rate = row.buyRate;
2157
+ if (median === null || median <= 0 || rate === null || rate === void 0 || !Number.isFinite(rate)) return null;
2158
+ return rate / median;
2159
+ }
2105
2160
  function TypefaceInterestPanel({ data }) {
2161
+ const medianBuyRate = medianOf((data.rows ?? []).map((row) => finiteOrNull(row.buyRate)));
2106
2162
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
2107
2163
  /* @__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.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: data.interpretationNote }) }),
2108
2164
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2109
2165
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Engagement by typeface" }),
2110
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Sort by any column. Sort on buy rate to find families that are looked at and do not sell." }),
2166
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Sort by any column. \u201CSells vs catalogue\u201D compares each family\u2019s orders-per-view against the median family, which is the part of that ratio that holds even though Google Analytics only sees a fraction of the views. It ranks families against each other; it is not the share of visitors who buy, and at these order volumes a family with one or two sales will swing a long way." }),
2111
2167
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2112
2168
  SortableTable,
2113
2169
  {
@@ -2168,15 +2224,46 @@ function TypefaceInterestPanel({ data }) {
2168
2224
  }
2169
2225
  },
2170
2226
  {
2227
+ /*
2228
+ * Relative to the catalogue, NOT an absolute rate.
2229
+ *
2230
+ * This is Sanity orders over GA4 views: a complete numerator on a lossy
2231
+ * denominator. Printed as "0.85%" it invited the reading "eight in a
2232
+ * thousand visitors buy this", which is wrong by whatever multiple GA4 is
2233
+ * missing — and it MOVES when the tag breaks. When Darden's GA4 count fell
2234
+ * from 471 a day to 70, every family's printed rate multiplied by about
2235
+ * seven, and the families that looked best were the ones GA4 had stopped
2236
+ * seeing.
2237
+ *
2238
+ * What survives that is the ORDER. The loss is roughly uniform across
2239
+ * families, so it cancels in a comparison between them even as it wrecks
2240
+ * the absolute figure. Showing each family against the catalogue median
2241
+ * keeps the one question this column can answer — which families convert
2242
+ * better or worse than the rest — and drops the one it cannot.
2243
+ */
2171
2244
  key: "buyRate",
2172
- label: "Buy rate",
2245
+ label: "Sells vs catalogue",
2173
2246
  numeric: true,
2174
2247
  sortValue: (row) => finiteOrNull(row.buyRate),
2175
2248
  exportValue: (row) => {
2176
- const r = finiteOrNull(row.buyRate);
2177
- return r === null ? null : formatPercent(r, 2);
2249
+ const index = buyRateIndex(row, medianBuyRate);
2250
+ return index === null ? null : `${index.toFixed(1)}x`;
2178
2251
  },
2179
- render: (row) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, "aria-label": finiteOrNull(row.buyRate) === null ? `${row.typeface} buy rate unavailable` : void 0, children: finiteOrNull(row.buyRate) === null ? "\u2014" : formatPercent(row.buyRate, 2) })
2252
+ render: (row) => {
2253
+ const index = buyRateIndex(row, medianBuyRate);
2254
+ if (index === null) {
2255
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, "aria-label": `${row.typeface} not comparable`, children: "\u2014" });
2256
+ }
2257
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 1, children: [
2258
+ index.toFixed(1),
2259
+ "\xD7",
2260
+ " ",
2261
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { style: { opacity: 0.6, fontSize: "0.85em" }, children: [
2262
+ index >= 1.15 ? "above" : index <= 0.85 ? "below" : "at",
2263
+ " median"
2264
+ ] })
2265
+ ] });
2266
+ }
2180
2267
  },
2181
2268
  {
2182
2269
  key: "testRate",