@shop-on/theme-react 0.5.0 → 0.6.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.
@@ -1,3 +1,4 @@
1
+ import { EventSourceCart, EventSourceLine } from "./storefront-events.js";
1
2
  //#region src/analytics.d.ts
2
3
  export type EventProperties = Record<string, unknown>;
3
4
  export declare function trackVisit(properties?: EventProperties): Promise<void>;
@@ -19,12 +20,22 @@ export declare function toInt(value: unknown): number | null;
19
20
  * `recommendation_shown` / `recommendation_clicked` pair, so the two event
20
21
  * families stay mutually exclusive by surface and nothing double-counts. */
21
22
  export type ProductListType = "collection" | "search";
22
- /** Fires `product_added_to_cart` after a successful add. Wired once inside
23
- * `use-cart`, so every add surface (buy buttons, quick add) is covered. */
23
+ /** Reports one successful add, both ways every listener hears it:
24
+ *
25
+ * - `shopon:cart:added` on `window`, the public event merchant scripts and
26
+ * the platform's marketing script read (GA4 `add_to_cart` is built from it
27
+ * alone). Fired in the editor preview too, like every theme event: it is a
28
+ * DOM event, not a beacon.
29
+ * - the `product_added_to_cart` beacon to Rails, which also feeds Meta CAPI.
30
+ *
31
+ * Call it once per added variant, from `use-cart` after the cart has been
32
+ * revalidated, so a listener reading the DOM sees the new cart. `line` is the
33
+ * line the variant landed on in the result's cart; `quantity` is the quantity
34
+ * ADDED, not the line's. */
24
35
  export declare function trackProductAddedToCart(input: {
25
- productId: string | number | null | undefined;
26
- variantId: string | number | null | undefined;
36
+ line: EventSourceLine;
27
37
  quantity: number;
38
+ cart: EventSourceCart;
28
39
  }): void;
29
40
  /** Fires `product_removed_from_cart` on line removal — the funnel-drop signal
30
41
  * symmetric to the add event, so it carries the same join keys. */
package/dist/analytics.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { toEventCart, toEventProduct, toEventVariant } from "./storefront-events.js";
1
2
  //#region src/analytics.ts
2
3
  const VISIT_KEY = "ahoy_visit";
3
4
  const VISITOR_KEY = "ahoy_visitor";
@@ -109,16 +110,36 @@ function toInt(value) {
109
110
  const parsed = Number.parseInt(String(value ?? ""), 10);
110
111
  return Number.isInteger(parsed) ? parsed : null;
111
112
  }
112
- /** Fires `product_added_to_cart` after a successful add. Wired once inside
113
- * `use-cart`, so every add surface (buy buttons, quick add) is covered. */
113
+ /** Reports one successful add, both ways every listener hears it:
114
+ *
115
+ * - `shopon:cart:added` on `window`, the public event merchant scripts and
116
+ * the platform's marketing script read (GA4 `add_to_cart` is built from it
117
+ * alone). Fired in the editor preview too, like every theme event: it is a
118
+ * DOM event, not a beacon.
119
+ * - the `product_added_to_cart` beacon to Rails, which also feeds Meta CAPI.
120
+ *
121
+ * Call it once per added variant, from `use-cart` after the cart has been
122
+ * revalidated, so a listener reading the DOM sees the new cart. `line` is the
123
+ * line the variant landed on in the result's cart; `quantity` is the quantity
124
+ * ADDED, not the line's. */
114
125
  function trackProductAddedToCart(input) {
115
- const productId = toInt(input.productId);
126
+ const { line, quantity, cart } = input;
127
+ if (typeof window !== "undefined") {
128
+ const detail = {
129
+ product: toEventProduct(line),
130
+ variant: toEventVariant(line),
131
+ quantity,
132
+ cart: toEventCart(cart)
133
+ };
134
+ window.dispatchEvent(new CustomEvent("shopon:cart:added", { detail }));
135
+ }
136
+ const productId = toInt(line.variant.product.id);
116
137
  if (productId === null) return;
117
- const variantId = toInt(input.variantId);
138
+ const variantId = toInt(line.variant.id);
118
139
  track("product_added_to_cart", {
119
140
  product_id: productId,
120
141
  ...variantId !== null ? { variant_id: variantId } : {},
121
- quantity: input.quantity
142
+ quantity
122
143
  });
123
144
  }
124
145
  /** Fires `product_removed_from_cart` on line removal — the funnel-drop signal
@@ -29,4 +29,13 @@ export declare function forwardsFromPreview(command: EditorCommand, target: Even
29
29
  textSelected: boolean;
30
30
  selectionShown: boolean;
31
31
  }): boolean;
32
+ /** A teammate's selection, drawn in their colour under this editor's own. */
33
+ export interface EditorPeerMark {
34
+ item: EditorSelectedItem;
35
+ color: string;
36
+ name: string;
37
+ }
38
+ /** The admin's `theme-editor:peers` payload, keeping only well-formed marks.
39
+ * Null when it isn't a list at all. */
40
+ export declare function parsePeerMarks(raw: unknown): EditorPeerMark[] | null;
32
41
  //#endregion
@@ -81,5 +81,28 @@ function forwardsFromPreview(command, target, ctx) {
81
81
  }
82
82
  return !(command === "copy" && ctx.textSelected);
83
83
  }
84
+ const MAX_PEER_MARKS = 12;
85
+ const MAX_PEER_NAME = 60;
86
+ const PEER_COLOR_RE = /^(#[0-9a-f]{3,8}|(rgba?|hsla?|oklch|oklab|lab|lch)\([0-9a-z.%\s,/+-]*\))$/i;
87
+ /** The admin's `theme-editor:peers` payload, keeping only well-formed marks.
88
+ * Null when it isn't a list at all. */
89
+ function parsePeerMarks(raw) {
90
+ if (!Array.isArray(raw)) return null;
91
+ const out = [];
92
+ for (const entry of raw) {
93
+ if (out.length === MAX_PEER_MARKS) break;
94
+ if (!entry || typeof entry !== "object") continue;
95
+ const v = entry;
96
+ const item = parseSelected(v.item);
97
+ if (!item || typeof v.color !== "string" || !PEER_COLOR_RE.test(v.color.trim())) continue;
98
+ const name = typeof v.name === "string" ? v.name.trim().slice(0, MAX_PEER_NAME) : "";
99
+ out.push({
100
+ item,
101
+ color: v.color.trim(),
102
+ name
103
+ });
104
+ }
105
+ return out;
106
+ }
84
107
  //#endregion
85
- export { forwardsFromPreview, parseSelected, readClickTarget, sameItem };
108
+ export { forwardsFromPreview, parsePeerMarks, parseSelected, readClickTarget, sameItem };
@@ -1,6 +1,6 @@
1
1
  "use client";
2
- import { forwardsFromPreview, parseSelected, readClickTarget, sameItem } from "./editor-preview-bridge-utils.js";
3
- import { jsx } from "react/jsx-runtime";
2
+ import { forwardsFromPreview, parsePeerMarks, parseSelected, readClickTarget, sameItem } from "./editor-preview-bridge-utils.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
4
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
5
5
  import { editorCommand } from "@shop-on/theme-contract";
6
6
  //#region src/editor-preview-bridge.tsx
@@ -100,10 +100,38 @@ const OVERLAY_RECT_STYLE = {
100
100
  width: "0",
101
101
  height: "0"
102
102
  };
103
+ const PEER_RING_STEP_PX = 3;
104
+ const PEER_LABEL_HEIGHT_PX = 18;
105
+ const PEER_RECT_STYLE = {
106
+ ...OVERLAY_RECT_STYLE,
107
+ boxShadow: "none"
108
+ };
109
+ const PEER_LABEL_STYLE = {
110
+ position: "absolute",
111
+ left: "-2px",
112
+ height: `${PEER_LABEL_HEIGHT_PX}px`,
113
+ padding: "0 6px",
114
+ borderRadius: "4px",
115
+ color: "#fff",
116
+ font: "500 11px/18px system-ui, sans-serif",
117
+ whiteSpace: "nowrap"
118
+ };
119
+ function createPeerRect() {
120
+ const div = document.createElement("div");
121
+ Object.assign(div.style, PEER_RECT_STYLE);
122
+ const label = document.createElement("span");
123
+ Object.assign(label.style, PEER_LABEL_STYLE);
124
+ div.appendChild(label);
125
+ return div;
126
+ }
103
127
  function EditorPreviewBridge({ preview }) {
104
128
  const isPreview = typeof preview === "string" && preview !== "";
105
129
  const [selected, setSelected] = useState(null);
106
130
  const [inspectorMode, setInspectorMode] = useState(true);
131
+ const peersRef = useRef([]);
132
+ const redrawRef = useRef(null);
133
+ const overlayRootRef = useRef(null);
134
+ const peerLayerRef = useRef(null);
107
135
  const overlayContainerRef = useRef(null);
108
136
  const selectedRef = useRef(null);
109
137
  const pendingScrollRef = useRef(null);
@@ -126,6 +154,13 @@ function EditorPreviewBridge({ preview }) {
126
154
  setInspectorMode(e.data.enabled);
127
155
  return;
128
156
  }
157
+ if (e.data.type === "theme-editor:peers") {
158
+ const marks = parsePeerMarks(e.data.peers);
159
+ if (!marks) return;
160
+ peersRef.current = marks;
161
+ redrawRef.current?.();
162
+ return;
163
+ }
129
164
  if (e.data.type === "theme-editor:mutate") {
130
165
  if (e.data.action !== "add") return;
131
166
  const item = parseSelected(e.data.item);
@@ -209,7 +244,8 @@ function EditorPreviewBridge({ preview }) {
209
244
  useEffect(() => {
210
245
  if (!isPreview) return;
211
246
  const container = overlayContainerRef.current;
212
- if (!container) return;
247
+ const peerLayer = peerLayerRef.current;
248
+ if (!container || !peerLayer) return;
213
249
  function ensureRectCount(n) {
214
250
  while (container.childElementCount < n) {
215
251
  const div = document.createElement("div");
@@ -243,6 +279,7 @@ function EditorPreviewBridge({ preview }) {
243
279
  div.style.width = `${r.width}px`;
244
280
  div.style.height = `${r.height}px`;
245
281
  });
282
+ drawPeers(targets);
246
283
  const rect = firstRect && firstRect.width > 0 && firstRect.height > 0 ? {
247
284
  top: firstRect.top,
248
285
  left: firstRect.left,
@@ -273,6 +310,38 @@ function EditorPreviewBridge({ preview }) {
273
310
  scrollItemIntoView(settleItem);
274
311
  }
275
312
  }
313
+ function drawPeers(ownTargets) {
314
+ const rings = /* @__PURE__ */ new Map();
315
+ for (const t of ownTargets) rings.set(t, 1);
316
+ const marks = inspectorModeRef.current ? peersRef.current : [];
317
+ let n = 0;
318
+ for (const mark of marks) {
319
+ let named = false;
320
+ findEditorTargets(mark.item).forEach((t) => {
321
+ const r = t.getBoundingClientRect();
322
+ if (r.width === 0 || r.height === 0) return;
323
+ const depth = rings.get(t) ?? 0;
324
+ rings.set(t, depth + 1);
325
+ const out = depth * PEER_RING_STEP_PX;
326
+ if (peerLayer.childElementCount <= n) peerLayer.appendChild(createPeerRect());
327
+ const div = peerLayer.children[n++];
328
+ div.style.display = "block";
329
+ div.style.top = `${r.top - out}px`;
330
+ div.style.left = `${r.left - out}px`;
331
+ div.style.width = `${r.width + out * 2}px`;
332
+ div.style.height = `${r.height + out * 2}px`;
333
+ div.style.borderColor = mark.color;
334
+ const label = div.firstElementChild;
335
+ const showName = !named && mark.name !== "";
336
+ if (showName) named = true;
337
+ label.style.display = showName ? "block" : "none";
338
+ label.style.top = r.top - out < PEER_LABEL_HEIGHT_PX ? "0" : `-${PEER_LABEL_HEIGHT_PX}px`;
339
+ label.style.backgroundColor = mark.color;
340
+ if (label.textContent !== mark.name) label.textContent = mark.name;
341
+ });
342
+ }
343
+ while (peerLayer.childElementCount > n) peerLayer.removeChild(peerLayer.lastElementChild);
344
+ }
276
345
  function schedule(fromResize) {
277
346
  if (fromResize) resizePending = true;
278
347
  if (rafPending) return;
@@ -280,12 +349,13 @@ function EditorPreviewBridge({ preview }) {
280
349
  requestAnimationFrame(update);
281
350
  }
282
351
  const scheduleFromContent = () => schedule(false);
352
+ redrawRef.current = scheduleFromContent;
283
353
  const scheduleFromResize = () => schedule(true);
284
354
  update();
285
355
  const resize = new ResizeObserver(scheduleFromResize);
286
356
  resize.observe(document.documentElement);
287
357
  const mut = new MutationObserver((records) => {
288
- const overlayNode = overlayContainerRef.current;
358
+ const overlayNode = overlayRootRef.current;
289
359
  if (overlayNode && records.every((r) => overlayNode.contains(r.target))) return;
290
360
  schedule(false);
291
361
  });
@@ -298,6 +368,7 @@ function EditorPreviewBridge({ preview }) {
298
368
  window.addEventListener("scroll", scheduleFromContent, true);
299
369
  window.addEventListener("resize", scheduleFromResize);
300
370
  return () => {
371
+ redrawRef.current = null;
301
372
  resize.disconnect();
302
373
  mut.disconnect();
303
374
  window.removeEventListener("scroll", scheduleFromContent, true);
@@ -309,10 +380,11 @@ function EditorPreviewBridge({ preview }) {
309
380
  inspectorMode
310
381
  ]);
311
382
  if (!isPreview) return null;
312
- return /* @__PURE__ */ jsx("div", {
313
- ref: overlayContainerRef,
383
+ return /* @__PURE__ */ jsxs("div", {
384
+ ref: overlayRootRef,
314
385
  style: OVERLAY_CONTAINER_STYLE,
315
- "aria-hidden": true
386
+ "aria-hidden": true,
387
+ children: [/* @__PURE__ */ jsx("div", { ref: peerLayerRef }), /* @__PURE__ */ jsx("div", { ref: overlayContainerRef })]
316
388
  });
317
389
  }
318
390
  //#endregion
package/dist/index.d.ts CHANGED
@@ -7,6 +7,6 @@ import { ProductJsonLd, ProductJsonLdProps } from "./product-json-ld.js";
7
7
  import { WebsiteJsonLd, WebsiteJsonLdProps } from "./website-json-ld.js";
8
8
  import { PreviewSearch, bouncePreviewToNotFound } from "./preview-guard.js";
9
9
  import { retainPreviewSearch } from "./preview-search-middleware.js";
10
- import { ArticleSchemaSource, BlockInstance, LayoutComponent, ProductSchemaSource, SectionGroupConfig, TemplateJSON, TemplateRendererInstance, TemplateRendererLoaders, TemplateSeoSource, createTemplateRenderer } from "./template-renderer.js";
10
+ import { ArticleSchemaSource, BlockInstance, ClosestScope, LayoutComponent, ProductSchemaSource, SectionGroupConfig, TemplateJSON, TemplateRendererInstance, TemplateRendererLoaders, TemplateSeoSource, createTemplateRenderer, withClosestOverlays } from "./template-renderer.js";
11
11
  import { useIsoLayoutEffect } from "./use-iso-layout-effect.js";
12
- export { ArticleJsonLd, type ArticleJsonLdProps, type ArticleSchemaSource, type BlockInstance, BreadcrumbJsonLd, type BreadcrumbJsonLdProps, EditorPreviewBridge, type EditorSelectedItem, FaqJsonLd, type FaqJsonLdItem, type FaqJsonLdProps, type LayoutComponent, type PreviewSearch, ProductJsonLd, type ProductJsonLdProps, type ProductSchemaSource, type SectionGroupConfig, type TemplateJSON, type TemplateRendererInstance, type TemplateRendererLoaders, type TemplateSeoSource, WebsiteJsonLd, type WebsiteJsonLdProps, bouncePreviewToNotFound, createTemplateRenderer, parseSelected, readClickTarget, retainPreviewSearch, sameItem, useIsoLayoutEffect };
12
+ export { ArticleJsonLd, type ArticleJsonLdProps, type ArticleSchemaSource, type BlockInstance, BreadcrumbJsonLd, type BreadcrumbJsonLdProps, ClosestScope, EditorPreviewBridge, type EditorSelectedItem, FaqJsonLd, type FaqJsonLdItem, type FaqJsonLdProps, type LayoutComponent, type PreviewSearch, ProductJsonLd, type ProductJsonLdProps, type ProductSchemaSource, type SectionGroupConfig, type TemplateJSON, type TemplateRendererInstance, type TemplateRendererLoaders, type TemplateSeoSource, WebsiteJsonLd, type WebsiteJsonLdProps, bouncePreviewToNotFound, createTemplateRenderer, parseSelected, readClickTarget, retainPreviewSearch, sameItem, useIsoLayoutEffect, withClosestOverlays };
package/dist/index.js CHANGED
@@ -7,6 +7,6 @@ import { parseSelected, readClickTarget, sameItem } from "./editor-preview-bridg
7
7
  import { EditorPreviewBridge } from "./editor-preview-bridge.js";
8
8
  import { bouncePreviewToNotFound } from "./preview-guard.js";
9
9
  import { retainPreviewSearch } from "./preview-search-middleware.js";
10
- import { createTemplateRenderer } from "./template-renderer.js";
10
+ import { ClosestScope, createTemplateRenderer, withClosestOverlays } from "./template-renderer.js";
11
11
  import { useIsoLayoutEffect } from "./use-iso-layout-effect.js";
12
- export { ArticleJsonLd, BreadcrumbJsonLd, EditorPreviewBridge, FaqJsonLd, ProductJsonLd, WebsiteJsonLd, bouncePreviewToNotFound, createTemplateRenderer, parseSelected, readClickTarget, retainPreviewSearch, sameItem, useIsoLayoutEffect };
12
+ export { ArticleJsonLd, BreadcrumbJsonLd, ClosestScope, EditorPreviewBridge, FaqJsonLd, ProductJsonLd, WebsiteJsonLd, bouncePreviewToNotFound, createTemplateRenderer, parseSelected, readClickTarget, retainPreviewSearch, sameItem, useIsoLayoutEffect, withClosestOverlays };
@@ -0,0 +1,33 @@
1
+ import { RecommendationAttribution } from "./analytics.js";
2
+ import { MouseEvent } from "react";
3
+ //#region src/recommendation-tracking.d.ts
4
+ /** The product a rail recommends for: the product page's, or the first line
5
+ * of the cart. */
6
+ export type RecommendationReference = {
7
+ id?: string | number | null;
8
+ handle: string;
9
+ };
10
+ /** Impression + click tracking for a recommendation rail. Spread the returned
11
+ * `{ ref, onClickCapture }` onto an element around the rail's cards, and mark
12
+ * each card with `data-product-id` / `data-product-handle` / `data-position`.
13
+ * With no `reference` nothing is reported: products picked by hand are not
14
+ * recommendations.
15
+ *
16
+ * `recommendation_shown` fires per card once half of it is in view — a rail
17
+ * scrolls sideways, so a card past its edge was not seen — and once per
18
+ * reference product, however often the rail re-renders.
19
+ *
20
+ * `recommendation_clicked` fires in the CAPTURE phase for a click that lands
21
+ * on a link inside a card, so the beacon leaves in the same tick as the
22
+ * navigation and an add-to-cart button is not a click-through. */
23
+ export declare function useRecommendationTracking({ reference, intent, signature }: {
24
+ reference: RecommendationReference | null | undefined;
25
+ intent: RecommendationAttribution["intent"];
26
+ /** The cards on show, as one string (their handles, joined): a new set is
27
+ * observed afresh. */
28
+ signature: string;
29
+ }): {
30
+ ref: import("react").Dispatch<import("react").SetStateAction<HTMLElement | null>>;
31
+ onClickCapture: (event: MouseEvent<HTMLElement>) => void;
32
+ };
33
+ //#endregion
@@ -0,0 +1,78 @@
1
+ import { ensureVisit, trackRecommendationClicked, trackRecommendationShown } from "./analytics.js";
2
+ import { useEffect, useRef, useState } from "react";
3
+ //#region src/recommendation-tracking.ts
4
+ function cardAttribution(card, reference, intent) {
5
+ const position = Number.parseInt(card.dataset.position ?? "", 10);
6
+ return {
7
+ referenceProductId: reference.id,
8
+ referenceProductHandle: reference.handle,
9
+ recommendedProductId: card.dataset.productId,
10
+ recommendedProductHandle: card.dataset.productHandle ?? "",
11
+ intent,
12
+ position: Number.isInteger(position) ? position : 0
13
+ };
14
+ }
15
+ /** Impression + click tracking for a recommendation rail. Spread the returned
16
+ * `{ ref, onClickCapture }` onto an element around the rail's cards, and mark
17
+ * each card with `data-product-id` / `data-product-handle` / `data-position`.
18
+ * With no `reference` nothing is reported: products picked by hand are not
19
+ * recommendations.
20
+ *
21
+ * `recommendation_shown` fires per card once half of it is in view — a rail
22
+ * scrolls sideways, so a card past its edge was not seen — and once per
23
+ * reference product, however often the rail re-renders.
24
+ *
25
+ * `recommendation_clicked` fires in the CAPTURE phase for a click that lands
26
+ * on a link inside a card, so the beacon leaves in the same tick as the
27
+ * navigation and an add-to-cart button is not a click-through. */
28
+ function useRecommendationTracking({ reference, intent, signature }) {
29
+ const [node, ref] = useState(null);
30
+ const shown = useRef(/* @__PURE__ */ new Set());
31
+ const referenceId = reference?.id;
32
+ const referenceHandle = reference?.handle;
33
+ useEffect(() => {
34
+ if (!node || !referenceHandle || !signature) return;
35
+ ensureVisit();
36
+ const current = {
37
+ id: referenceId,
38
+ handle: referenceHandle
39
+ };
40
+ const observer = new IntersectionObserver((entries) => {
41
+ for (const entry of entries) {
42
+ if (!entry.isIntersecting) continue;
43
+ const card = entry.target;
44
+ observer.unobserve(card);
45
+ const key = `${referenceHandle}:${card.dataset.productId}`;
46
+ if (shown.current.has(key)) continue;
47
+ shown.current.add(key);
48
+ trackRecommendationShown(cardAttribution(card, current, intent));
49
+ }
50
+ }, { threshold: .5 });
51
+ for (const card of node.querySelectorAll("[data-product-id]")) observer.observe(card);
52
+ return () => observer.disconnect();
53
+ }, [
54
+ node,
55
+ referenceId,
56
+ referenceHandle,
57
+ intent,
58
+ signature
59
+ ]);
60
+ const onClickCapture = (event) => {
61
+ if (!referenceHandle) return;
62
+ const target = event.target;
63
+ const card = target?.closest("[data-product-id]");
64
+ if (!card) return;
65
+ const anchor = target?.closest("a");
66
+ if (!anchor || !card.contains(anchor)) return;
67
+ trackRecommendationClicked(cardAttribution(card, {
68
+ id: referenceId,
69
+ handle: referenceHandle
70
+ }, intent));
71
+ };
72
+ return {
73
+ ref,
74
+ onClickCapture
75
+ };
76
+ }
77
+ //#endregion
78
+ export { useRecommendationTracking };
@@ -0,0 +1,86 @@
1
+ import { Money } from "@shop-on/theme-runtime/money";
2
+ //#region src/storefront-events.d.ts
3
+ /**
4
+ * The cart as merchant scripts see it, in every `shopon:cart:*` event.
5
+ *
6
+ * A PUBLIC CONTRACT: merchant scripts, app sections and the platform's
7
+ * marketing script (GA4 `add_to_cart`, `Shop::Marketing::PixelScript`) listen
8
+ * on these, so a field can't be renamed once shipped. One copy, here, so every
9
+ * theme sends the same shape and a listener survives a theme switch.
10
+ *
11
+ * Hand-mapped, never the server's `CartData`: that type carries fields that
12
+ * exist for a theme's own rendering (image `srcset` / `thumbhash`, the
13
+ * storefront-scope `visible` flag) and grows whenever a theme needs one.
14
+ * Emitting it raw would publish all of that. House rules: images are URL
15
+ * strings, prices are `Money`, optional values are `null`, never absent.
16
+ */
17
+ export type EventProduct = {
18
+ id: string | null;
19
+ handle: string;
20
+ title: string;
21
+ image: string | null;
22
+ };
23
+ export type EventVariant = {
24
+ id: string;
25
+ title: string;
26
+ sku: string | null;
27
+ price: Money;
28
+ image: string | null;
29
+ };
30
+ export type EventCartLine = {
31
+ id: string;
32
+ quantity: number;
33
+ cost: Money;
34
+ product: EventProduct;
35
+ variant: EventVariant;
36
+ };
37
+ export type EventCart = {
38
+ id: string;
39
+ totalQuantity: number;
40
+ subtotalAmount: Money;
41
+ discountAmount: Money;
42
+ lines: EventCartLine[];
43
+ };
44
+ /** `shopon:cart:added`'s `detail`. `quantity` is the quantity ADDED, not the
45
+ * resulting line quantity: adding 1 to a line that held 2 reports 1. */
46
+ export type CartAddedDetail = {
47
+ product: EventProduct;
48
+ variant: EventVariant;
49
+ quantity: number;
50
+ cart: EventCart;
51
+ };
52
+ /** The slice of `@shop-on/theme-server/cart`'s `CartLine` the events read —
53
+ * structural, so this package needs no server dependency. */
54
+ export type EventSourceLine = {
55
+ id: string;
56
+ quantity: number;
57
+ cost: Money;
58
+ variant: {
59
+ id: string;
60
+ title: string;
61
+ sku: string | null;
62
+ price: Money;
63
+ image: {
64
+ url: string | null;
65
+ } | null;
66
+ product: {
67
+ id: string | null;
68
+ handle: string;
69
+ title: string;
70
+ image: {
71
+ url: string | null;
72
+ } | null;
73
+ };
74
+ };
75
+ };
76
+ export type EventSourceCart = {
77
+ id: string;
78
+ totalQuantity: number;
79
+ subtotalAmount: Money;
80
+ discountAmount: Money;
81
+ lines: EventSourceLine[];
82
+ };
83
+ export declare function toEventProduct(line: EventSourceLine): EventProduct;
84
+ export declare function toEventVariant(line: EventSourceLine): EventVariant;
85
+ export declare function toEventCart(cart: EventSourceCart): EventCart;
86
+ //#endregion
@@ -0,0 +1,36 @@
1
+ //#region src/storefront-events.ts
2
+ function toEventProduct(line) {
3
+ const product = line.variant.product;
4
+ return {
5
+ id: product.id,
6
+ handle: product.handle,
7
+ title: product.title,
8
+ image: product.image?.url ?? null
9
+ };
10
+ }
11
+ function toEventVariant(line) {
12
+ return {
13
+ id: line.variant.id,
14
+ title: line.variant.title,
15
+ sku: line.variant.sku ?? null,
16
+ price: line.variant.price,
17
+ image: line.variant.image?.url ?? null
18
+ };
19
+ }
20
+ function toEventCart(cart) {
21
+ return {
22
+ id: cart.id,
23
+ totalQuantity: cart.totalQuantity,
24
+ subtotalAmount: cart.subtotalAmount,
25
+ discountAmount: cart.discountAmount,
26
+ lines: cart.lines.map((line) => ({
27
+ id: line.id,
28
+ quantity: line.quantity,
29
+ cost: line.cost,
30
+ product: toEventProduct(line),
31
+ variant: toEventVariant(line)
32
+ }))
33
+ };
34
+ }
35
+ //#endregion
36
+ export { toEventCart, toEventProduct, toEventVariant };
@@ -1,5 +1,5 @@
1
1
  import { ComponentType, ReactNode } from "react";
2
- import { TemplateSection } from "@shop-on/theme-contract";
2
+ import { ClosestOverlays, TemplateSection } from "@shop-on/theme-contract";
3
3
  import { ArticleSchemaSource, BlockInstance, BlockInstance as BlockInstance$1, ProductSchemaSource, SectionGroupConfig, SectionGroupConfig as SectionGroupConfig$1, TemplateJSON, TemplateJSON as TemplateJSON$1, TemplateSeoSource } from "@shop-on/theme-runtime/template-types";
4
4
  //#region src/template-renderer.d.ts
5
5
  export type LayoutComponent = ComponentType<{
@@ -14,6 +14,13 @@ export interface AppSectionProps {
14
14
  groupType?: string;
15
15
  }
16
16
  export type AppSectionComponent = ComponentType<AppSectionProps>;
17
+ export declare const ClosestScope: import("react").Provider<string | undefined>;
18
+ /** `block` (the `each` subtree root, whose id is `eachId`) with `overlays`
19
+ * merged into its subtree's `closestSettings`, for the `ClosestScope` mounts
20
+ * below it to read. A new tree along each touched path only — every other
21
+ * node, and the input, stay as they were — so a list can merge each page it
22
+ * fetches without re-rendering cards whose blocks did not change. */
23
+ export declare function withClosestOverlays(block: BlockInstance$1, eachId: string, overlays: ClosestOverlays | null | undefined): BlockInstance$1;
17
24
  export declare function loadAppScript(src: string): Promise<void>;
18
25
  /** The current shop's handle, read from the root route's loader data: every
19
26
  * theme's root loader returns `fetchShopInfo()` as `shop`. An app's element
@@ -8,6 +8,45 @@ const EMPTY_BLOCK_PATH = [];
8
8
  const SectionIdContext = createContext(void 0);
9
9
  const BlockPathContext = createContext(EMPTY_BLOCK_PATH);
10
10
  const BlockDepthContext = createContext(void 0);
11
+ const ClosestScopeContext = createContext(void 0);
12
+ const ClosestScope = ClosestScopeContext.Provider;
13
+ /** `block` (the `each` subtree root, whose id is `eachId`) with `overlays`
14
+ * merged into its subtree's `closestSettings`, for the `ClosestScope` mounts
15
+ * below it to read. A new tree along each touched path only — every other
16
+ * node, and the input, stay as they were — so a list can merge each page it
17
+ * fetches without re-rendering cards whose blocks did not change. */
18
+ function withClosestOverlays(block, eachId, overlays) {
19
+ let root = block;
20
+ for (const [key, byItem] of Object.entries(overlays ?? {})) {
21
+ const [head, ...rest] = key.split("/");
22
+ if (head !== eachId) continue;
23
+ const merge = (node, path) => {
24
+ if (path.length === 0) {
25
+ const closestSettings = { ...node.closestSettings };
26
+ for (const [handle, settings] of Object.entries(byItem)) closestSettings[handle] = {
27
+ ...Object.hasOwn(closestSettings, handle) ? closestSettings[handle] : void 0,
28
+ ...settings
29
+ };
30
+ return {
31
+ ...node,
32
+ closestSettings
33
+ };
34
+ }
35
+ const [id, ...tail] = path;
36
+ const child = node.blocks && Object.hasOwn(node.blocks, id) ? node.blocks[id] : void 0;
37
+ if (!child) return node;
38
+ return {
39
+ ...node,
40
+ blocks: {
41
+ ...node.blocks,
42
+ [id]: merge(child, tail)
43
+ }
44
+ };
45
+ };
46
+ root = merge(root, rest);
47
+ }
48
+ return root;
49
+ }
11
50
  const appScriptPromises = /* @__PURE__ */ new Map();
12
51
  function loadAppScript(src) {
13
52
  const cached = appScriptPromises.get(src);
@@ -197,10 +236,12 @@ function createTemplateRenderer(loaders, opts) {
197
236
  children: renderSections(template.sections, template.order)
198
237
  });
199
238
  }
200
- function renderThemeBlock(block) {
239
+ function renderThemeBlock(block, scope) {
201
240
  const BlockComponent = resolveBlock(block.type);
241
+ const overlay = scope !== void 0 && block.closestSettings && Object.hasOwn(block.closestSettings, scope) ? block.closestSettings[scope] : void 0;
202
242
  return /* @__PURE__ */ jsx(BlockComponent, {
203
243
  ...block.settings,
244
+ ...overlay,
204
245
  blockId: block.id,
205
246
  blocks: selectRenderableChildren(block)
206
247
  });
@@ -210,6 +251,7 @@ function createTemplateRenderer(loaders, opts) {
210
251
  const inheritedPath = useContext(BlockPathContext);
211
252
  const containerPath = inheritedPath.length > 0 ? inheritedPath : parentBlockId ? [parentBlockId] : EMPTY_BLOCK_PATH;
212
253
  const depth = useContext(BlockDepthContext);
254
+ const scope = useContext(ClosestScopeContext);
213
255
  return /* @__PURE__ */ jsx(Fragment, { children: blocks.map((block, i) => {
214
256
  if (block.disabled) return null;
215
257
  const isApp = block.source === "app";
@@ -231,7 +273,7 @@ function createTemplateRenderer(loaders, opts) {
231
273
  value: ownPath,
232
274
  children: /* @__PURE__ */ jsx(BlockDepthContext.Provider, {
233
275
  value: depth === void 0 ? void 0 : depth + 1,
234
- children: renderThemeBlock(block)
276
+ children: renderThemeBlock(block, scope)
235
277
  })
236
278
  })]
237
279
  }, key);
@@ -247,4 +289,4 @@ function createTemplateRenderer(loaders, opts) {
247
289
  };
248
290
  }
249
291
  //#endregion
250
- export { collectTemplateTypes, createTemplateRenderer, loadAppScript, useShopHandle };
292
+ export { ClosestScope, collectTemplateTypes, createTemplateRenderer, loadAppScript, useShopHandle, withClosestOverlays };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shop-on/theme-react",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "license": "SEE LICENSE IN dist/LICENSE.md",
5
5
  "files": [
6
6
  "dist"
@@ -22,6 +22,8 @@
22
22
  "./preview-guard": "./dist/preview-guard.js",
23
23
  "./preview-search-middleware": "./dist/preview-search-middleware.js",
24
24
  "./product-json-ld": "./dist/product-json-ld.js",
25
+ "./recommendation-tracking": "./dist/recommendation-tracking.js",
26
+ "./storefront-events": "./dist/storefront-events.js",
25
27
  "./template-renderer": "./dist/template-renderer.js",
26
28
  "./turnstile-widget": "./dist/turnstile-widget.js",
27
29
  "./uploaded-font-faces": "./dist/uploaded-font-faces.js",
@@ -31,8 +33,8 @@
31
33
  "./package.json": "./package.json"
32
34
  },
33
35
  "dependencies": {
34
- "@shop-on/theme-contract": "0.5.0",
35
- "@shop-on/theme-runtime": "0.5.0"
36
+ "@shop-on/theme-contract": "0.6.0",
37
+ "@shop-on/theme-runtime": "0.6.0"
36
38
  },
37
39
  "devDependencies": {
38
40
  "@tanstack/react-router": "1.170.45",