@shop-on/theme-react 0.0.0-stage → 0.2.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/LICENSE.md +39 -0
- package/dist/analytics.d.ts +100 -0
- package/dist/analytics.js +250 -0
- package/dist/article-json-ld.d.ts +7 -0
- package/dist/article-json-ld.js +33 -0
- package/dist/breadcrumb-json-ld.d.ts +7 -0
- package/dist/breadcrumb-json-ld.js +24 -0
- package/dist/custom-css.d.ts +17 -0
- package/dist/custom-css.js +29 -0
- package/dist/editor-preview-bridge-utils.d.ts +32 -0
- package/dist/editor-preview-bridge-utils.js +85 -0
- package/dist/editor-preview-bridge.d.ts +8 -0
- package/dist/editor-preview-bridge.js +319 -0
- package/dist/faq-json-ld.d.ts +11 -0
- package/dist/faq-json-ld.js +25 -0
- package/dist/hreflang-links.d.ts +12 -0
- package/dist/hreflang-links.js +51 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/link-utils.d.ts +5 -0
- package/dist/link-utils.js +8 -0
- package/dist/preview-guard.d.ts +6 -0
- package/dist/preview-guard.js +15 -0
- package/dist/preview-search-middleware.d.ts +3 -0
- package/dist/preview-search-middleware.js +11 -0
- package/dist/product-json-ld.d.ts +45 -0
- package/dist/product-json-ld.js +50 -0
- package/dist/template-renderer.d.ts +80 -0
- package/dist/template-renderer.js +221 -0
- package/dist/turnstile-widget.d.ts +39 -0
- package/dist/turnstile-widget.js +84 -0
- package/dist/uploaded-font-faces.d.ts +7 -0
- package/dist/uploaded-font-faces.js +24 -0
- package/dist/use-iso-layout-effect.d.ts +4 -0
- package/dist/use-iso-layout-effect.js +5 -0
- package/dist/website-json-ld.d.ts +7 -0
- package/dist/website-json-ld.js +28 -0
- package/dist/when-near-viewport.d.ts +45 -0
- package/dist/when-near-viewport.js +59 -0
- package/package.json +52 -3
- package/README.md +0 -3
package/dist/LICENSE.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Cody Theme Development License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cody LLC. All rights reserved except as granted below.
|
|
4
|
+
|
|
5
|
+
"The Software" is this package, its source, and its documentation. "The
|
|
6
|
+
Platform" is Cody, the commerce platform operated by Cody LLC.
|
|
7
|
+
|
|
8
|
+
## 1. Grant
|
|
9
|
+
|
|
10
|
+
You may use, copy and modify the Software, and distribute modified copies, solely
|
|
11
|
+
to develop, test, and distribute themes, apps and other extensions that run on
|
|
12
|
+
the Platform.
|
|
13
|
+
|
|
14
|
+
## 2. Restrictions
|
|
15
|
+
|
|
16
|
+
You may not:
|
|
17
|
+
|
|
18
|
+
1. use the Software, or anything derived from it, with any commerce platform,
|
|
19
|
+
storefront or hosting service other than the Platform;
|
|
20
|
+
2. use the Software to build a product that competes with the Platform;
|
|
21
|
+
3. sublicense the Software, or distribute it other than as part of a theme,
|
|
22
|
+
app or extension for the Platform;
|
|
23
|
+
4. remove or alter this license or any copyright notice in the Software.
|
|
24
|
+
|
|
25
|
+
## 3. Your work
|
|
26
|
+
|
|
27
|
+
Themes, apps and extensions you build with the Software are yours. The parts of
|
|
28
|
+
the Software they include stay under this license.
|
|
29
|
+
|
|
30
|
+
## 4. Termination
|
|
31
|
+
|
|
32
|
+
This license ends automatically if you break any of its terms. You must then
|
|
33
|
+
stop using the Software and delete your copies of it.
|
|
34
|
+
|
|
35
|
+
## 5. No warranty
|
|
36
|
+
|
|
37
|
+
The Software is provided "as is", without warranty of any kind. Cody LLC is not
|
|
38
|
+
liable for any claim, damages or other liability arising from the Software or
|
|
39
|
+
its use.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//#region src/analytics.d.ts
|
|
2
|
+
export type EventProperties = Record<string, unknown>;
|
|
3
|
+
export declare function trackVisit(properties?: EventProperties): Promise<void>;
|
|
4
|
+
/** Every event carries a per-event `id`. Rails forwards it as the Meta CAPI
|
|
5
|
+
* `event_id`, which is the dedup key for a job retry (and, later, for a
|
|
6
|
+
* browser-side twin of a server event). */
|
|
7
|
+
export declare function track(name: string, properties?: EventProperties): Promise<void>;
|
|
8
|
+
/** Idempotent per page load — calls `trackVisit` only once even if several
|
|
9
|
+
* components mount concurrently. Every tracking hook calls it first, so the
|
|
10
|
+
* visit exists before any event references it. */
|
|
11
|
+
export declare function ensureVisit(properties?: EventProperties): void;
|
|
12
|
+
/** Storefront product ids arrive as stringified integer primary keys. Every
|
|
13
|
+
* aggregate downstream casts `(properties->>'<key>')::int`, so an unparseable
|
|
14
|
+
* id is dropped at the edge rather than written as a row that poisons a
|
|
15
|
+
* window. */
|
|
16
|
+
export declare function toInt(value: unknown): number | null;
|
|
17
|
+
/** The discovery surfaces that emit `product_list_viewed` / `product_clicked`.
|
|
18
|
+
* The recommendation rail is deliberately NOT one — it keeps its own
|
|
19
|
+
* `recommendation_shown` / `recommendation_clicked` pair, so the two event
|
|
20
|
+
* families stay mutually exclusive by surface and nothing double-counts. */
|
|
21
|
+
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. */
|
|
24
|
+
export declare function trackProductAddedToCart(input: {
|
|
25
|
+
productId: string | number | null | undefined;
|
|
26
|
+
variantId: string | number | null | undefined;
|
|
27
|
+
quantity: number;
|
|
28
|
+
}): void;
|
|
29
|
+
/** Fires `product_removed_from_cart` on line removal — the funnel-drop signal
|
|
30
|
+
* symmetric to the add event, so it carries the same join keys. */
|
|
31
|
+
export declare function trackProductRemovedFromCart(input: {
|
|
32
|
+
productId: string | number | null | undefined;
|
|
33
|
+
variantId: string | number | null | undefined;
|
|
34
|
+
quantity: number;
|
|
35
|
+
}): void;
|
|
36
|
+
/** Fires `checkout_started` on the cart→checkout handoff. Returns the promise
|
|
37
|
+
* so the caller can await it before the full-document hop: `keepalive` POSTs
|
|
38
|
+
* have historically been dropped by Safari across cross-document navigation,
|
|
39
|
+
* and this is the conversion signal. `beacon` swallows its own errors, so
|
|
40
|
+
* awaiting is safe. */
|
|
41
|
+
export declare function trackCheckoutStarted(input: {
|
|
42
|
+
totalQuantity: number;
|
|
43
|
+
subtotalAmount: number;
|
|
44
|
+
currency: string;
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
export declare function trackSearchSubmitted(input: {
|
|
47
|
+
query: string;
|
|
48
|
+
totalCount: number;
|
|
49
|
+
}): void;
|
|
50
|
+
export declare function trackCartViewed(input: {
|
|
51
|
+
lineCount: number;
|
|
52
|
+
totalQuantity: number;
|
|
53
|
+
subtotalAmount: number;
|
|
54
|
+
currency: string;
|
|
55
|
+
}): void;
|
|
56
|
+
/** Fires `product_clicked` (GA4 `select_item`) on click-through from a card in
|
|
57
|
+
* a list. `position` is the card's index within the rendered page. */
|
|
58
|
+
export declare function trackProductClicked(input: {
|
|
59
|
+
productId: string | number | null | undefined;
|
|
60
|
+
productHandle?: string;
|
|
61
|
+
listType: ProductListType;
|
|
62
|
+
listId?: string | number | null;
|
|
63
|
+
position?: number | null;
|
|
64
|
+
}): void;
|
|
65
|
+
/** Fires `product_list_viewed` (GA4 `view_item_list`) once a list scrolls into
|
|
66
|
+
* view, carrying the rendered items and their positions so a funnel can
|
|
67
|
+
* compute impression→click→ATC by position. Items with an unparseable id are
|
|
68
|
+
* dropped; the event is skipped entirely when nothing survives. */
|
|
69
|
+
export declare function trackProductListViewed(input: {
|
|
70
|
+
listType: ProductListType;
|
|
71
|
+
listId?: string | number | null;
|
|
72
|
+
listHandle?: string;
|
|
73
|
+
totalCount?: number;
|
|
74
|
+
items: {
|
|
75
|
+
productId: string | number | null | undefined;
|
|
76
|
+
position: number;
|
|
77
|
+
}[];
|
|
78
|
+
}): void;
|
|
79
|
+
/** One card of a recommendation rail: the product the rail recommends for,
|
|
80
|
+
* the one it recommended, why, and where in the rail. */
|
|
81
|
+
export type RecommendationAttribution = {
|
|
82
|
+
referenceProductId: string | number | null | undefined;
|
|
83
|
+
referenceProductHandle: string;
|
|
84
|
+
recommendedProductId: string | number | null | undefined;
|
|
85
|
+
recommendedProductHandle: string;
|
|
86
|
+
intent: "RELATED" | "COMPLEMENTARY";
|
|
87
|
+
position: number;
|
|
88
|
+
};
|
|
89
|
+
/** Fires `recommendation_shown` for a rail card in view. It carries no
|
|
90
|
+
* `product_id` on purpose: "shown" is not a positive engagement signal, so
|
|
91
|
+
* the trainer keeps ignoring it. The pair is separate from the listing's
|
|
92
|
+
* `product_list_viewed` / `product_clicked`, so a rail impression never
|
|
93
|
+
* double-counts as a list impression. */
|
|
94
|
+
export declare function trackRecommendationShown(attribution: RecommendationAttribution): void;
|
|
95
|
+
/** Fires `recommendation_clicked` for a click through to a rail card's product.
|
|
96
|
+
* `product_id` mirrors `recommended_product_id` so the trainer accepts the
|
|
97
|
+
* click as a positive signal for the product the buyer chose; the attribution
|
|
98
|
+
* fields stay distinct for analytics queries. */
|
|
99
|
+
export declare function trackRecommendationClicked(attribution: RecommendationAttribution): void;
|
|
100
|
+
//#endregion
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
//#region src/analytics.ts
|
|
2
|
+
const VISIT_KEY = "ahoy_visit";
|
|
3
|
+
const VISITOR_KEY = "ahoy_visitor";
|
|
4
|
+
const VISIT_TTL_MS = 144e5;
|
|
5
|
+
const VISITOR_TTL_MS = 63072e6;
|
|
6
|
+
function isBrowser() {
|
|
7
|
+
return typeof window !== "undefined" && typeof localStorage !== "undefined";
|
|
8
|
+
}
|
|
9
|
+
function isPreview() {
|
|
10
|
+
try {
|
|
11
|
+
return new URLSearchParams(window.location.search).has("preview");
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function readToken(key) {
|
|
17
|
+
if (!isBrowser()) return null;
|
|
18
|
+
try {
|
|
19
|
+
const raw = localStorage.getItem(key);
|
|
20
|
+
if (!raw) return null;
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
if (!parsed?.token || !parsed.expiresAt || parsed.expiresAt < Date.now()) return null;
|
|
23
|
+
return parsed;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function writeToken(key, token, ttlMs) {
|
|
29
|
+
if (!isBrowser()) return;
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(key, JSON.stringify({
|
|
32
|
+
token,
|
|
33
|
+
expiresAt: Date.now() + ttlMs
|
|
34
|
+
}));
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
function newToken() {
|
|
38
|
+
try {
|
|
39
|
+
return crypto.randomUUID();
|
|
40
|
+
} catch {
|
|
41
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function getVisitToken() {
|
|
45
|
+
const existing = readToken(VISIT_KEY);
|
|
46
|
+
if (existing) {
|
|
47
|
+
writeToken(VISIT_KEY, existing.token, VISIT_TTL_MS);
|
|
48
|
+
return existing.token;
|
|
49
|
+
}
|
|
50
|
+
const token = newToken();
|
|
51
|
+
writeToken(VISIT_KEY, token, VISIT_TTL_MS);
|
|
52
|
+
return token;
|
|
53
|
+
}
|
|
54
|
+
function getVisitorToken() {
|
|
55
|
+
const existing = readToken(VISITOR_KEY);
|
|
56
|
+
if (existing) return existing.token;
|
|
57
|
+
const token = newToken();
|
|
58
|
+
writeToken(VISITOR_KEY, token, VISITOR_TTL_MS);
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
async function beacon(path, body) {
|
|
62
|
+
if (!isBrowser() || isPreview()) return;
|
|
63
|
+
try {
|
|
64
|
+
await fetch(path, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
"Ahoy-Visit": getVisitToken(),
|
|
69
|
+
"Ahoy-Visitor": getVisitorToken()
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
keepalive: true,
|
|
73
|
+
credentials: "same-origin"
|
|
74
|
+
});
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
async function trackVisit(properties) {
|
|
78
|
+
await beacon("/analytics/visits", {
|
|
79
|
+
started_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
80
|
+
...properties
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** Every event carries a per-event `id`. Rails forwards it as the Meta CAPI
|
|
84
|
+
* `event_id`, which is the dedup key for a job retry (and, later, for a
|
|
85
|
+
* browser-side twin of a server event). */
|
|
86
|
+
async function track(name, properties = {}) {
|
|
87
|
+
await beacon("/analytics/events", { events: [{
|
|
88
|
+
name,
|
|
89
|
+
properties,
|
|
90
|
+
id: newToken(),
|
|
91
|
+
time: (/* @__PURE__ */ new Date()).toISOString()
|
|
92
|
+
}] });
|
|
93
|
+
}
|
|
94
|
+
let visitTrackedThisLoad = false;
|
|
95
|
+
/** Idempotent per page load — calls `trackVisit` only once even if several
|
|
96
|
+
* components mount concurrently. Every tracking hook calls it first, so the
|
|
97
|
+
* visit exists before any event references it. */
|
|
98
|
+
function ensureVisit(properties) {
|
|
99
|
+
if (!isBrowser() || visitTrackedThisLoad) return;
|
|
100
|
+
visitTrackedThisLoad = true;
|
|
101
|
+
trackVisit(properties);
|
|
102
|
+
}
|
|
103
|
+
/** Storefront product ids arrive as stringified integer primary keys. Every
|
|
104
|
+
* aggregate downstream casts `(properties->>'<key>')::int`, so an unparseable
|
|
105
|
+
* id is dropped at the edge rather than written as a row that poisons a
|
|
106
|
+
* window. */
|
|
107
|
+
function toInt(value) {
|
|
108
|
+
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
109
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
110
|
+
return Number.isInteger(parsed) ? parsed : null;
|
|
111
|
+
}
|
|
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. */
|
|
114
|
+
function trackProductAddedToCart(input) {
|
|
115
|
+
const productId = toInt(input.productId);
|
|
116
|
+
if (productId === null) return;
|
|
117
|
+
const variantId = toInt(input.variantId);
|
|
118
|
+
track("product_added_to_cart", {
|
|
119
|
+
product_id: productId,
|
|
120
|
+
...variantId !== null ? { variant_id: variantId } : {},
|
|
121
|
+
quantity: input.quantity
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Fires `product_removed_from_cart` on line removal — the funnel-drop signal
|
|
125
|
+
* symmetric to the add event, so it carries the same join keys. */
|
|
126
|
+
function trackProductRemovedFromCart(input) {
|
|
127
|
+
const productId = toInt(input.productId);
|
|
128
|
+
if (productId === null) return;
|
|
129
|
+
const variantId = toInt(input.variantId);
|
|
130
|
+
track("product_removed_from_cart", {
|
|
131
|
+
product_id: productId,
|
|
132
|
+
...variantId !== null ? { variant_id: variantId } : {},
|
|
133
|
+
quantity: input.quantity
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/** Fires `checkout_started` on the cart→checkout handoff. Returns the promise
|
|
137
|
+
* so the caller can await it before the full-document hop: `keepalive` POSTs
|
|
138
|
+
* have historically been dropped by Safari across cross-document navigation,
|
|
139
|
+
* and this is the conversion signal. `beacon` swallows its own errors, so
|
|
140
|
+
* awaiting is safe. */
|
|
141
|
+
function trackCheckoutStarted(input) {
|
|
142
|
+
return track("checkout_started", {
|
|
143
|
+
total_quantity: input.totalQuantity,
|
|
144
|
+
subtotal_amount: input.subtotalAmount,
|
|
145
|
+
currency: input.currency
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** Fires `search_submitted` once per unique query per page load. Keyed on the
|
|
149
|
+
* query alone, so paginating or refining filters on the same query doesn't
|
|
150
|
+
* refire — only a fresh query does. Blank queries (the empty state) are
|
|
151
|
+
* skipped. */
|
|
152
|
+
const trackedSearches = /* @__PURE__ */ new Set();
|
|
153
|
+
function trackSearchSubmitted(input) {
|
|
154
|
+
const q = input.query.trim();
|
|
155
|
+
if (!q || trackedSearches.has(q)) return;
|
|
156
|
+
trackedSearches.add(q);
|
|
157
|
+
track("search_submitted", {
|
|
158
|
+
query: q,
|
|
159
|
+
total_count: input.totalCount
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/** Fires `cart_viewed` when the drawer opens or the cart page paints.
|
|
163
|
+
* Idempotent across consecutive views of the SAME cart state — reopening the
|
|
164
|
+
* drawer unchanged shouldn't refire. Keyed on `(line_count, total_quantity)`
|
|
165
|
+
* rather than a snapshot of every line, so routine open/close churn doesn't
|
|
166
|
+
* pollute the log. */
|
|
167
|
+
let lastCartViewKey = null;
|
|
168
|
+
function trackCartViewed(input) {
|
|
169
|
+
const key = `${input.lineCount}:${input.totalQuantity}`;
|
|
170
|
+
if (lastCartViewKey === key) return;
|
|
171
|
+
lastCartViewKey = key;
|
|
172
|
+
track("cart_viewed", {
|
|
173
|
+
line_count: input.lineCount,
|
|
174
|
+
total_quantity: input.totalQuantity,
|
|
175
|
+
subtotal_amount: input.subtotalAmount,
|
|
176
|
+
currency: input.currency
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** Fires `product_clicked` (GA4 `select_item`) on click-through from a card in
|
|
180
|
+
* a list. `position` is the card's index within the rendered page. */
|
|
181
|
+
function trackProductClicked(input) {
|
|
182
|
+
const productId = toInt(input.productId);
|
|
183
|
+
if (productId === null) return;
|
|
184
|
+
const listId = input.listId == null ? null : toInt(input.listId);
|
|
185
|
+
track("product_clicked", {
|
|
186
|
+
product_id: productId,
|
|
187
|
+
...input.productHandle ? { product_handle: input.productHandle } : {},
|
|
188
|
+
list_type: input.listType,
|
|
189
|
+
...listId !== null ? { list_id: listId } : {},
|
|
190
|
+
...input.position != null ? { position: input.position } : {}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/** Fires `product_list_viewed` (GA4 `view_item_list`) once a list scrolls into
|
|
194
|
+
* view, carrying the rendered items and their positions so a funnel can
|
|
195
|
+
* compute impression→click→ATC by position. Items with an unparseable id are
|
|
196
|
+
* dropped; the event is skipped entirely when nothing survives. */
|
|
197
|
+
function trackProductListViewed(input) {
|
|
198
|
+
const items = input.items.flatMap((it) => {
|
|
199
|
+
const product_id = toInt(it.productId);
|
|
200
|
+
return product_id === null ? [] : [{
|
|
201
|
+
product_id,
|
|
202
|
+
position: it.position
|
|
203
|
+
}];
|
|
204
|
+
});
|
|
205
|
+
if (items.length === 0) return;
|
|
206
|
+
const listId = input.listId == null ? null : toInt(input.listId);
|
|
207
|
+
track("product_list_viewed", {
|
|
208
|
+
list_type: input.listType,
|
|
209
|
+
...listId !== null ? { list_id: listId } : {},
|
|
210
|
+
...input.listHandle ? { list_handle: input.listHandle } : {},
|
|
211
|
+
...input.totalCount != null ? { total_count: input.totalCount } : {},
|
|
212
|
+
items
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function recommendationPayload(a) {
|
|
216
|
+
const reference = toInt(a.referenceProductId);
|
|
217
|
+
const recommended = toInt(a.recommendedProductId);
|
|
218
|
+
if (reference === null || recommended === null) return null;
|
|
219
|
+
return {
|
|
220
|
+
reference_product_id: reference,
|
|
221
|
+
recommended_product_id: recommended,
|
|
222
|
+
reference_product_handle: a.referenceProductHandle,
|
|
223
|
+
recommended_product_handle: a.recommendedProductHandle,
|
|
224
|
+
intent: a.intent,
|
|
225
|
+
position: a.position
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Fires `recommendation_shown` for a rail card in view. It carries no
|
|
229
|
+
* `product_id` on purpose: "shown" is not a positive engagement signal, so
|
|
230
|
+
* the trainer keeps ignoring it. The pair is separate from the listing's
|
|
231
|
+
* `product_list_viewed` / `product_clicked`, so a rail impression never
|
|
232
|
+
* double-counts as a list impression. */
|
|
233
|
+
function trackRecommendationShown(attribution) {
|
|
234
|
+
const payload = recommendationPayload(attribution);
|
|
235
|
+
if (payload) track("recommendation_shown", payload);
|
|
236
|
+
}
|
|
237
|
+
/** Fires `recommendation_clicked` for a click through to a rail card's product.
|
|
238
|
+
* `product_id` mirrors `recommended_product_id` so the trainer accepts the
|
|
239
|
+
* click as a positive signal for the product the buyer chose; the attribution
|
|
240
|
+
* fields stay distinct for analytics queries. */
|
|
241
|
+
function trackRecommendationClicked(attribution) {
|
|
242
|
+
const payload = recommendationPayload(attribution);
|
|
243
|
+
if (!payload) return;
|
|
244
|
+
track("recommendation_clicked", {
|
|
245
|
+
...payload,
|
|
246
|
+
product_id: payload.recommended_product_id
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
export { ensureVisit, toInt, track, trackCartViewed, trackCheckoutStarted, trackProductAddedToCart, trackProductClicked, trackProductListViewed, trackProductRemovedFromCart, trackRecommendationClicked, trackRecommendationShown, trackSearchSubmitted, trackVisit };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ArticleSchemaSource } from "@shop-on/theme-runtime/template-types";
|
|
2
|
+
//#region src/article-json-ld.d.ts
|
|
3
|
+
export interface ArticleJsonLdProps {
|
|
4
|
+
data: ArticleSchemaSource;
|
|
5
|
+
}
|
|
6
|
+
export declare function ArticleJsonLd({ data }: ArticleJsonLdProps): import("react").JSX.Element;
|
|
7
|
+
//#endregion
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { escapeForScriptTag } from "@shop-on/theme-runtime/json-ld-escape";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
//#region src/article-json-ld.tsx
|
|
4
|
+
function buildArticleLd(data) {
|
|
5
|
+
const ld = {
|
|
6
|
+
"@context": "https://schema.org",
|
|
7
|
+
"@type": "BlogPosting",
|
|
8
|
+
headline: data.headline,
|
|
9
|
+
description: data.description,
|
|
10
|
+
datePublished: data.datePublished,
|
|
11
|
+
dateModified: data.dateModified
|
|
12
|
+
};
|
|
13
|
+
if (data.imageUrl) ld.image = [data.imageUrl];
|
|
14
|
+
if (data.authorName) ld.author = {
|
|
15
|
+
"@type": "Person",
|
|
16
|
+
name: data.authorName
|
|
17
|
+
};
|
|
18
|
+
if (data.publisherName) ld.publisher = {
|
|
19
|
+
"@type": "Organization",
|
|
20
|
+
name: data.publisherName
|
|
21
|
+
};
|
|
22
|
+
if (data.mainEntityOfPage) ld.mainEntityOfPage = data.mainEntityOfPage;
|
|
23
|
+
return ld;
|
|
24
|
+
}
|
|
25
|
+
function ArticleJsonLd({ data }) {
|
|
26
|
+
const json = escapeForScriptTag(JSON.stringify(buildArticleLd(data)));
|
|
27
|
+
return /* @__PURE__ */ jsx("script", {
|
|
28
|
+
type: "application/ld+json",
|
|
29
|
+
dangerouslySetInnerHTML: { __html: json }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
export { ArticleJsonLd };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { BreadcrumbSchemaSource } from "@shop-on/theme-runtime/template-types";
|
|
2
|
+
//#region src/breadcrumb-json-ld.d.ts
|
|
3
|
+
export interface BreadcrumbJsonLdProps {
|
|
4
|
+
data: BreadcrumbSchemaSource;
|
|
5
|
+
}
|
|
6
|
+
export declare function BreadcrumbJsonLd({ data }: BreadcrumbJsonLdProps): import("react").JSX.Element;
|
|
7
|
+
//#endregion
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { escapeForScriptTag } from "@shop-on/theme-runtime/json-ld-escape";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
//#region src/breadcrumb-json-ld.tsx
|
|
4
|
+
function buildBreadcrumbLd(data) {
|
|
5
|
+
return {
|
|
6
|
+
"@context": "https://schema.org",
|
|
7
|
+
"@type": "BreadcrumbList",
|
|
8
|
+
itemListElement: data.items.map((it, i) => ({
|
|
9
|
+
"@type": "ListItem",
|
|
10
|
+
position: i + 1,
|
|
11
|
+
name: it.name,
|
|
12
|
+
item: it.url
|
|
13
|
+
}))
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function BreadcrumbJsonLd({ data }) {
|
|
17
|
+
const json = escapeForScriptTag(JSON.stringify(buildBreadcrumbLd(data)));
|
|
18
|
+
return /* @__PURE__ */ jsx("script", {
|
|
19
|
+
type: "application/ld+json",
|
|
20
|
+
dangerouslySetInnerHTML: { __html: json }
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { BreadcrumbJsonLd };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/custom-css.d.ts
|
|
2
|
+
export declare function escapeAttrValue(value: string): string;
|
|
3
|
+
/** Guard + trim a merchant-authored CSS value from settings/instance data.
|
|
4
|
+
* Returns null for anything that isn't a non-empty string once trimmed, so
|
|
5
|
+
* callers can treat null uniformly as "nothing to render". The only
|
|
6
|
+
* transform applied is neutralizing a `</style>` (or `</script>`) breakout —
|
|
7
|
+
* `dangerouslySetInnerHTML` does not escape `</`, so a merchant value
|
|
8
|
+
* containing `</style>` would otherwise close the wrapping tag early. There
|
|
9
|
+
* is no other sanitization: this is merchant-authored CSS rendered on the
|
|
10
|
+
* merchant's own storefront (Shopify parity). */
|
|
11
|
+
export declare function sanitizeCustomCss(css: unknown): string | null;
|
|
12
|
+
/** Wrap sanitized CSS in `selector { … }` via native CSS nesting so a
|
|
13
|
+
* merchant can target the wrapper itself with `&` (the `display: contents`
|
|
14
|
+
* section/block wrapper the renderer emits) or descend into it. Returns null
|
|
15
|
+
* when there's nothing to render. */
|
|
16
|
+
export declare function scopeCustomCss(selector: string, css: unknown): string | null;
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/custom-css.ts
|
|
2
|
+
function escapeAttrValue(value) {
|
|
3
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
4
|
+
}
|
|
5
|
+
/** Guard + trim a merchant-authored CSS value from settings/instance data.
|
|
6
|
+
* Returns null for anything that isn't a non-empty string once trimmed, so
|
|
7
|
+
* callers can treat null uniformly as "nothing to render". The only
|
|
8
|
+
* transform applied is neutralizing a `</style>` (or `<\/script>`) breakout —
|
|
9
|
+
* `dangerouslySetInnerHTML` does not escape `</`, so a merchant value
|
|
10
|
+
* containing `</style>` would otherwise close the wrapping tag early. There
|
|
11
|
+
* is no other sanitization: this is merchant-authored CSS rendered on the
|
|
12
|
+
* merchant's own storefront (Shopify parity). */
|
|
13
|
+
function sanitizeCustomCss(css) {
|
|
14
|
+
if (typeof css !== "string") return null;
|
|
15
|
+
const trimmed = css.trim();
|
|
16
|
+
if (!trimmed) return null;
|
|
17
|
+
return trimmed.replace(/<\//g, "<\\/");
|
|
18
|
+
}
|
|
19
|
+
/** Wrap sanitized CSS in `selector { … }` via native CSS nesting so a
|
|
20
|
+
* merchant can target the wrapper itself with `&` (the `display: contents`
|
|
21
|
+
* section/block wrapper the renderer emits) or descend into it. Returns null
|
|
22
|
+
* when there's nothing to render. */
|
|
23
|
+
function scopeCustomCss(selector, css) {
|
|
24
|
+
const sanitized = sanitizeCustomCss(css);
|
|
25
|
+
if (sanitized === null) return null;
|
|
26
|
+
return `${selector} { ${sanitized} }`;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { escapeAttrValue, sanitizeCustomCss, scopeCustomCss };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { EditorCommand } from "@shop-on/theme-contract";
|
|
2
|
+
//#region src/editor-preview-bridge-utils.d.ts
|
|
3
|
+
export interface EditorSelectedItem {
|
|
4
|
+
type: "section" | "block";
|
|
5
|
+
sectionId: string;
|
|
6
|
+
blockId?: string;
|
|
7
|
+
/** Container path: ids of the ancestor blocks between the section and
|
|
8
|
+
* `blockId`, section excluded, `blockId` excluded. Absent/`[]` = the block
|
|
9
|
+
* sits directly in the section. */
|
|
10
|
+
blockPath?: string[];
|
|
11
|
+
/** Legacy mirror of the last `blockPath` hop. Emitted so a rebuilt theme
|
|
12
|
+
* still nests one level when it talks to an admin build that predates block
|
|
13
|
+
* paths; never read by the new admin (it parses `blockPath`). */
|
|
14
|
+
parentBlockId?: string;
|
|
15
|
+
groupType?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function sameItem(a: EditorSelectedItem | null, b: EditorSelectedItem | null): boolean;
|
|
18
|
+
export declare function parseSelected(raw: unknown): EditorSelectedItem | null;
|
|
19
|
+
export declare function readClickTarget(target: EventTarget | null): EditorSelectedItem | null;
|
|
20
|
+
/**
|
|
21
|
+
* Whether a key the editor binds should go to the editor from inside the
|
|
22
|
+
* preview, rather than stay with the storefront. The storefront keeps every
|
|
23
|
+
* key typed into its own fields (the search box, a quantity input), a text
|
|
24
|
+
* copy while its text is selected, and Esc inside its own dialogs — a cart
|
|
25
|
+
* drawer closes on Esc, and deselecting the section under it too would be a
|
|
26
|
+
* surprise.
|
|
27
|
+
*/
|
|
28
|
+
export declare function forwardsFromPreview(command: EditorCommand, target: EventTarget | null, ctx: {
|
|
29
|
+
textSelected: boolean;
|
|
30
|
+
selectionShown: boolean;
|
|
31
|
+
}): boolean;
|
|
32
|
+
//#endregion
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { blockPathsEqual, normalizeBlockPath } from "@shop-on/theme-contract";
|
|
2
|
+
//#region src/editor-preview-bridge-utils.ts
|
|
3
|
+
function sameItem(a, b) {
|
|
4
|
+
if (a === b) return true;
|
|
5
|
+
if (!a || !b) return false;
|
|
6
|
+
return a.type === b.type && a.sectionId === b.sectionId && (a.blockId ?? null) === (b.blockId ?? null) && blockPathsEqual(a.blockPath, b.blockPath) && (a.groupType ?? null) === (b.groupType ?? null);
|
|
7
|
+
}
|
|
8
|
+
function parseSelected(raw) {
|
|
9
|
+
if (!raw || typeof raw !== "object") return null;
|
|
10
|
+
const v = raw;
|
|
11
|
+
if (v.type !== "section" && v.type !== "block") return null;
|
|
12
|
+
if (typeof v.sectionId !== "string" || v.sectionId === "") return null;
|
|
13
|
+
return {
|
|
14
|
+
type: v.type,
|
|
15
|
+
sectionId: v.sectionId,
|
|
16
|
+
blockId: typeof v.blockId === "string" ? v.blockId : void 0,
|
|
17
|
+
blockPath: normalizeBlockPath(v.blockPath, v.parentBlockId),
|
|
18
|
+
groupType: typeof v.groupType === "string" ? v.groupType : void 0
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function readClickTarget(target) {
|
|
22
|
+
if (!(target instanceof Element)) return null;
|
|
23
|
+
const sectionEl = target.closest("[data-editor-section-id]") ?? target.closest("[data-section-id]");
|
|
24
|
+
if (!sectionEl) return null;
|
|
25
|
+
const sectionId = sectionEl.getAttribute("data-editor-section-id") ?? sectionEl.getAttribute("data-section-id") ?? "";
|
|
26
|
+
if (!sectionId) return null;
|
|
27
|
+
const groupType = target.closest("[data-editor-section-id]")?.getAttribute("data-editor-group-type") || void 0;
|
|
28
|
+
const blockEl = target.closest("[data-editor-block-id]") ?? target.closest("[data-block-id]");
|
|
29
|
+
if (blockEl && sectionEl.contains(blockEl)) {
|
|
30
|
+
const blockId = blockEl.getAttribute("data-editor-block-id") ?? blockEl.getAttribute("data-block-id") ?? "";
|
|
31
|
+
if (!blockId) return null;
|
|
32
|
+
const blockPath = readAncestorBlockPath(blockEl, sectionEl);
|
|
33
|
+
return {
|
|
34
|
+
type: "block",
|
|
35
|
+
sectionId,
|
|
36
|
+
blockId,
|
|
37
|
+
blockPath,
|
|
38
|
+
parentBlockId: blockPath[blockPath.length - 1],
|
|
39
|
+
groupType
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
type: "section",
|
|
44
|
+
sectionId,
|
|
45
|
+
groupType
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function readAncestorBlockPath(blockEl, sectionEl) {
|
|
49
|
+
const path = [];
|
|
50
|
+
let node = blockEl.parentElement;
|
|
51
|
+
while (node && node !== sectionEl && sectionEl.contains(node)) {
|
|
52
|
+
const id = node.getAttribute("data-editor-block-id");
|
|
53
|
+
if (id) path.unshift(id);
|
|
54
|
+
node = node.parentElement;
|
|
55
|
+
}
|
|
56
|
+
return path;
|
|
57
|
+
}
|
|
58
|
+
const SELECTION_COMMANDS = /* @__PURE__ */ new Set([
|
|
59
|
+
"deselect",
|
|
60
|
+
"copy",
|
|
61
|
+
"paste",
|
|
62
|
+
"duplicate",
|
|
63
|
+
"remove",
|
|
64
|
+
"moveUp",
|
|
65
|
+
"moveDown"
|
|
66
|
+
]);
|
|
67
|
+
const STOREFRONT_FIELDS = "input, textarea, select, [role=\"textbox\"], [role=\"combobox\"], [role=\"slider\"], [role=\"spinbutton\"]";
|
|
68
|
+
/**
|
|
69
|
+
* Whether a key the editor binds should go to the editor from inside the
|
|
70
|
+
* preview, rather than stay with the storefront. The storefront keeps every
|
|
71
|
+
* key typed into its own fields (the search box, a quantity input), a text
|
|
72
|
+
* copy while its text is selected, and Esc inside its own dialogs — a cart
|
|
73
|
+
* drawer closes on Esc, and deselecting the section under it too would be a
|
|
74
|
+
* surprise.
|
|
75
|
+
*/
|
|
76
|
+
function forwardsFromPreview(command, target, ctx) {
|
|
77
|
+
if (!ctx.selectionShown && SELECTION_COMMANDS.has(command)) return false;
|
|
78
|
+
if (target instanceof HTMLElement) {
|
|
79
|
+
if (target.isContentEditable || target.closest(STOREFRONT_FIELDS)) return false;
|
|
80
|
+
if (command === "deselect" && target.closest("dialog, [role=\"dialog\"]")) return false;
|
|
81
|
+
}
|
|
82
|
+
return !(command === "copy" && ctx.textSelected);
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
export { forwardsFromPreview, parseSelected, readClickTarget, sameItem };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { EditorSelectedItem } from "./editor-preview-bridge-utils.js";
|
|
2
|
+
//#region src/editor-preview-bridge.d.ts
|
|
3
|
+
interface Props {
|
|
4
|
+
preview: string | undefined;
|
|
5
|
+
}
|
|
6
|
+
export declare function EditorPreviewBridge({ preview }: Props): import("react").JSX.Element | null;
|
|
7
|
+
//#endregion
|
|
8
|
+
export type { EditorSelectedItem };
|