@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.
- 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 +45 -3
- 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
|
|
@@ -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.
|
|
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",
|