@occluze/tether 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -8
- package/dist/esm/astro.js +2 -0
- 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-VC46IEJQ.js +1 -0
- package/dist/esm/chunks/chunk-YXCXIPQV.js +4 -0
- package/dist/esm/chunks/{chunk-KYQ5OGBX.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 -0
- 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 +14 -4
- 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 +486 -125
- package/dist/tether.markets.js +17 -11
- 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 +31 -5
- package/src/account.js +313 -0
- package/src/analytics.js +232 -0
- package/src/astro.js +76 -0
- package/src/cart.js +119 -16
- package/src/client.js +30 -15
- package/src/collection.js +15 -10
- package/src/config.js +5 -0
- package/src/diagnose.js +12 -3
- package/src/dom.js +39 -10
- package/src/extras.js +63 -0
- package/src/fragments.js +13 -12
- package/src/index.js +114 -40
- package/src/initial.js +75 -0
- package/src/lazy.js +9 -1
- package/src/loader.js +53 -7
- package/src/markets-ui.js +15 -6
- package/src/markets.js +1 -1
- package/src/mounting.js +68 -3
- 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 +51 -13
- package/src/search.js +153 -0
- package/src/server.js +251 -0
- package/src/status.js +3 -8
- package/types/astro.d.ts +15 -0
- package/types/index.d.ts +72 -4
- package/types/server.d.ts +102 -0
- package/dist/esm/chunks/chunk-GHIUMD5H.js +0 -1
- package/dist/esm/chunks/chunk-LI3YDMGF.js +0 -4
- package/dist/esm/chunks/diagnose-ZXXXJIQE.js +0 -1
- package/dist/esm/chunks/markets-ui-AG6Y5VUE.js +0 -1
package/src/index.js
CHANGED
|
@@ -17,11 +17,11 @@ import {
|
|
|
17
17
|
} from './loader.js';
|
|
18
18
|
import * as plans from './selling-plans.js';
|
|
19
19
|
import { resolveProductKey } from './status.js';
|
|
20
|
-
import { attr, sel, get, qsa, emit, formatMoney, busy, parseJSONAttr, toAttributes } from './dom.js';
|
|
20
|
+
import { attr, sel, get, qsa, emit, formatMoney, busy, parseJSONAttr, toAttributes, imageUrl } from './dom.js';
|
|
21
21
|
import { loadPart, coreModules } from './lazy.js';
|
|
22
|
-
import { mountedLists, liveLists, pruneLive, within, mountProduct, retryFailed } from './mounting.js';
|
|
22
|
+
import { mountedLists, liveLists, pruneLive, within, mountProduct, retryFailed, hydrateFromPage, settleMarket, trackMarket } from './mounting.js';
|
|
23
23
|
|
|
24
|
-
export const VERSION = '0.
|
|
24
|
+
export const VERSION = '0.6.0';
|
|
25
25
|
|
|
26
26
|
let initPromise = null;
|
|
27
27
|
let observer = null;
|
|
@@ -31,6 +31,7 @@ export function mount(root = document) {
|
|
|
31
31
|
if (!isConfigured()) return 0;
|
|
32
32
|
let count = 0;
|
|
33
33
|
pruneLive(); // page transitions detach old roots: don't keep them alive
|
|
34
|
+
hydrateFromPage(root); // products the server already rendered: no request needed
|
|
34
35
|
|
|
35
36
|
within(root, listSelector()).forEach((el) => {
|
|
36
37
|
if (mountedLists.has(el)) return;
|
|
@@ -39,7 +40,7 @@ export function mount(root = document) {
|
|
|
39
40
|
const list = new ListScope(el);
|
|
40
41
|
liveLists.add(list);
|
|
41
42
|
el.addEventListener(`${cfg.prefix}:list-rendered`, (e) => mount(e.detail.container));
|
|
42
|
-
list.load();
|
|
43
|
+
trackMarket(list.load());
|
|
43
44
|
});
|
|
44
45
|
|
|
45
46
|
within(root, sel('product')).forEach((el) => { if (mountProduct(el)) count++; });
|
|
@@ -49,10 +50,22 @@ export function mount(root = document) {
|
|
|
49
50
|
loadPart('markets').then((m) => m.renderCountrySelects(root)).catch((err) => reportError(err, 'markets'));
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// Customer accounts: their own optional part, when configured and the page (or a login) needs it.
|
|
54
|
+
if ((cfg.account?.clientId || cfg.account?.server) && (within(root, `[${attr('account-login')}],[${attr('account-bind')}],[${attr('orders')}],[${attr('show')}^="logged-"]`).length
|
|
55
|
+
|| /[?&]code=/.test(window.location.search) || hasAccountSession())) {
|
|
56
|
+
loadPart('account').then((m) => m.mountAccount(root)).catch((err) => reportError(err, 'account'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Search as you type: its own optional part, only for pages that have it.
|
|
60
|
+
if (within(root, sel('search')).length) {
|
|
61
|
+
loadPart('search').then((m) => m.mountSearch(root)).catch((err) => reportError(err, 'search'));
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
qsa(root, sel('account-link')).forEach((a) => {
|
|
53
65
|
a.setAttribute('href', `https://${cfg.domain}/account`);
|
|
54
66
|
});
|
|
55
67
|
|
|
68
|
+
settleMarket();
|
|
56
69
|
Cart.render();
|
|
57
70
|
log(`mounted ${count} scope(s)`, root);
|
|
58
71
|
return count;
|
|
@@ -100,9 +113,9 @@ function bindControls() {
|
|
|
100
113
|
const id = line.getAttribute(`data-${cfg.prefix}-key`);
|
|
101
114
|
const current = Cart.lines.find((l) => l.id === id);
|
|
102
115
|
const run = (fn) => { e.preventDefault(); fn().catch((err) => reportError(err, 'cart line')); };
|
|
103
|
-
if (t.closest(sel('line-remove'))) return run(() => Cart.removeLine(id));
|
|
104
|
-
if (current && t.closest(sel('line-plus'))) return run(() => Cart.
|
|
105
|
-
if (current && t.closest(sel('line-minus'))) return run(() => Cart.
|
|
116
|
+
if (t.closest(sel('line-remove'))) { line.classList.add('is-busy'); return run(() => Cart.removeLine(id)); }
|
|
117
|
+
if (current && t.closest(sel('line-plus'))) return run(() => Cart.changeQuantity(id, 1));
|
|
118
|
+
if (current && t.closest(sel('line-minus'))) return run(() => Cart.changeQuantity(id, -1));
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
const removeCode = t.closest(sel('discount-remove'));
|
|
@@ -122,10 +135,12 @@ function bindControls() {
|
|
|
122
135
|
if (!t?.closest) return;
|
|
123
136
|
const lineEl = t.closest(dataKeySel());
|
|
124
137
|
const id = lineEl?.getAttribute(`data-${cfg.prefix}-key`);
|
|
138
|
+
// The line shows is-busy / aria-busy until Shopify has answered (removed by the next render).
|
|
139
|
+
const busyLine = (p, what) => { lineEl.classList.add('is-busy'); lineEl.setAttribute('aria-busy', 'true'); p.catch((err) => reportError(err, what)); };
|
|
125
140
|
if (id && t.matches(`input${sel('line', 'quantity')}`)) {
|
|
126
|
-
Cart.updateLine(id, parseInt(t.value, 10) || 0)
|
|
141
|
+
busyLine(Cart.updateLine(id, parseInt(t.value, 10) || 0), 'cart line');
|
|
127
142
|
} else if (id && t.matches(`select${sel('line-plan')}`)) {
|
|
128
|
-
Cart.updateLine(id, { sellingPlanId: t.value || null })
|
|
143
|
+
busyLine(Cart.updateLine(id, { sellingPlanId: t.value || null }), 'cart line plan');
|
|
129
144
|
} else if (t.matches(`select${sel('country-select')}`)) {
|
|
130
145
|
if (t.value) setCountry(t.value).catch((err) => reportError(err, 'country'));
|
|
131
146
|
} else if (t.matches(sel('cart-note'))) {
|
|
@@ -141,15 +156,55 @@ function bindControls() {
|
|
|
141
156
|
const code = input?.value.trim();
|
|
142
157
|
if (!code) return;
|
|
143
158
|
busy(form, async () => {
|
|
159
|
+
const mark = (bad) => {
|
|
160
|
+
form.classList.toggle('is-invalid', bad);
|
|
161
|
+
if (input) { if (bad) input.setAttribute('aria-invalid', 'true'); else input.removeAttribute('aria-invalid'); }
|
|
162
|
+
};
|
|
144
163
|
try {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
164
|
+
await Cart.applyDiscount(code);
|
|
165
|
+
mark(false);
|
|
166
|
+
emit('discount-applied', { code }, form);
|
|
167
|
+
if (input) input.value = '';
|
|
168
|
+
} catch (err) {
|
|
169
|
+
mark(true);
|
|
170
|
+
if (err?.code === 'INVALID_DISCOUNT') emit('discount-invalid', { code }, form); else reportError(err, 'discount');
|
|
171
|
+
}
|
|
151
172
|
});
|
|
173
|
+
}, true); // capture: before a router (Astro's ClientRouter) turns the submit into a navigation
|
|
174
|
+
document.addEventListener('input', (e) => {
|
|
175
|
+
const f = e.target.closest?.(sel('discount-form'));
|
|
176
|
+
if (f) { f.classList.remove('is-invalid'); e.target.removeAttribute?.('aria-invalid'); }
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Other tabs: the cart or the market changed there (handled by the extras part, loaded then).
|
|
180
|
+
window.addEventListener('storage', (e) => {
|
|
181
|
+
if (cfg.domain && e.key?.startsWith(`${cfg.prefix}:`)) loadPart('extras').then((m) => m.onStorage(e)).catch(() => {});
|
|
152
182
|
});
|
|
183
|
+
// Shareable variant links: only when turned on.
|
|
184
|
+
document.addEventListener(`${cfg.prefix}:variant-changed`, (e) => {
|
|
185
|
+
const root = e.target;
|
|
186
|
+
if (cfg.variantUrl || root.hasAttribute?.(attr('variant-url'))) loadPart('extras').then((m) => m.writeVariantUrl(root, e.detail)).catch(() => {});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Astro View Transitions (<ClientRouter />): mount every new page, close the cart while leaving.
|
|
190
|
+
if (cfg.astro !== false) {
|
|
191
|
+
// Astro fires this on the first load too: wait for init (the visitor's saved market) first.
|
|
192
|
+
document.addEventListener('astro:page-load', () => (initPromise || Promise.resolve()).then(() => {
|
|
193
|
+
// Navigated to a page of another market (e.g. /de/): switch first, then mount in that market.
|
|
194
|
+
// Back on a normal page after one: the visitor's own choice, else the default market.
|
|
195
|
+
let target = pageMarket();
|
|
196
|
+
if (!target && onPageMarket) {
|
|
197
|
+
try { target = JSON.parse(localStorage.getItem(`${cfg.prefix}:market:${cfg.domain}`) || 'null'); } catch { target = null; }
|
|
198
|
+
target = target?.country ? target : baseMarket;
|
|
199
|
+
}
|
|
200
|
+
onPageMarket = Boolean(pageMarket());
|
|
201
|
+
const differs = target?.country && (String(target.country).toUpperCase() !== String(cfg.country || '').toUpperCase()
|
|
202
|
+
|| String(target.language || '').toUpperCase() !== String(cfg.language || '').toUpperCase());
|
|
203
|
+
if (differs) setCountry(target.country, { language: target.language ?? null, locale: target.locale, remember: false }).then(() => mount(document), () => mount(document));
|
|
204
|
+
else mount(document);
|
|
205
|
+
}));
|
|
206
|
+
document.addEventListener('astro:before-swap', () => unmount());
|
|
207
|
+
}
|
|
153
208
|
|
|
154
209
|
// Back online after a failed load: try the failed products and lists again.
|
|
155
210
|
window.addEventListener('online', () => {
|
|
@@ -181,34 +236,14 @@ function dataKeySel() { return `[data-${cfg.prefix}-key]`; }
|
|
|
181
236
|
* group in one request. Bundles, build-a-box, "complete the set".
|
|
182
237
|
* Lines get attribute _group (and the group's tether-line-attributes).
|
|
183
238
|
*/
|
|
184
|
-
export
|
|
185
|
-
const group = btn.closest(sel('group'));
|
|
186
|
-
if (!group) return null;
|
|
187
|
-
const name = get(group, 'group');
|
|
188
|
-
const extra = toAttributes(parseJSONAttr(group, 'line-attributes'));
|
|
189
|
-
const groupAttrs = name ? [{ key: '_group', value: name }, ...extra] : extra;
|
|
190
|
-
|
|
191
|
-
const lines = qsa(group, sel('product'))
|
|
192
|
-
.map((el) => scopes.get(el))
|
|
193
|
-
.filter((s) => s && s.variant?.availableForSale && s.quantity > 0)
|
|
194
|
-
.map((s) => {
|
|
195
|
-
const l = s.getLine();
|
|
196
|
-
l.attributes = [...(l.attributes || []), ...groupAttrs];
|
|
197
|
-
return l;
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
if (!lines.length) { warn('group-add: nothing selected'); return null; }
|
|
201
|
-
try {
|
|
202
|
-
const cart = await Cart.add(lines);
|
|
203
|
-
emit('group-added', { group: name, lines }, group);
|
|
204
|
-
return cart;
|
|
205
|
-
} catch (err) { reportError(err, 'group add'); return null; }
|
|
206
|
-
}
|
|
239
|
+
export const addGroup = (btn) => loadPart('extras').then((m) => m.addGroup(btn));
|
|
207
240
|
|
|
208
241
|
// ---------------------------------------------------------------------------
|
|
209
242
|
// Init
|
|
210
243
|
// ---------------------------------------------------------------------------
|
|
211
244
|
|
|
245
|
+
let analyticsQueue = null;
|
|
246
|
+
|
|
212
247
|
export function init(overrides = {}) {
|
|
213
248
|
if (initPromise && !Object.keys(overrides).length) return initPromise;
|
|
214
249
|
configure({ ...(window.TETHER_CONFIG || {}), ...overrides });
|
|
@@ -221,6 +256,13 @@ export function init(overrides = {}) {
|
|
|
221
256
|
|
|
222
257
|
bindControls();
|
|
223
258
|
if (cfg.observe) startObserver();
|
|
259
|
+
// Analytics (optional part): buffer Tether's events until it has loaded, so none are missed.
|
|
260
|
+
if (cfg.analytics && !analyticsQueue) {
|
|
261
|
+
analyticsQueue = [];
|
|
262
|
+
['product-ready', 'list-ready', 'cart-updated', 'cart-toggled', 'checkout', 'search'].forEach((n) => document.addEventListener(`${cfg.prefix}:${n}`, (e) => analyticsQueue.push(e)));
|
|
263
|
+
document.addEventListener('astro:page-load', () => analyticsQueue.push(new CustomEvent(`${cfg.prefix}:page`)));
|
|
264
|
+
loadPart('analytics').then((m) => m.startAnalytics(analyticsQueue)).catch((err) => reportError(err, 'analytics'));
|
|
265
|
+
}
|
|
224
266
|
|
|
225
267
|
initPromise = applyMarket(overrides).then(() => Cart.init()).then(async (cart) => {
|
|
226
268
|
// A saved cart from another market: move it to this one, so prices match the page.
|
|
@@ -239,8 +281,30 @@ export function init(overrides = {}) {
|
|
|
239
281
|
* Without markets.detect only the visitor's saved choice applies, read here without loading
|
|
240
282
|
* the markets part.
|
|
241
283
|
*/
|
|
284
|
+
/**
|
|
285
|
+
* A page built for one market (e.g. /de/ pages): <meta name="tether-page-country" content="DE">,
|
|
286
|
+
* tether-page-language, tether-page-locale. Wins over the visitor's saved choice, isn't remembered.
|
|
287
|
+
*/
|
|
288
|
+
function pageMarket(doc = document) {
|
|
289
|
+
const get = (n) => doc.querySelector(`meta[name="${cfg.prefix}-page-${n}"]`)?.getAttribute('content') || undefined;
|
|
290
|
+
const country = get('country');
|
|
291
|
+
return country ? { country: country.toUpperCase(), language: get('language'), locale: get('locale') } : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let baseMarket = null; // the configured market, before a visitor's or page's market applied
|
|
295
|
+
let onPageMarket = false;
|
|
296
|
+
|
|
242
297
|
async function applyMarket(overrides) {
|
|
243
298
|
const m = cfg.markets || {};
|
|
299
|
+
baseMarket = { country: cfg.country, language: cfg.language, locale: cfg.locale };
|
|
300
|
+
const page = pageMarket();
|
|
301
|
+
onPageMarket = Boolean(page);
|
|
302
|
+
if (page) {
|
|
303
|
+
cfg.country = page.country;
|
|
304
|
+
cfg.language = page.language ? page.language.toUpperCase() : undefined;
|
|
305
|
+
if (page.locale) cfg.locale = page.locale;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
244
308
|
if (m.detect === false) return;
|
|
245
309
|
let market = null;
|
|
246
310
|
if (m.detect) {
|
|
@@ -252,10 +316,17 @@ async function applyMarket(overrides) {
|
|
|
252
316
|
if (!market) return;
|
|
253
317
|
if (market.country) cfg.country = market.country;
|
|
254
318
|
if (market.language) cfg.language = market.language;
|
|
255
|
-
if (market.locale && !overrides.locale
|
|
319
|
+
if (market.locale && !overrides.locale) cfg.locale = market.locale; // the visitor's market wins over the default
|
|
256
320
|
log('market', market);
|
|
257
321
|
}
|
|
258
322
|
|
|
323
|
+
function hasAccountSession() {
|
|
324
|
+
try { return Boolean(sessionStorage[`${cfg.prefix}:account:${cfg.domain}`] || localStorage[`${cfg.prefix}:account:${cfg.domain}`] || localStorage[`${cfg.prefix}:account-hint:${cfg.domain}`]); } catch { return false; }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Customer accounts (optional part, loaded on first use). See src/account.js. */
|
|
328
|
+
const accountApi = new Proxy({}, { get: (_, n) => (...a) => loadPart('account').then((m) => m[n](...a)) });
|
|
329
|
+
|
|
259
330
|
/** Tether.setCountry('DE'): loads the markets part on first use. */
|
|
260
331
|
export function setCountry(code, opts) {
|
|
261
332
|
return loadPart('markets').then((m) => m.setCountry(code, opts));
|
|
@@ -288,10 +359,12 @@ export const api = {
|
|
|
288
359
|
getRecommendations,
|
|
289
360
|
getScope,
|
|
290
361
|
addGroup,
|
|
362
|
+
account: accountApi,
|
|
291
363
|
primeProduct,
|
|
292
364
|
normalizeProductKey,
|
|
293
365
|
clearProductCache,
|
|
294
366
|
formatMoney,
|
|
367
|
+
imageUrl,
|
|
295
368
|
plans,
|
|
296
369
|
TetherError,
|
|
297
370
|
resolveProductKey,
|
|
@@ -313,7 +386,8 @@ export default api;
|
|
|
313
386
|
// Auto-start for the plain <script> setup.
|
|
314
387
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
315
388
|
window.Tether = api;
|
|
316
|
-
|
|
389
|
+
// Not with a framework integration (Astro), which starts Tether itself.
|
|
390
|
+
const auto = window.TETHER_CONFIG && window.TETHER_CONFIG.autoMount !== false && !window.TETHER_INTEGRATION;
|
|
317
391
|
if (auto) {
|
|
318
392
|
const start = () => init().then(() => mount(document));
|
|
319
393
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
|
package/src/initial.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the browser script will show first for a product, so a server can render the same
|
|
3
|
+
* (no flash when Tether takes over): the selected variant, price, purchase mode and conditions.
|
|
4
|
+
* Same rules as src/product.js: first available variant (or the one in ?variant=), subscription
|
|
5
|
+
* when the product requires a plan or defaultPurchase is 'subscription', low stock at 5 or fewer.
|
|
6
|
+
*/
|
|
7
|
+
import { GID, allPlans, allocationFor, findPlan, planPrice, planSavings } from './selling-plans.js';
|
|
8
|
+
|
|
9
|
+
export function initialState(product, { variant, sellingPlan, defaultPurchase = 'one-time', lowStock = 5 } = {}) {
|
|
10
|
+
const vs = product?.variants?.nodes || [];
|
|
11
|
+
const gid = variant ? GID.variant(variant) : null;
|
|
12
|
+
const v = (gid && vs.find((x) => x.id === gid)) || vs.find((x) => x.availableForSale) || vs[0] || null;
|
|
13
|
+
if (!v) return null;
|
|
14
|
+
const selected = Object.fromEntries((v.selectedOptions || []).map((o) => [o.name, o.value]));
|
|
15
|
+
|
|
16
|
+
const plans = allPlans(product);
|
|
17
|
+
const fromUrl = findPlan(product, sellingPlan);
|
|
18
|
+
let plan = plans.length ? (fromUrl || plans[0]) : null;
|
|
19
|
+
if (plan && !allocationFor(v, plan.id)) plan = plans.find((p) => allocationFor(v, p.id)) || plan;
|
|
20
|
+
const wantsSub = product.requiresSellingPlan || fromUrl || defaultPurchase === 'subscription';
|
|
21
|
+
const subscriptionAvailable = plans.some((p) => allocationFor(v, p.id));
|
|
22
|
+
const purchase = plans.length && wantsSub ? 'subscription' : 'one-time';
|
|
23
|
+
const sub = purchase === 'subscription' && subscriptionAvailable;
|
|
24
|
+
|
|
25
|
+
const price = sub ? planPrice(v, plan.id) : v.price;
|
|
26
|
+
const compareAt = sub ? (allocationFor(v, plan.id)?.priceAdjustments?.[0]?.compareAtPrice || v.compareAtPrice) : v.compareAtPrice;
|
|
27
|
+
const onSale = Boolean(compareAt && parseFloat(compareAt.amount) > parseFloat(price.amount));
|
|
28
|
+
const q = v.quantityAvailable;
|
|
29
|
+
const low = q != null && q > 0 && q <= lowStock;
|
|
30
|
+
const savingsNow = sub ? planSavings(v, plan.id).percentage : 0;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
variant: v,
|
|
34
|
+
selected,
|
|
35
|
+
purchase,
|
|
36
|
+
plan: sub ? plan : null,
|
|
37
|
+
/** The plan list (tether-plan-list="all"): selected one gets is-selected, unavailable ones is-unavailable. */
|
|
38
|
+
plans: plans.map((p) => ({
|
|
39
|
+
id: p.id, name: p.name, price: planPrice(v, p.id), savings: planSavings(v, p.id).percentage,
|
|
40
|
+
selected: sub && p.id === plan.id, available: Boolean(allocationFor(v, p.id)),
|
|
41
|
+
})),
|
|
42
|
+
/** Savings of the selected plan in % (tether-bind="subscription-savings"), 0 without one. */
|
|
43
|
+
savings: plan ? planSavings(v, plan.id).percentage : 0,
|
|
44
|
+
price,
|
|
45
|
+
compareAtPrice: onSale ? compareAt : null,
|
|
46
|
+
available: v.availableForSale,
|
|
47
|
+
/** The tether-show conditions, e.g. hidden={!state.show['out-of-stock']}. */
|
|
48
|
+
show: {
|
|
49
|
+
'in-stock': v.availableForSale,
|
|
50
|
+
'out-of-stock': !v.availableForSale,
|
|
51
|
+
backorder: Boolean(v.availableForSale && v.currentlyNotInStock),
|
|
52
|
+
'on-sale': onSale,
|
|
53
|
+
'not-on-sale': !onSale,
|
|
54
|
+
'has-variants': vs.length > 1,
|
|
55
|
+
'single-variant': vs.length === 1,
|
|
56
|
+
'low-stock': low,
|
|
57
|
+
'plenty-in-stock': v.availableForSale && !low,
|
|
58
|
+
'subscription-available': subscriptionAvailable,
|
|
59
|
+
'subscription-unavailable': !subscriptionAvailable,
|
|
60
|
+
'subscription-only': Boolean(product.requiresSellingPlan),
|
|
61
|
+
'one-time-available': !product.requiresSellingPlan,
|
|
62
|
+
'subscription-selected': sub,
|
|
63
|
+
'one-time-selected': !sub,
|
|
64
|
+
'has-savings': sub ? savingsNow > 0 : onSale,
|
|
65
|
+
},
|
|
66
|
+
/** Option values without an available variant, as plain data (for frameworks that serialise props). */
|
|
67
|
+
unavailable: Object.fromEntries((product.options || []).map((o) => [o.name, o.optionValues.map((x) => x.name).filter((n) => isUnavail(o.name, n))])),
|
|
68
|
+
/** Option values with no available variant (given the other selected options): class is-unavailable. */
|
|
69
|
+
isUnavailable: (name, value) => isUnavail(name, value),
|
|
70
|
+
};
|
|
71
|
+
function isUnavail(name, value) {
|
|
72
|
+
return !vs.some((x) => x.availableForSale && x.selectedOptions.every((o) =>
|
|
73
|
+
(o.name === name ? o.value === value : selected[o.name] === o.value)));
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/lazy.js
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
* ESM / Node: dynamic import (bundlers split these into their own chunks).
|
|
4
4
|
* The <script> build swaps this file for lazy-iife.js (see scripts/build.mjs).
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
const PARTS = {
|
|
7
|
+
diagnose: () => import('./diagnose.js'),
|
|
8
|
+
markets: () => import('./markets-ui.js'),
|
|
9
|
+
search: () => import('./search.js'),
|
|
10
|
+
extras: () => import('./extras.js'),
|
|
11
|
+
account: () => import('./account.js'),
|
|
12
|
+
analytics: () => import('./analytics.js'),
|
|
13
|
+
};
|
|
14
|
+
export const loadPart = (name) => PARTS[name]();
|
|
7
15
|
|
|
8
16
|
// Only the <script> build shares its modules with separately loaded parts.
|
|
9
17
|
export const coreModules = undefined;
|
package/src/loader.js
CHANGED
|
@@ -90,8 +90,15 @@ async function flush() {
|
|
|
90
90
|
handles.forEach((h, i) => { if (data[`h${i}`]) got.set(h, data[`h${i}`]); });
|
|
91
91
|
|
|
92
92
|
keys.forEach((k) => settle(k, (r) => {
|
|
93
|
-
if (got.has(k)) {
|
|
94
|
-
|
|
93
|
+
if (got.has(k)) {
|
|
94
|
+
const p = got.get(k);
|
|
95
|
+
if (gen === generation) { primeProduct(p); freshAt.set(p.handle, Date.now()); freshAt.set(p.id, Date.now()); }
|
|
96
|
+
r.resolve(p);
|
|
97
|
+
} else {
|
|
98
|
+
// A miss is remembered for a few seconds (one page load asking twice makes one request),
|
|
99
|
+
// not longer: a product published in Shopify shows up on the next visit.
|
|
100
|
+
const miss = productCache.get(k);
|
|
101
|
+
if (gen === generation) setTimeout(() => { if (productCache.get(k) === miss) productCache.delete(k); }, 10000);
|
|
95
102
|
r.reject(new TetherError(`Product not found: ${k}`, { code: 'NOT_FOUND' }));
|
|
96
103
|
}
|
|
97
104
|
}));
|
|
@@ -100,7 +107,44 @@ async function flush() {
|
|
|
100
107
|
}
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
export function clearProductCache() { productCache.clear(); generation++; }
|
|
110
|
+
export function clearProductCache() { productCache.clear(); generation++; stale.clear(); freshAt.clear(); }
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Hydration: products rendered on the server (tether/server hydrate()) are used at once and
|
|
114
|
+
// refreshed once in the background, so prices and stock are live without a loading state.
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const stale = new Set(); // keys of products that came from the server and weren't refreshed yet
|
|
118
|
+
const freshAt = new Map(); // key -> when it was last loaded live (this page session)
|
|
119
|
+
const FRESH_MS = 60000;
|
|
120
|
+
|
|
121
|
+
export function hydrateProducts(products) {
|
|
122
|
+
(products || []).forEach((p) => {
|
|
123
|
+
if (!p?.id || !p.handle) return;
|
|
124
|
+
// Loaded live in the last minute (an earlier page of a View Transition visit): that copy is newer.
|
|
125
|
+
if (Date.now() - (freshAt.get(p.handle) || 0) < FRESH_MS && productCache.has(p.handle)) return;
|
|
126
|
+
primeProduct(p);
|
|
127
|
+
stale.add(p.handle);
|
|
128
|
+
stale.add(p.id);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** True once per server-provided product: the caller refreshes it. */
|
|
133
|
+
export function takeStale(key) {
|
|
134
|
+
const k = normalizeProductKey(key);
|
|
135
|
+
if (!stale.has(k)) return false;
|
|
136
|
+
const p = productCache.get(k);
|
|
137
|
+
stale.delete(k);
|
|
138
|
+
// Both keys point to the same product: refresh it once.
|
|
139
|
+
p?.then?.((x) => { stale.delete(x.handle); stale.delete(x.id); });
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Fetch these products again (bypassing the cache). */
|
|
144
|
+
export function refreshProducts(keys) {
|
|
145
|
+
keys.forEach((k) => productCache.delete(normalizeProductKey(k)));
|
|
146
|
+
return keys.map((k) => getProduct(k));
|
|
147
|
+
}
|
|
104
148
|
|
|
105
149
|
/**
|
|
106
150
|
* A page of products from a collection (by handle) or from a search query.
|
|
@@ -141,11 +185,13 @@ ${fragment}`, { first, after, query: query || undefined, sortKey: sortKey || und
|
|
|
141
185
|
return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
|
|
142
186
|
}
|
|
143
187
|
|
|
144
|
-
export async function getRecommendations(
|
|
145
|
-
const
|
|
146
|
-
|
|
188
|
+
export async function getRecommendations(product, intent = 'RELATED') {
|
|
189
|
+
const key = normalizeProductKey(typeof product === 'object' ? product?.id : product);
|
|
190
|
+
const byId = key?.startsWith('gid://');
|
|
191
|
+
const data = await gql(`query TetherRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
|
|
192
|
+
productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
|
|
147
193
|
}
|
|
148
|
-
${productFragment()}`, { id:
|
|
194
|
+
${productFragment()}`, byId ? { id: key, intent } : { handle: key, intent });
|
|
149
195
|
const list = data.productRecommendations || [];
|
|
150
196
|
list.forEach(primeProduct);
|
|
151
197
|
return list;
|
package/src/markets-ui.js
CHANGED
|
@@ -30,11 +30,14 @@ function renderCountrySelects(root = document) {
|
|
|
30
30
|
.replace(/\{currency\}/g, c.currency?.isoCode || '').replace(/\{symbol\}/g, c.currency?.symbol || '');
|
|
31
31
|
selects.forEach((s) => {
|
|
32
32
|
const tpl = s.getAttribute(attr('country-label')) || '{name} ({currency})';
|
|
33
|
+
// tether-countries="NL,DE,BE": only these (in your order); otherwise every country of the store.
|
|
34
|
+
const only = (s.getAttribute(attr('countries')) || '').toUpperCase().split(/[\s,]+/).filter(Boolean);
|
|
35
|
+
const list = only.length ? only.map((c) => countries.find((x) => x.isoCode === c)).filter(Boolean) : countries;
|
|
33
36
|
s.innerHTML = '';
|
|
34
|
-
|
|
37
|
+
list.forEach((c) => s.appendChild(new Option(fill(tpl, c), c.isoCode)));
|
|
35
38
|
s.value = current;
|
|
36
39
|
if (s.value !== current) s.selectedIndex = -1; // a country the store doesn't sell to
|
|
37
|
-
s.disabled =
|
|
40
|
+
s.disabled = list.length < 2 && !s.hasAttribute(attr('always-enabled'));
|
|
38
41
|
});
|
|
39
42
|
binds.forEach((el) => {
|
|
40
43
|
const k = get(el, 'country-bind') || 'name';
|
|
@@ -49,14 +52,20 @@ function renderCountrySelects(root = document) {
|
|
|
49
52
|
* buyerIdentity.countryCode is updated, and every product and list on the page re-renders.
|
|
50
53
|
* Tether.setCountry('DE') Tether.setCountry('BE', { language: 'FR' })
|
|
51
54
|
*/
|
|
52
|
-
export async function setCountry(code, { language, remember = true } = {}) {
|
|
55
|
+
export async function setCountry(code, { language, locale, remember = true } = {}) {
|
|
53
56
|
const country = toCountry(code);
|
|
54
57
|
if (!country) throw new TetherError(`Not a country code: ${code}`, { code: 'INVALID_COUNTRY' });
|
|
55
|
-
|
|
56
|
-
const
|
|
58
|
+
// Money format: the option, else a markets.locales entry for this country, else unchanged.
|
|
59
|
+
const entry = Object.values(cfg.markets?.locales || {}).find((m) => toCountry(m?.country) === country);
|
|
60
|
+
const own = locale || entry?.locale;
|
|
61
|
+
const loc = own || cfg.locale;
|
|
62
|
+
// Language: the option, else the markets.locales entry for this country (which may have none), else unchanged.
|
|
63
|
+
const lang = language !== undefined ? toLanguage(language) : entry ? (entry.language ? toLanguage(entry.language) : undefined) : cfg.language;
|
|
64
|
+
const changed = country !== String(cfg.country || '').toUpperCase() || lang !== cfg.language || loc !== cfg.locale;
|
|
57
65
|
cfg.country = country;
|
|
58
66
|
cfg.language = lang || undefined;
|
|
59
|
-
|
|
67
|
+
cfg.locale = loc;
|
|
68
|
+
if (remember) rememberMarket({ country, language: lang, locale: own });
|
|
60
69
|
if (!changed) { renderCountrySelects(); return Cart.state; }
|
|
61
70
|
|
|
62
71
|
clearProductCache();
|
package/src/markets.js
CHANGED
|
@@ -112,7 +112,7 @@ export function detectMarket(markets = cfg.markets, env = defaultEnv()) {
|
|
|
112
112
|
|
|
113
113
|
export function rememberMarket(market) {
|
|
114
114
|
if (cfg.markets?.remember === false) return;
|
|
115
|
-
try { localStorage.setItem(storageKey(), JSON.stringify({ country: market.country, language: market.language || undefined })); } catch { /* private mode */ }
|
|
115
|
+
try { localStorage.setItem(storageKey(), JSON.stringify({ country: market.country, language: market.language || undefined, locale: market.locale || undefined })); } catch { /* private mode */ }
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
let localizationPromise = null;
|
package/src/mounting.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Product scopes and lists that are mounted, shared by index.js and the optional markets part. */
|
|
2
2
|
import { cfg, log, warn, reportError } from './config.js';
|
|
3
|
-
import { ProductScope, scopes } from './product.js';
|
|
3
|
+
import { ProductScope, scopes, rememberOptionNames } from './product.js';
|
|
4
4
|
import { listSelector } from './collection.js';
|
|
5
|
-
import { getProduct } from './loader.js';
|
|
5
|
+
import { getProduct, takeStale, refreshProducts, hydrateProducts } from './loader.js';
|
|
6
6
|
import { resolveProductKey, setScopeStatus, isNotFound } from './status.js';
|
|
7
7
|
import { attr, get, qsa, emit } from './dom.js';
|
|
8
8
|
|
|
@@ -45,7 +45,9 @@ function mountProduct(el) {
|
|
|
45
45
|
// A rendered scope keeps showing its content while it refreshes (e.g. country switch).
|
|
46
46
|
if (!scopes.get(el)) setScopeStatus(el, 'loading');
|
|
47
47
|
else el.setAttribute(attr('state'), 'updating');
|
|
48
|
-
getProduct(key)
|
|
48
|
+
const loading = getProduct(key);
|
|
49
|
+
marketPending?.add(loading);
|
|
50
|
+
loading
|
|
49
51
|
.then((p) => {
|
|
50
52
|
if (!current()) return;
|
|
51
53
|
const existing = scopes.get(el);
|
|
@@ -53,6 +55,7 @@ function mountProduct(el) {
|
|
|
53
55
|
// eslint-disable-next-line no-new
|
|
54
56
|
else new ProductScope(el, p);
|
|
55
57
|
applyPageTitle(el, p);
|
|
58
|
+
if (cfg.revalidate !== false && takeStale(key)) queueRefresh(key);
|
|
56
59
|
})
|
|
57
60
|
.catch((err) => {
|
|
58
61
|
if (!current()) return;
|
|
@@ -87,3 +90,65 @@ export function retryFailed() {
|
|
|
87
90
|
mountProduct(el);
|
|
88
91
|
});
|
|
89
92
|
}
|
|
93
|
+
|
|
94
|
+
// Server-rendered products: one batched background request refreshes them all.
|
|
95
|
+
let refreshQueue = new Set();
|
|
96
|
+
function queueRefresh(key) {
|
|
97
|
+
if (!refreshQueue.size) setTimeout(flushRefresh, 0);
|
|
98
|
+
refreshQueue.add(key);
|
|
99
|
+
}
|
|
100
|
+
function flushRefresh() {
|
|
101
|
+
const keys = [...refreshQueue];
|
|
102
|
+
refreshQueue = new Set();
|
|
103
|
+
refreshProducts(keys).forEach((promise, i) => promise.then((fresh) => {
|
|
104
|
+
liveProducts.forEach((el) => {
|
|
105
|
+
const scope = scopes.get(el);
|
|
106
|
+
if (!scope || !el.isConnected) return;
|
|
107
|
+
const k = resolveProductKey(el).key;
|
|
108
|
+
if (k !== keys[i] && k !== fresh.handle && k !== fresh.id) return;
|
|
109
|
+
if (JSON.stringify(scope.product) !== JSON.stringify(fresh)) scope.update(fresh);
|
|
110
|
+
});
|
|
111
|
+
}).catch((err) => reportError(err, `refresh ${keys[i]}`)));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* <script type="application/json" data-tether-hydrate> tags written by tether/server's hydrate():
|
|
116
|
+
* their products are used without a request. Ignored when they were made for another market.
|
|
117
|
+
*/
|
|
118
|
+
const readScripts = new WeakSet();
|
|
119
|
+
let marketPending = null; // product loads to wait for before html.tether-market-pending goes
|
|
120
|
+
|
|
121
|
+
/** Lists (and anything else that loads prices) wait too. */
|
|
122
|
+
export const trackMarket = (p) => { marketPending?.add(p); return p; };
|
|
123
|
+
|
|
124
|
+
/** After a mount: drop html.tether-market-pending once this page's products are live. */
|
|
125
|
+
export function settleMarket() {
|
|
126
|
+
if (!marketPending) return;
|
|
127
|
+
const waits = [...marketPending];
|
|
128
|
+
Promise.allSettled(waits).then(() => {
|
|
129
|
+
if (!marketPending || waits.some((w) => !marketPending.has(w))) return;
|
|
130
|
+
if (waits.length === marketPending.size) {
|
|
131
|
+
marketPending = null;
|
|
132
|
+
document.documentElement.classList.remove(`${cfg.prefix}-market-pending`);
|
|
133
|
+
} else settleMarket(); // more products came in meanwhile
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function hydrateFromPage(root = document) {
|
|
138
|
+
qsa(root.ownerDocument || root, 'script[type="application/json"][data-tether-hydrate]').forEach((s) => {
|
|
139
|
+
if (readScripts.has(s)) return;
|
|
140
|
+
readScripts.add(s);
|
|
141
|
+
try {
|
|
142
|
+
const data = JSON.parse(s.textContent);
|
|
143
|
+
rememberOptionNames(data.products); // the build's option names, also for other languages
|
|
144
|
+
const same = (a, b) => String(a || '').toUpperCase() === String(b || '').toUpperCase();
|
|
145
|
+
if (same(data.country, cfg.country) && same(data.language, cfg.language)) hydrateProducts(data.products);
|
|
146
|
+
else {
|
|
147
|
+
// The page was built for another market: its prices aren't this visitor's. Mark the page
|
|
148
|
+
// (html.tether-market-pending) until the live ones are in, so CSS can hide prices meanwhile.
|
|
149
|
+
log('hydration skipped: other market');
|
|
150
|
+
if (!marketPending) { marketPending = new Set(); document.documentElement.classList.add(`${cfg.prefix}-market-pending`); }
|
|
151
|
+
}
|
|
152
|
+
} catch (err) { warn('bad data-tether-hydrate', err); }
|
|
153
|
+
});
|
|
154
|
+
}
|