@occluze/tether 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/README.md +9 -7
- package/dist/esm/astro.js +1 -1
- package/dist/esm/chunks/account-WAWNASZU.js +1 -0
- package/dist/esm/chunks/analytics-22NMIVBV.js +1 -0
- package/dist/esm/chunks/chunk-4UR45XTC.js +1 -0
- package/dist/esm/chunks/chunk-6R752OYZ.js +1 -0
- package/dist/esm/chunks/chunk-BGIKQY6H.js +1 -0
- package/dist/esm/chunks/chunk-CISGFYOF.js +1 -0
- package/dist/esm/chunks/chunk-KARKXC6G.js +1 -0
- package/dist/esm/chunks/chunk-YXCXIPQV.js +4 -0
- package/dist/esm/chunks/{chunk-RBVRRQIU.js → chunk-YZMBXS6Y.js} +1 -1
- package/dist/esm/chunks/chunk-ZVUPVSLF.js +1 -0
- package/dist/esm/chunks/diagnose-6ZSK5DGE.js +1 -0
- package/dist/esm/chunks/extras-QDCSW6VU.js +1 -0
- package/dist/esm/chunks/markets-ui-HJCVLCKJ.js +1 -0
- package/dist/esm/chunks/search-LN2H6SXS.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +1 -1
- package/dist/tether.account.js +374 -0
- package/dist/tether.account.min.js +1 -0
- package/dist/tether.analytics.js +259 -0
- package/dist/tether.analytics.min.js +1 -0
- package/dist/tether.diagnose.js +11 -2
- package/dist/tether.diagnose.min.js +1 -1
- package/dist/tether.extras.js +87 -0
- package/dist/tether.extras.min.js +1 -0
- package/dist/tether.js +268 -75
- package/dist/tether.markets.js +3 -3
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +3 -3
- package/dist/tether.search.js +176 -0
- package/dist/tether.search.min.js +1 -0
- package/package.json +5 -2
- package/src/account.js +313 -0
- package/src/analytics.js +232 -0
- package/src/astro.js +4 -1
- package/src/cart.js +74 -11
- package/src/client.js +8 -3
- package/src/collection.js +15 -10
- package/src/config.js +3 -0
- package/src/diagnose.js +11 -2
- package/src/dom.js +7 -1
- package/src/extras.js +63 -0
- package/src/fragments.js +1 -1
- package/src/index.js +103 -41
- package/src/initial.js +13 -4
- package/src/lazy.js +9 -1
- package/src/loader.js +4 -1
- package/src/markets-ui.js +2 -1
- package/src/mounting.js +27 -2
- package/src/parts/account.js +3 -0
- package/src/parts/analytics.js +3 -0
- package/src/parts/extras.js +3 -0
- package/src/parts/search.js +3 -0
- package/src/product.js +19 -5
- package/src/search.js +153 -0
- package/src/server.js +78 -6
- package/src/status.js +2 -2
- package/types/index.d.ts +56 -3
- package/types/server.d.ts +10 -2
- package/dist/esm/chunks/chunk-HHMOPDZG.js +0 -1
- package/dist/esm/chunks/chunk-HXKV6QGB.js +0 -4
- package/dist/esm/chunks/chunk-RNZMTQTX.js +0 -1
- package/dist/esm/chunks/diagnose-J667GHGS.js +0 -1
- package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +0 -1
package/src/analytics.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics, as an optional part (loaded when config.analytics is set).
|
|
3
|
+
*
|
|
4
|
+
* analytics: {
|
|
5
|
+
* dataLayer: true, // GA4 / Google Tag Manager ecommerce events (window.dataLayer)
|
|
6
|
+
* shopify: true, // Shopify's own analytics (experimental, see docs/analytics.md)
|
|
7
|
+
* onEvent(name, data) {}, // any other tool: Meta, TikTok, Plausible, …
|
|
8
|
+
* }
|
|
9
|
+
*
|
|
10
|
+
* Events: view_item (the page's product), view_item_list (lists), add_to_cart / remove_from_cart
|
|
11
|
+
* (from the cart's changes, so quick +/- and JS calls count too), view_cart (cart opened),
|
|
12
|
+
* begin_checkout, search (search as you type). Purchases happen on Shopify's checkout: use
|
|
13
|
+
* Shopify's Google & YouTube app or a checkout pixel for those.
|
|
14
|
+
*
|
|
15
|
+
* Shopify analytics: Tether sets up the same event bus Hydrogen uses (window.Shopify.analytics) and
|
|
16
|
+
* loads Shopify's consent script and shopify.js, which build and send the events. Nothing is sent
|
|
17
|
+
* before the Customer Privacy API allows analytics.
|
|
18
|
+
*/
|
|
19
|
+
import { cfg, log, reportError } from './config.js';
|
|
20
|
+
import { gql } from './client.js';
|
|
21
|
+
import { sel } from './dom.js';
|
|
22
|
+
import { Cart } from './cart.js';
|
|
23
|
+
|
|
24
|
+
const num = (m) => (m ? parseFloat(m.amount) : 0);
|
|
25
|
+
const id = (gid) => String(gid || '').split('/').pop();
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------- shapes
|
|
28
|
+
|
|
29
|
+
function itemFromVariant(p, v, quantity = 1, index) {
|
|
30
|
+
return {
|
|
31
|
+
item_id: v?.sku || id(v?.id) || id(p.id),
|
|
32
|
+
item_name: p.title,
|
|
33
|
+
item_variant: v?.title === 'Default Title' ? undefined : v?.title,
|
|
34
|
+
item_brand: p.vendor || undefined,
|
|
35
|
+
item_category: p.productType || undefined,
|
|
36
|
+
price: num(v?.price || p.priceRange?.minVariantPrice),
|
|
37
|
+
quantity,
|
|
38
|
+
...(index != null ? { index } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function itemFromLine(l, quantity = l.quantity) {
|
|
43
|
+
const m = l.merchandise || {};
|
|
44
|
+
return itemFromVariant({ ...(m.product || {}), title: m.product?.title }, m, quantity);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const value = (items) => Math.round(items.reduce((s, i) => s + (i.price || 0) * (i.quantity || 1), 0) * 100) / 100;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- GA4 / GTM
|
|
50
|
+
|
|
51
|
+
function ga(event, ecommerce) {
|
|
52
|
+
const name = typeof cfg.analytics.dataLayer === 'string' ? cfg.analytics.dataLayer : 'dataLayer';
|
|
53
|
+
const dl = (window[name] = window[name] || []);
|
|
54
|
+
if (ecommerce) dl.push({ ecommerce: null }); // Google: clear the previous ecommerce object first
|
|
55
|
+
dl.push(ecommerce ? { event, ecommerce } : { event });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------- Shopify analytics (Hydrogen's bus)
|
|
59
|
+
|
|
60
|
+
let bus = null;
|
|
61
|
+
|
|
62
|
+
function shopifyBus(shopId) {
|
|
63
|
+
const destinations = [];
|
|
64
|
+
const buffer = [];
|
|
65
|
+
const privacy = () => window.Shopify?.customerPrivacy;
|
|
66
|
+
const canTrack = () => {
|
|
67
|
+
try {
|
|
68
|
+
const p = privacy();
|
|
69
|
+
return p?.consentStatus === 'loaded' && p.currentVisitorConsent?.()?.analytics !== 'no' && Boolean(p.analyticsProcessingAllowed?.());
|
|
70
|
+
} catch { return false; }
|
|
71
|
+
};
|
|
72
|
+
const config = { shop: { shopId, channel: 'headless' }, consent: { mode: cfg.analytics.shopify?.consent || 'no-banner' } };
|
|
73
|
+
const deliver = () => {
|
|
74
|
+
if (!canTrack()) return;
|
|
75
|
+
destinations.forEach((d) => {
|
|
76
|
+
while (d.next < buffer.length) {
|
|
77
|
+
const { event, payload } = buffer[d.next++];
|
|
78
|
+
(d.subs.get(event) || []).forEach((cb) => { try { cb(payload, d.context); } catch (err) { reportError(err, `analytics ${d.name}`); } });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
const api = {
|
|
83
|
+
getConfig: () => config,
|
|
84
|
+
publish(event, payload = {}) {
|
|
85
|
+
buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
|
|
86
|
+
if (buffer.length > 500) { buffer.shift(); destinations.forEach((d) => { d.next = Math.max(0, d.next - 1); }); }
|
|
87
|
+
deliver();
|
|
88
|
+
},
|
|
89
|
+
addDestination(dest) {
|
|
90
|
+
const tag = `hydrogen:${dest.name}`;
|
|
91
|
+
const d = {
|
|
92
|
+
name: dest.name, next: 0, subs: new Map(),
|
|
93
|
+
context: {
|
|
94
|
+
getTrackingValues: () => {
|
|
95
|
+
const i = canTrack() ? privacy()?.__internal : null;
|
|
96
|
+
return { uniqueToken: i?.uniqueToken?.({ generateFallback: true, tag }) ?? '', visitToken: i?.visitToken?.({ generateFallback: true, tag }) ?? '' };
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
const subscribe = (event, cb) => {
|
|
101
|
+
if (!d.subs.has(event)) d.subs.set(event, new Set());
|
|
102
|
+
d.subs.get(event).add(cb);
|
|
103
|
+
return () => d.subs.get(event)?.delete(cb);
|
|
104
|
+
};
|
|
105
|
+
Promise.resolve(dest.setup?.({ subscribe, getConfig: api.getConfig })).then(() => { destinations.push(d); deliver(); })
|
|
106
|
+
.catch((err) => reportError(err, `analytics ${dest.name}`));
|
|
107
|
+
return () => { const i = destinations.indexOf(d); if (i >= 0) destinations.splice(i, 1); };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
document.addEventListener('consentTrackingApiLoaded', deliver);
|
|
111
|
+
document.addEventListener('visitorConsentCollected', deliver);
|
|
112
|
+
return api;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function loadScript(src) {
|
|
116
|
+
const s = document.createElement('script');
|
|
117
|
+
s.src = src;
|
|
118
|
+
s.async = true;
|
|
119
|
+
document.head.appendChild(s);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function startShopify() {
|
|
123
|
+
if (window.Shopify?.analytics) { bus = window.Shopify.analytics; return; } // already set up (e.g. by the theme)
|
|
124
|
+
const data = await gql('query TetherShopId { shop { id } }', {}, { context: false });
|
|
125
|
+
const country = String(cfg.country || 'US').toUpperCase();
|
|
126
|
+
const S = (window.Shopify = window.Shopify || {});
|
|
127
|
+
Object.assign(S, {
|
|
128
|
+
country,
|
|
129
|
+
locale: (cfg.locale || 'en').split('-')[0].toLowerCase(), // required by Shopify's script
|
|
130
|
+
shop: cfg.domain,
|
|
131
|
+
routes: { root: '/', match: () => ({ pageTemplateName: mainProduct() ? 'product' : 'page' }), ...(S.routes || {}) },
|
|
132
|
+
});
|
|
133
|
+
const proxy = cfg.analytics.shopify?.proxy; // same-origin Storefront API path, for exact visitor counts
|
|
134
|
+
S.customerPrivacy = {
|
|
135
|
+
...(S.customerPrivacy || {}),
|
|
136
|
+
config: proxy
|
|
137
|
+
? { isHeadless: true, asyncConsent: true, asyncVisitorState: true, consentDomain: window.location.host }
|
|
138
|
+
: { isHeadless: true, storefrontAccessToken: cfg.token, checkoutRootDomain: cfg.domain },
|
|
139
|
+
};
|
|
140
|
+
bus = shopifyBus(data.shop.id);
|
|
141
|
+
S.analytics = bus;
|
|
142
|
+
loadScript(cfg.analytics.shopify?.consent === 'default-banner'
|
|
143
|
+
? 'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js'
|
|
144
|
+
: 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js');
|
|
145
|
+
loadScript('https://cdn.shopify.com/storefront/analytics/shopify.js');
|
|
146
|
+
log('shopify analytics ready', data.shop.id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const productPayload = (p, v, quantity = 1) => ({
|
|
150
|
+
id: p.id, title: p.title, price: v.price, vendor: p.vendor || '', variantId: v.id, variantTitle: v.title,
|
|
151
|
+
quantity, sku: v.sku || undefined, productType: p.productType || undefined,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function analyticsLine(l) {
|
|
155
|
+
const m = l.merchandise || {};
|
|
156
|
+
return { id: l.id, quantity: l.quantity, merchandise: { id: m.id, title: m.title, price: m.price, sku: m.sku, product: { id: m.product?.id, title: m.product?.title, vendor: m.product?.vendor || '', handle: m.product?.handle } } };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------- what happened on the page
|
|
160
|
+
|
|
161
|
+
function mainProduct() {
|
|
162
|
+
const el = document.querySelector(sel('product'));
|
|
163
|
+
return el && !el.closest(`${sel('collection')},${sel('products')},${sel('recommendations')}`) ? window.Tether?.getScope(el) : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const send = (name, data, shopifyEvent, shopifyPayload) => {
|
|
167
|
+
if (cfg.analytics.dataLayer) ga(name, data);
|
|
168
|
+
if (bus && shopifyEvent) bus.publish(shopifyEvent, shopifyPayload);
|
|
169
|
+
try { cfg.analytics.onEvent?.(name, data); } catch (err) { reportError(err, 'analytics onEvent'); }
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
let lastLines = null; // variant id -> line, to turn cart changes into add/remove events
|
|
173
|
+
let viewed = new WeakSet();
|
|
174
|
+
|
|
175
|
+
function handle(e) {
|
|
176
|
+
const name = e.type.slice(cfg.prefix.length + 1);
|
|
177
|
+
const d = e.detail || {};
|
|
178
|
+
if (name === 'product-ready') {
|
|
179
|
+
const scope = d.scope;
|
|
180
|
+
if (!scope || viewed.has(scope.root) || mainProduct() !== scope) return;
|
|
181
|
+
viewed.add(scope.root);
|
|
182
|
+
const v = scope.variant || d.product.variants.nodes[0];
|
|
183
|
+
const items = [itemFromVariant(d.product, v)];
|
|
184
|
+
send('view_item', { currency: v?.price?.currencyCode, value: value(items), items }, 'product_viewed', { products: [productPayload(d.product, v)] });
|
|
185
|
+
} else if (name === 'list-ready') {
|
|
186
|
+
const items = (d.products || []).slice(0, 200).map((p, i) => itemFromVariant(p, p.variants?.nodes?.[0], 1, i));
|
|
187
|
+
const list = { item_list_id: d.collection?.handle || 'products', item_list_name: d.collection?.title || 'Products' };
|
|
188
|
+
send('view_item_list', { ...list, items }, d.collection ? 'collection_viewed' : null, d.collection ? { collection: { id: d.collection.id, handle: d.collection.handle } } : null);
|
|
189
|
+
} else if (name === 'cart-updated') {
|
|
190
|
+
const cart = d.cart;
|
|
191
|
+
const now = new Map((cart?.lines?.nodes || []).map((l) => [l.merchandise?.id, l]));
|
|
192
|
+
if (lastLines && d.reason !== 'load' && d.reason !== 'pending') {
|
|
193
|
+
const currency = cart?.cost?.totalAmount?.currencyCode;
|
|
194
|
+
now.forEach((l, k) => {
|
|
195
|
+
const diff = l.quantity - (lastLines.get(k)?.quantity || 0);
|
|
196
|
+
if (diff > 0) {
|
|
197
|
+
const items = [itemFromLine(l, diff)];
|
|
198
|
+
send('add_to_cart', { currency, value: value(items), items }, 'product_added_to_cart', {
|
|
199
|
+
cart, prevCart: lastCart, currentLine: analyticsLine(l), prevLine: lastLines.get(k) ? analyticsLine(lastLines.get(k)) : undefined,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
lastLines.forEach((l, k) => {
|
|
204
|
+
const diff = l.quantity - (now.get(k)?.quantity || 0);
|
|
205
|
+
if (diff > 0) { const items = [itemFromLine(l, diff)]; send('remove_from_cart', { currency, value: value(items), items }); }
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
lastLines = now;
|
|
209
|
+
lastCart = cart;
|
|
210
|
+
} else if (name === 'cart-toggled' && d.open) {
|
|
211
|
+
const items = Cart.lines.map((l) => itemFromLine(l));
|
|
212
|
+
send('view_cart', { currency: Cart.state?.cost?.totalAmount?.currencyCode, value: value(items), items });
|
|
213
|
+
} else if (name === 'checkout') {
|
|
214
|
+
const items = (d.cart?.lines?.nodes || []).map((l) => itemFromLine(l));
|
|
215
|
+
const coupon = (d.cart?.discountCodes || []).filter((c) => c.applicable).map((c) => c.code).join(',') || undefined;
|
|
216
|
+
send('begin_checkout', { currency: d.cart?.cost?.totalAmount?.currencyCode, value: value(items), coupon, items });
|
|
217
|
+
} else if (name === 'search' && d.term) {
|
|
218
|
+
send('search', { search_term: d.term }, 'search_viewed', { searchTerm: d.term });
|
|
219
|
+
} else if (name === 'page') {
|
|
220
|
+
viewed = new WeakSet();
|
|
221
|
+
if (bus) bus.publish('page_viewed', {});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
let lastCart = null;
|
|
225
|
+
|
|
226
|
+
/** Called by the core with the events it buffered while this part loaded. */
|
|
227
|
+
export async function startAnalytics(queue) {
|
|
228
|
+
if (cfg.analytics.shopify) await startShopify().catch((err) => reportError(err, 'shopify analytics'));
|
|
229
|
+
if (bus) bus.publish('page_viewed', {});
|
|
230
|
+
queue.splice(0).forEach(handle);
|
|
231
|
+
queue.push = (e) => { handle(e); return 0; }; // from now on, handle events as they come
|
|
232
|
+
}
|
package/src/astro.js
CHANGED
|
@@ -65,7 +65,10 @@ export default function tether(options = {}) {
|
|
|
65
65
|
injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
|
|
66
66
|
injectScript('page', [
|
|
67
67
|
"import Tether from '@occluze/tether';",
|
|
68
|
-
|
|
68
|
+
// As the base config (like window.TETHER_CONFIG), not as overrides: a visitor's saved market
|
|
69
|
+
// (country, money format) then wins over the defaults, as on a plain script-tag site.
|
|
70
|
+
`window.TETHER_CONFIG = { ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) };`,
|
|
71
|
+
'Tether.init().then(() => Tether.mount(document));',
|
|
69
72
|
].join('\n'));
|
|
70
73
|
},
|
|
71
74
|
},
|
package/src/cart.js
CHANGED
|
@@ -29,7 +29,7 @@ export function toLineInput(input, quantity = 1, opts = {}) {
|
|
|
29
29
|
|
|
30
30
|
function mutation(name, args, inner) {
|
|
31
31
|
return `mutation Tether_${name}(${args}) {
|
|
32
|
-
${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message } }
|
|
32
|
+
${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message target } }
|
|
33
33
|
}
|
|
34
34
|
${cartFragment()}`;
|
|
35
35
|
}
|
|
@@ -47,7 +47,8 @@ function ariaState(open) {
|
|
|
47
47
|
if (!d.hasAttribute('role')) d.setAttribute('role', 'dialog');
|
|
48
48
|
if (!d.hasAttribute('aria-label') && !d.hasAttribute('aria-labelledby')) d.setAttribute('aria-label', get(d, 'cart-label') || 'Cart');
|
|
49
49
|
d.setAttribute('aria-modal', String(open));
|
|
50
|
-
|
|
50
|
+
// Closed: hidden from screen readers and out of the Tab order (inert), like a closed dialog.
|
|
51
|
+
if (!d.classList.contains(cfg.cartDrawerOpenClass)) { d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; }
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -69,6 +70,10 @@ export const Cart = {
|
|
|
69
70
|
get count() { return this.state?.totalQuantity || 0; },
|
|
70
71
|
|
|
71
72
|
lastError: null,
|
|
73
|
+
/** Set by the account part while a customer is logged in (Customer Account API token). */
|
|
74
|
+
customerAccessToken: null,
|
|
75
|
+
/** line id -> { qty, done }: what quick +/- clicks asked for, shown until Shopify has answered. */
|
|
76
|
+
pendingQty: new Map(),
|
|
72
77
|
/** Shopify's notes on the last change, e.g. less stock than asked: [{ code, message }]. */
|
|
73
78
|
warnings: [],
|
|
74
79
|
/** true once the saved cart (or the absence of one) is known. */
|
|
@@ -76,7 +81,9 @@ export const Cart = {
|
|
|
76
81
|
|
|
77
82
|
_take(payload, name) {
|
|
78
83
|
const p = unwrap(payload, name);
|
|
79
|
-
|
|
84
|
+
// Shopify can repeat a warning per line or code: each message once.
|
|
85
|
+
this.warnings = (p.warnings || []).filter((w, i, all) => all.findIndex((x) => x.message === w.message) === i)
|
|
86
|
+
.map((w) => ({ ...w, message: (typeof cfg.warningText === 'function' && cfg.warningText(w)) || w.message }));
|
|
80
87
|
if (this.warnings.length) emit('cart-warning', { warnings: this.warnings });
|
|
81
88
|
return p.cart;
|
|
82
89
|
},
|
|
@@ -99,6 +106,8 @@ export const Cart = {
|
|
|
99
106
|
_commit(cart, reason) {
|
|
100
107
|
this.state = cart;
|
|
101
108
|
if (cart?.id) this._setId(cart.id);
|
|
109
|
+
// Other tabs reload the cart (storage event); the count also shows at once on the next page.
|
|
110
|
+
try { localStorage.setItem(`${cfg.storageKey}:seen`, `${Date.now()}|${cart?.totalQuantity || 0}`); } catch { /* private mode */ }
|
|
102
111
|
this.render();
|
|
103
112
|
emit('cart-updated', { cart, reason });
|
|
104
113
|
return cart;
|
|
@@ -107,6 +116,13 @@ export const Cart = {
|
|
|
107
116
|
async init() {
|
|
108
117
|
const id = this.id();
|
|
109
118
|
if (!id) { this.state = null; this.loaded = true; this.render(); emit('cart-updated', { cart: null, reason: 'load' }); return null; }
|
|
119
|
+
// Until the saved cart has loaded, show its last known count instead of the page's 0.
|
|
120
|
+
if (!this.loaded) {
|
|
121
|
+
try {
|
|
122
|
+
const n = (localStorage.getItem(`${cfg.storageKey}:seen`) || '').split('|')[1];
|
|
123
|
+
if (n) qsa(document, sel('cart-count')).forEach((el) => { if (/^\s*0?\s*$/.test(el.textContent)) el.textContent = n; });
|
|
124
|
+
} catch { /* private mode */ }
|
|
125
|
+
}
|
|
110
126
|
try {
|
|
111
127
|
const data = await gql(`query TetherCart($id: ID!) { cart(id: $id) { ...TetherCart } }\n${cartFragment()}`, { id });
|
|
112
128
|
if (!data.cart) { this._setId(null); this.state = null; } else this.state = data.cart;
|
|
@@ -124,7 +140,10 @@ export const Cart = {
|
|
|
124
140
|
},
|
|
125
141
|
|
|
126
142
|
async _create(input = {}) {
|
|
127
|
-
const buyerIdentity = cfg.country
|
|
143
|
+
const buyerIdentity = cfg.country || this.customerAccessToken ? {
|
|
144
|
+
...(cfg.country ? { countryCode: String(cfg.country).toUpperCase() } : {}),
|
|
145
|
+
...(this.customerAccessToken ? { customerAccessToken: this.customerAccessToken } : {}),
|
|
146
|
+
} : undefined;
|
|
128
147
|
const data = await gql(
|
|
129
148
|
mutation('cartCreate', '$input: CartInput!', 'input: $input'),
|
|
130
149
|
{ input: { buyerIdentity, ...input } },
|
|
@@ -186,6 +205,26 @@ export const Cart = {
|
|
|
186
205
|
));
|
|
187
206
|
},
|
|
188
207
|
|
|
208
|
+
/**
|
|
209
|
+
* +1 / -1 for a line. Quick clicks add up: each builds on the quantity the last click asked for,
|
|
210
|
+
* which shows at once (tether-line="quantity", Cart.lines) until Shopify has answered.
|
|
211
|
+
*/
|
|
212
|
+
changeQuantity(lineId, delta) {
|
|
213
|
+
const current = this.lines.find((l) => l.id === lineId);
|
|
214
|
+
if (!current) return Promise.resolve(this.state);
|
|
215
|
+
const target = Math.max(0, (this.pendingQty.get(lineId)?.qty ?? current.quantity) + delta);
|
|
216
|
+
const job = { qty: target, done: false };
|
|
217
|
+
this.pendingQty.set(lineId, job);
|
|
218
|
+
this.render();
|
|
219
|
+
emit('cart-updated', { cart: this.state, reason: 'pending' });
|
|
220
|
+
return this.updateLine(lineId, target).finally(() => {
|
|
221
|
+
if (this.pendingQty.get(lineId) === job) { this.pendingQty.delete(lineId); this.render(); emit('cart-updated', { cart: this.state, reason: 'settled' }); }
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
/** Quantity to show for a line: the one asked for while an update is on its way. */
|
|
226
|
+
quantityOf(line) { return this.pendingQty.get(line.id)?.qty ?? line.quantity; },
|
|
227
|
+
|
|
189
228
|
removeLine(lineIds) {
|
|
190
229
|
const ids = Array.isArray(lineIds) ? lineIds : [lineIds];
|
|
191
230
|
return this._queue(async () => this._commit(
|
|
@@ -227,9 +266,17 @@ export const Cart = {
|
|
|
227
266
|
));
|
|
228
267
|
},
|
|
229
268
|
|
|
230
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Adds a code. A code Shopify doesn't accept isn't kept in the cart (it would warn on every change):
|
|
271
|
+
* it's removed again and the promise rejects with code INVALID_DISCOUNT.
|
|
272
|
+
*/
|
|
273
|
+
async applyDiscount(code) {
|
|
231
274
|
const current = (this.state?.discountCodes || []).map((d) => d.code);
|
|
232
|
-
|
|
275
|
+
const cart = await this.setDiscountCodes([...new Set([...current, code])]);
|
|
276
|
+
const hit = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === String(code).toLowerCase());
|
|
277
|
+
if (hit?.applicable) return cart;
|
|
278
|
+
await this.removeDiscount(code).catch(() => {});
|
|
279
|
+
throw new TetherError(`Discount code not valid: ${code}`, { code: 'INVALID_DISCOUNT' });
|
|
233
280
|
},
|
|
234
281
|
|
|
235
282
|
removeDiscount(code) {
|
|
@@ -263,7 +310,8 @@ export const Cart = {
|
|
|
263
310
|
|
|
264
311
|
try {
|
|
265
312
|
const data = await gql('query TetherCheckout($id: ID!) { cart(id: $id) { checkoutUrl } }', { id });
|
|
266
|
-
|
|
313
|
+
let url = (this.beforeCheckout && await this.beforeCheckout().catch(() => null)) || data.cart?.checkoutUrl;
|
|
314
|
+
if (url && this.customerAccessToken) url += `${url.includes('?') ? '&' : '?'}logged_in=true`;
|
|
267
315
|
if (!url) throw new TetherError('No checkoutUrl (cart empty or expired)');
|
|
268
316
|
emit('checkout', { url, cart: this.state });
|
|
269
317
|
if (tab && !tab.closed) tab.location.href = url;
|
|
@@ -280,12 +328,18 @@ export const Cart = {
|
|
|
280
328
|
|
|
281
329
|
drawers() { return qsa(document, sel('cart-drawer')); },
|
|
282
330
|
|
|
283
|
-
|
|
331
|
+
/** Without a tether-cart-drawer (a React/Svelte/Vue cart), open/close still work: as state for subscribe. */
|
|
332
|
+
_open: false,
|
|
333
|
+
isOpen() {
|
|
334
|
+
const ds = this.drawers();
|
|
335
|
+
return ds.length ? ds.some((d) => d.classList.contains(cfg.cartDrawerOpenClass)) : this._open;
|
|
336
|
+
},
|
|
284
337
|
|
|
285
338
|
open() {
|
|
286
339
|
if (this.isOpen()) return;
|
|
340
|
+
this._open = true;
|
|
287
341
|
const ds = this.drawers().filter((d) => get(d, 'cart-drawer') !== 'manual');
|
|
288
|
-
ds.forEach((d) => { d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
|
|
342
|
+
ds.forEach((d) => { d.inert = false; d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
|
|
289
343
|
ariaState(true);
|
|
290
344
|
if (cfg.cartFocus !== false && ds[0]) {
|
|
291
345
|
// Move focus into the cart (after the open transition has made it visible).
|
|
@@ -302,9 +356,10 @@ export const Cart = {
|
|
|
302
356
|
|
|
303
357
|
close() {
|
|
304
358
|
if (!this.isOpen()) return;
|
|
359
|
+
this._open = false;
|
|
305
360
|
const ds = this.drawers();
|
|
306
361
|
const hadFocus = ds.some((d) => d.contains(document.activeElement));
|
|
307
|
-
ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); });
|
|
362
|
+
ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; });
|
|
308
363
|
ariaState(false);
|
|
309
364
|
if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
|
310
365
|
returnFocus = null;
|
|
@@ -397,7 +452,7 @@ function lineValue(line, key) {
|
|
|
397
452
|
case 'compare-at-price': return formatMoney(line.cost?.compareAtAmountPerQuantity || m.compareAtPrice);
|
|
398
453
|
case 'line-total': return formatMoney(line.cost?.totalAmount);
|
|
399
454
|
case 'line-subtotal': return formatMoney(line.cost?.subtotalAmount);
|
|
400
|
-
case 'quantity': return line
|
|
455
|
+
case 'quantity': return Cart.quantityOf(line);
|
|
401
456
|
case 'plan': return plan?.name || '';
|
|
402
457
|
case 'plan-description': return plan?.description || '';
|
|
403
458
|
case 'components': return (line.lineComponents || [])
|
|
@@ -434,11 +489,19 @@ function renderLine(node, line) {
|
|
|
434
489
|
return undefined;
|
|
435
490
|
});
|
|
436
491
|
|
|
492
|
+
// Shopify's note about this line (e.g. less stock than asked): class is-warning, tether-line="warning".
|
|
493
|
+
const warning = Cart.warnings.find((w) => w.target === line.id);
|
|
494
|
+
node.classList.toggle('is-warning', Boolean(warning));
|
|
495
|
+
const pending = Cart.pendingQty.has(line.id);
|
|
496
|
+
node.classList.toggle('is-busy', pending);
|
|
497
|
+
if (pending) node.setAttribute('aria-busy', 'true'); else node.removeAttribute('aria-busy');
|
|
498
|
+
qsa(node, sel('line', 'warning')).forEach((el) => setTextTracked(el, warning?.message || ''));
|
|
437
499
|
const lineConds = {
|
|
438
500
|
subscription: isSub,
|
|
439
501
|
'one-time': !isSub,
|
|
440
502
|
'on-sale': parseFloat(line.cost?.compareAtAmountPerQuantity?.amount) > parseFloat(line.cost?.amountPerQuantity?.amount),
|
|
441
503
|
'has-components': (line.lineComponents || []).length > 0,
|
|
504
|
+
warning: Boolean(warning),
|
|
442
505
|
};
|
|
443
506
|
qsa(node, sel('line-show')).forEach((el) => {
|
|
444
507
|
const k = get(el, 'line-show');
|
package/src/client.js
CHANGED
|
@@ -48,7 +48,12 @@ export async function request(conf, query, variables = {}, { context = true } =
|
|
|
48
48
|
throw new TetherError('Tether is not configured: domain and token are required', { code: 'NOT_CONFIGURED' });
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// Shopify caches the response of an identical query text (prices included, for hours after a
|
|
52
|
+
// change). A time bucket in a comment keeps answers at most maxAge seconds old (default 60).
|
|
53
|
+
let text = context ? withContext(query, conf) : query;
|
|
54
|
+
const age = conf.maxAge ?? 60;
|
|
55
|
+
if (age > 0 && !/^\s*mutation\b/.test(text)) text += ` #${Math.floor(Date.now() / (age * 1000))}`;
|
|
56
|
+
const body = JSON.stringify({ query: text, variables });
|
|
52
57
|
let attempt = 0;
|
|
53
58
|
|
|
54
59
|
for (;;) {
|
|
@@ -89,8 +94,8 @@ export async function request(conf, query, variables = {}, { context = true } =
|
|
|
89
94
|
return request(conf, query.replace(/\bquantityAvailable\b/g, ''), variables, { context });
|
|
90
95
|
}
|
|
91
96
|
// An older apiVersion without cart warnings (before 2024-07): retry without them.
|
|
92
|
-
if (json.errors.some((e) => /'warnings'/.test(e.message || '')) && /warnings \{ code message \}/.test(query)) {
|
|
93
|
-
return request(conf, query.replace(/ warnings \{ code message \}/g, ''), variables, { context });
|
|
97
|
+
if (json.errors.some((e) => /'warnings'/.test(e.message || '')) && /warnings \{ code message target \}/.test(query)) {
|
|
98
|
+
return request(conf, query.replace(/ warnings \{ code message target \}/g, ''), variables, { context });
|
|
94
99
|
}
|
|
95
100
|
const throttled = json.errors.some((e) => e.extensions?.code === 'THROTTLED');
|
|
96
101
|
if (throttled && attempt++ < conf.retries) { await sleep(500 * 2 ** attempt); continue; }
|
package/src/collection.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* quick-add with a subscription toggle).
|
|
6
6
|
*/
|
|
7
7
|
import { reportError } from './config.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { TetherError } from './client.js';
|
|
9
|
+
import { attr, sel, get, has, scoped, emit, setText, setImage, repeat, notifyLayoutChanged, busy, show } from './dom.js';
|
|
10
|
+
import { getProducts, getRecommendations } from './loader.js';
|
|
11
|
+
import { setScopeStatus, isNotFound, resolveProductKey } from './status.js';
|
|
11
12
|
|
|
12
13
|
export const listSelector = () => `${sel('collection')},${sel('products')},${sel('recommendations')}`;
|
|
13
14
|
|
|
@@ -46,9 +47,12 @@ export class ListScope {
|
|
|
46
47
|
const stale = () => token !== this.loadToken;
|
|
47
48
|
try {
|
|
48
49
|
if (has(this.root, 'recommendations')) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
50
|
+
// A handle, url:handle (one dynamic product page), or empty inside a product: that product.
|
|
51
|
+
let { key } = resolveProductKey(this.root, window.location, 'recommendations');
|
|
52
|
+
const scope = this.root.closest(sel('product'));
|
|
53
|
+
if (!key && scope) ({ key } = resolveProductKey(scope));
|
|
54
|
+
if (!key) throw new TetherError('No product for recommendations', { code: 'NOT_FOUND' });
|
|
55
|
+
const recs = await getRecommendations(key, get(this.root, 'intent') || 'RELATED');
|
|
52
56
|
if (stale()) return;
|
|
53
57
|
const limit = parseInt(get(this.root, 'limit') || '', 10);
|
|
54
58
|
this.products = limit ? recs.slice(0, limit) : recs;
|
|
@@ -66,7 +70,8 @@ export class ListScope {
|
|
|
66
70
|
if (stale()) return;
|
|
67
71
|
// Keep what's already rendered on a failed "load more"; otherwise show the error state.
|
|
68
72
|
if (!more) setScopeStatus(this.root, isNotFound(err) ? 'not-found' : 'error', err);
|
|
69
|
-
|
|
73
|
+
// A missing product for recommendations is a page detail, not an error to report.
|
|
74
|
+
if (!(isNotFound(err) && has(this.root, 'recommendations'))) reportError(err, 'list load failed');
|
|
70
75
|
emit('error', { error: err, context: 'list' }, this.root);
|
|
71
76
|
}
|
|
72
77
|
}
|
|
@@ -95,10 +100,10 @@ export class ListScope {
|
|
|
95
100
|
const empty = this.products.length === 0;
|
|
96
101
|
this.$(sel('show')).forEach((el) => {
|
|
97
102
|
const k = get(el, 'show');
|
|
98
|
-
if (k === 'list-empty') el
|
|
99
|
-
if (k === 'list-filled') el
|
|
103
|
+
if (k === 'list-empty') show(el, empty);
|
|
104
|
+
if (k === 'list-filled') show(el, !empty);
|
|
100
105
|
});
|
|
101
|
-
this.$(sel('load-more')).forEach((el) =>
|
|
106
|
+
this.$(sel('load-more')).forEach((el) => show(el, Boolean(this.pageInfo?.hasNextPage)));
|
|
102
107
|
notifyLayoutChanged('list');
|
|
103
108
|
}
|
|
104
109
|
}
|
package/src/config.js
CHANGED
|
@@ -36,6 +36,9 @@ export const DEFAULTS = {
|
|
|
36
36
|
|
|
37
37
|
// Subscriptions
|
|
38
38
|
defaultPurchase: 'one-time', // 'one-time' | 'subscription'
|
|
39
|
+
variantUrl: false, // true: ?variant= follows the visitor's choice on the main product (shareable links)
|
|
40
|
+
maxAge: 60, // seconds a Shopify answer may be cached (0 = Shopify's own caching, can be hours)
|
|
41
|
+
lowStock: 5, // tether-show="low-stock" at this many or fewer (needs inventory access)
|
|
39
42
|
|
|
40
43
|
// Mounting
|
|
41
44
|
autoMount: true, // mount document on DOMContentLoaded
|
package/src/diagnose.js
CHANGED
|
@@ -63,7 +63,7 @@ export function checkMarkup(root = document) {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
const known = new Set([
|
|
66
|
-
'in-stock', 'out-of-stock', 'backorder', 'low-stock', 'on-sale', 'not-on-sale', 'has-variants', 'single-variant',
|
|
66
|
+
'in-stock', 'out-of-stock', 'backorder', 'low-stock', 'plenty-in-stock', 'on-sale', 'not-on-sale', 'has-variants', 'single-variant',
|
|
67
67
|
'subscription-available', 'subscription-unavailable', 'subscription-only', 'one-time-available',
|
|
68
68
|
'subscription-selected', 'one-time-selected', 'has-savings', 'loading', 'ready', 'error', 'not-found',
|
|
69
69
|
'cart-empty', 'cart-filled', 'cart-has-subscription', 'cart-no-subscription', 'cart-has-discount', 'cart-error', 'cart-warning',
|
|
@@ -107,7 +107,8 @@ export async function diagnose({ root = document, silent = false, version, now =
|
|
|
107
107
|
autoMount: cfg.autoMount, observe: cfg.observe, onError: typeof cfg.onError === 'function',
|
|
108
108
|
},
|
|
109
109
|
apiVersion: apiVersionStatus(cfg.apiVersion, now),
|
|
110
|
-
market
|
|
110
|
+
// The market in use (a visitor's choice included), plus what detection would pick.
|
|
111
|
+
market: { country: cfg.country || null, language: cfg.language || null, locale: cfg.locale || null, detected: cfg.markets?.detect ? detectMarket(cfg.markets) : null },
|
|
111
112
|
reachable: false,
|
|
112
113
|
shop: null,
|
|
113
114
|
latencyMs: null,
|
|
@@ -141,6 +142,14 @@ export async function diagnose({ root = document, silent = false, version, now =
|
|
|
141
142
|
const plans = allPlans(p);
|
|
142
143
|
report.products.push({ key: keys[i], handle: p.handle, title: p.title, plans: plans.length, requiresSellingPlan: p.requiresSellingPlan });
|
|
143
144
|
plans.forEach((pl) => report.sellingPlans.push({ product: p.handle, group: pl.group?.name, plan: pl.name, id: pl.id }));
|
|
145
|
+
// Options a visitor can't choose on this page (they get the first available variant's value).
|
|
146
|
+
qsa(root, sel('product')).filter((el) => resolveProductKey(el).key === keys[i] && el.querySelector(sel('add-to-cart'))).forEach((el) => {
|
|
147
|
+
const shown = new Set(qsa(el, `${sel('option-list')},select${sel('option')}`).map((x) => get(x, 'option-list') || get(x, 'option')));
|
|
148
|
+
if (!shown.size && qsa(el, `select${sel('variant-select')}`).length) return;
|
|
149
|
+
(p.options || []).filter((o) => o.optionValues.length > 1 && !shown.has(o.name)).forEach((o) => {
|
|
150
|
+
report.problems.push({ level: 'warn', message: `Product "${p.handle}": option "${o.name}" (${o.optionValues.length} values) has no tether-option-list, so visitors can't choose it.` });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
144
153
|
} else {
|
|
145
154
|
report.products.push({ key: keys[i], error: r.reason?.message });
|
|
146
155
|
report.problems.push({ level: 'error', message: `Product "${keys[i]}": ${r.reason?.message}. Is it published to the Headless channel?` });
|
package/src/dom.js
CHANGED
|
@@ -33,6 +33,12 @@ export function emit(name, detail, target = document) {
|
|
|
33
33
|
target.dispatchEvent(new CustomEvent(`${cfg.prefix}:${name}`, { detail, bubbles: true }));
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** 30 -> "30%" (en, nl) or "30 %" (de, fr), in the configured locale. */
|
|
37
|
+
export function formatPercent(n, locale = cfg.locale) {
|
|
38
|
+
if (!n) return '';
|
|
39
|
+
try { return new Intl.NumberFormat(locale || undefined, { style: 'percent', maximumFractionDigits: 0 }).format(n / 100); } catch { return `${n}%`; }
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
export function formatMoney(money) {
|
|
37
43
|
if (!money) return '';
|
|
38
44
|
if (typeof cfg.formatMoney === 'function') return cfg.formatMoney(money, cfg.locale);
|
|
@@ -168,7 +174,7 @@ function template(container) {
|
|
|
168
174
|
/** Rebuilds the list on every render. Fine for images, tags, options. */
|
|
169
175
|
export function repeat(container, items, render) {
|
|
170
176
|
const tpl = template(container);
|
|
171
|
-
if (!tpl) { warn('repeater without a template child', container); return; }
|
|
177
|
+
if (!tpl) { if (items.length) warn('repeater without a template child', container); return; }
|
|
172
178
|
container.innerHTML = '';
|
|
173
179
|
items.forEach((item, i) => {
|
|
174
180
|
const node = tpl.cloneNode(true);
|
package/src/extras.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional part with features that load when first needed: bundles (on the first "add set"
|
|
3
|
+
* click), tab sync (on the first change in another tab) and shareable variant links (on the
|
|
4
|
+
* first variant change, with variantUrl or tether-variant-url).
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, warn, reportError } from './config.js';
|
|
7
|
+
import { sel, get, has, qsa, emit, parseJSONAttr, toAttributes } from './dom.js';
|
|
8
|
+
import { scopes } from './product.js';
|
|
9
|
+
import { Cart } from './cart.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* [tether-group] + [tether-group-add]: adds every product scope inside the group in one request.
|
|
13
|
+
* Bundles, build-a-box, "complete the set". Lines get attribute _group (and tether-line-attributes).
|
|
14
|
+
*/
|
|
15
|
+
export async function addGroup(btn) {
|
|
16
|
+
const group = btn.closest(sel('group'));
|
|
17
|
+
if (!group) return null;
|
|
18
|
+
const name = get(group, 'group');
|
|
19
|
+
const extra = toAttributes(parseJSONAttr(group, 'line-attributes'));
|
|
20
|
+
const groupAttrs = name ? [{ key: '_group', value: name }, ...extra] : extra;
|
|
21
|
+
const lines = qsa(group, sel('product'))
|
|
22
|
+
.map((el) => scopes.get(el))
|
|
23
|
+
.filter((s) => s && s.variant?.availableForSale && s.quantity > 0)
|
|
24
|
+
.map((s) => {
|
|
25
|
+
const l = s.getLine();
|
|
26
|
+
l.attributes = [...(l.attributes || []), ...groupAttrs];
|
|
27
|
+
return l;
|
|
28
|
+
});
|
|
29
|
+
if (!lines.length) { warn('group-add: nothing selected'); return null; }
|
|
30
|
+
try {
|
|
31
|
+
const cart = await Cart.add(lines);
|
|
32
|
+
emit('group-added', { group: name, lines }, group);
|
|
33
|
+
return cart;
|
|
34
|
+
} catch (err) { reportError(err, 'group add'); return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Another tab changed the cart (reload it here) or the visitor's market (follow it). */
|
|
38
|
+
export function onStorage(e) {
|
|
39
|
+
if (e.key === cfg.storageKey || e.key === `${cfg.storageKey}:seen`) Cart.init();
|
|
40
|
+
else if (e.key === `${cfg.prefix}:market:${cfg.domain}` && e.newValue) {
|
|
41
|
+
try {
|
|
42
|
+
const m = JSON.parse(e.newValue);
|
|
43
|
+
if (m?.country) window.Tether?.setCountry(m.country, { language: m.language ?? null, locale: m.locale, remember: false }).catch(() => {});
|
|
44
|
+
} catch { /* ignore */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const urlKeys = new WeakMap(); // scope root -> last variant|plan, so the first render doesn't write
|
|
49
|
+
|
|
50
|
+
/** ?variant= (and selling_plan=) follow the visitor's choice. */
|
|
51
|
+
export function writeVariantUrl(root, { variant, sellingPlanId }) {
|
|
52
|
+
const key = `${variant.id}|${sellingPlanId || ''}`;
|
|
53
|
+
const before = urlKeys.get(root);
|
|
54
|
+
urlKeys.set(root, key);
|
|
55
|
+
if (!before || before === key) return;
|
|
56
|
+
if (!has(root, 'variant-url') && !(cfg.variantUrl && document.querySelector(sel('product')) === root)) return;
|
|
57
|
+
try {
|
|
58
|
+
const u = new URL(window.location.href);
|
|
59
|
+
u.searchParams.set('variant', variant.id.split('/').pop());
|
|
60
|
+
if (sellingPlanId) u.searchParams.set('selling_plan', sellingPlanId.split('/').pop()); else u.searchParams.delete('selling_plan');
|
|
61
|
+
window.history.replaceState(window.history.state, '', u);
|
|
62
|
+
} catch { /* ignore */ }
|
|
63
|
+
}
|