@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.
- package/dist/analytics.d.ts +15 -4
- package/dist/analytics.js +26 -5
- package/dist/editor-preview-bridge-utils.d.ts +9 -0
- package/dist/editor-preview-bridge-utils.js +24 -1
- package/dist/editor-preview-bridge.js +79 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/recommendation-tracking.d.ts +33 -0
- package/dist/recommendation-tracking.js +78 -0
- package/dist/storefront-events.d.ts +86 -0
- package/dist/storefront-events.js +36 -0
- package/dist/template-renderer.d.ts +8 -1
- package/dist/template-renderer.js +89 -18
- package/package.json +5 -3
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
23
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
113
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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__ */
|
|
313
|
-
ref:
|
|
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
|
|
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
|
-
|
|
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__ */
|
|
197
|
+
children: /* @__PURE__ */ jsx(BlockPathContext.Provider, {
|
|
147
198
|
value: EMPTY_BLOCK_PATH,
|
|
148
|
-
children:
|
|
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
|
|
192
|
-
|
|
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(
|
|
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(
|
|
203
|
-
|
|
204
|
-
|
|
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.
|
|
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.
|
|
35
|
-
"@shop-on/theme-runtime": "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",
|