@shop-on/theme-react 0.4.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
@@ -1,19 +1,59 @@
1
1
  import { escapeAttrValue, scopeCustomCss } from "./custom-css.js";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { createContext, createElement, useContext, useEffect } from "react";
4
- import { collectTypes, selectRenderableChildren, selectRenderableSections } from "@shop-on/theme-contract";
4
+ import { MAX_APP_BLOCK_CONTAINER_DEPTH, collectTypes, selectRenderableChildren, selectRenderableSections } from "@shop-on/theme-contract";
5
5
  import { rootRouteId, useMatch } from "@tanstack/react-router";
6
6
  //#region src/template-renderer.tsx
7
7
  const EMPTY_BLOCK_PATH = [];
8
8
  const SectionIdContext = createContext(void 0);
9
9
  const BlockPathContext = createContext(EMPTY_BLOCK_PATH);
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
+ }
10
50
  const appScriptPromises = /* @__PURE__ */ new Map();
11
51
  function loadAppScript(src) {
12
52
  const cached = appScriptPromises.get(src);
13
53
  if (cached) return cached;
14
54
  const p = new Promise((resolve, reject) => {
15
55
  if (typeof window === "undefined") return resolve();
16
- const existing = document.querySelector(`script[data-app-bundle="${src}"]`);
56
+ const existing = document.querySelector(`script[data-app-bundle="${CSS.escape(src)}"]`);
17
57
  if (existing) {
18
58
  existing.addEventListener("load", () => resolve(), { once: true });
19
59
  existing.addEventListener("error", () => reject(/* @__PURE__ */ new Error("app-script-load-failed")), { once: true });
@@ -46,21 +86,31 @@ function useShopHandle() {
46
86
  select: (match) => shopHandleOf(match.loaderData)
47
87
  });
48
88
  }
49
- function DefaultAppSection({ section, sectionId }) {
50
- const { tag, scriptUrl } = section;
51
- const settings = section.settings ?? {};
89
+ function AppElement({ tag, scriptUrl, settings, kind, instanceId }) {
52
90
  const shopHandle = useShopHandle();
53
91
  useEffect(() => {
54
92
  if (scriptUrl) loadAppScript(scriptUrl).catch(() => {});
55
93
  }, [scriptUrl]);
56
- if (!tag) return null;
57
94
  return createElement(tag, {
58
- "data-settings": JSON.stringify(settings),
59
- "data-app-section-id": sectionId,
95
+ "data-settings": JSON.stringify(settings ?? {}),
96
+ [`data-app-${kind}-id`]: instanceId,
60
97
  "data-shop-handle": shopHandle,
61
98
  suppressHydrationWarning: true
62
99
  });
63
100
  }
101
+ function DefaultAppSection({ section, sectionId }) {
102
+ if (!section.tag) return null;
103
+ return /* @__PURE__ */ jsx(AppElement, {
104
+ tag: section.tag,
105
+ scriptUrl: section.scriptUrl,
106
+ settings: section.settings,
107
+ kind: "section",
108
+ instanceId: sectionId
109
+ });
110
+ }
111
+ function mountsAppBlock(block, depth) {
112
+ return !!block.id && depth !== void 0 && depth <= MAX_APP_BLOCK_CONTAINER_DEPTH && !block.static && !!block.tag && block.type.startsWith("app-");
113
+ }
64
114
  /** `collectTypes` over every template, merged. */
65
115
  function collectTemplateTypes(templates) {
66
116
  const sections = /* @__PURE__ */ new Set();
@@ -109,6 +159,7 @@ function createTemplateRenderer(loaders, opts) {
109
159
  async function preloadBlocks(args) {
110
160
  const types = /* @__PURE__ */ new Set();
111
161
  const visit = (b) => {
162
+ if (b.source === "app") return;
112
163
  types.add(b.type);
113
164
  if (b.blocks) for (const child of Object.values(b.blocks)) visit(child);
114
165
  };
@@ -143,9 +194,12 @@ function createTemplateRenderer(loaders, opts) {
143
194
  style: { display: "contents" },
144
195
  children: /* @__PURE__ */ jsx(SectionIdContext.Provider, {
145
196
  value: instanceId,
146
- children: /* @__PURE__ */ jsxs(BlockPathContext.Provider, {
197
+ children: /* @__PURE__ */ jsx(BlockPathContext.Provider, {
147
198
  value: EMPTY_BLOCK_PATH,
148
- children: [sectionCss ? /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: sectionCss } }) : null, renderSectionBody(section, instanceId, groupType, blocks, staticBlocks)]
199
+ children: /* @__PURE__ */ jsxs(BlockDepthContext.Provider, {
200
+ value: 0,
201
+ children: [sectionCss ? /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: sectionCss } }) : null, renderSectionBody(section, instanceId, groupType, blocks, staticBlocks)]
202
+ })
149
203
  })
150
204
  })
151
205
  }, instanceId);
@@ -182,14 +236,26 @@ function createTemplateRenderer(loaders, opts) {
182
236
  children: renderSections(template.sections, template.order)
183
237
  });
184
238
  }
239
+ function renderThemeBlock(block, scope) {
240
+ const BlockComponent = resolveBlock(block.type);
241
+ const overlay = scope !== void 0 && block.closestSettings && Object.hasOwn(block.closestSettings, scope) ? block.closestSettings[scope] : void 0;
242
+ return /* @__PURE__ */ jsx(BlockComponent, {
243
+ ...block.settings,
244
+ ...overlay,
245
+ blockId: block.id,
246
+ blocks: selectRenderableChildren(block)
247
+ });
248
+ }
185
249
  function BlockRenderer({ blocks, parentBlockId }) {
186
250
  const sectionId = useContext(SectionIdContext);
187
251
  const inheritedPath = useContext(BlockPathContext);
188
252
  const containerPath = inheritedPath.length > 0 ? inheritedPath : parentBlockId ? [parentBlockId] : EMPTY_BLOCK_PATH;
253
+ const depth = useContext(BlockDepthContext);
254
+ const scope = useContext(ClosestScopeContext);
189
255
  return /* @__PURE__ */ jsx(Fragment, { children: blocks.map((block, i) => {
190
256
  if (block.disabled) return null;
191
- const BlockComponent = resolveBlock(block.type);
192
- const children = selectRenderableChildren(block);
257
+ const isApp = block.source === "app";
258
+ if (isApp && !mountsAppBlock(block, depth)) return null;
193
259
  const key = block.id ?? `${block.type}-${i}`;
194
260
  const ownPath = block.id ? [...containerPath, block.id] : containerPath;
195
261
  const blockCss = block.id && sectionId ? scopeCustomCss(`[data-editor-section-id="${escapeAttrValue(sectionId)}"] ${containerPath.map((seg) => `[data-editor-block-id="${escapeAttrValue(seg)}"] `).join("")}[data-editor-block-id="${escapeAttrValue(block.id)}"]`, block.custom_css) : null;
@@ -197,12 +263,17 @@ function createTemplateRenderer(loaders, opts) {
197
263
  "data-editor-block-id": block.id || void 0,
198
264
  className: `block-${block.type.replace(/^_/, "")}`,
199
265
  style: { display: "contents" },
200
- children: [blockCss ? /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: blockCss } }) : null, /* @__PURE__ */ jsx(BlockPathContext.Provider, {
266
+ children: [blockCss ? /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: blockCss } }) : null, isApp ? /* @__PURE__ */ jsx(AppElement, {
267
+ tag: block.tag,
268
+ scriptUrl: block.scriptUrl,
269
+ settings: block.settings,
270
+ kind: "block",
271
+ instanceId: block.id
272
+ }) : /* @__PURE__ */ jsx(BlockPathContext.Provider, {
201
273
  value: ownPath,
202
- children: /* @__PURE__ */ jsx(BlockComponent, {
203
- ...block.settings,
204
- blockId: block.id,
205
- blocks: children
274
+ children: /* @__PURE__ */ jsx(BlockDepthContext.Provider, {
275
+ value: depth === void 0 ? void 0 : depth + 1,
276
+ children: renderThemeBlock(block, scope)
206
277
  })
207
278
  })]
208
279
  }, key);
@@ -218,4 +289,4 @@ function createTemplateRenderer(loaders, opts) {
218
289
  };
219
290
  }
220
291
  //#endregion
221
- 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.4.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.4.0",
35
- "@shop-on/theme-runtime": "0.4.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",