@occluze/tether 0.3.6 → 0.5.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 -7
- package/dist/esm/astro.js +2 -0
- package/dist/esm/chunks/chunk-HHMOPDZG.js +1 -0
- package/dist/esm/chunks/chunk-HXKV6QGB.js +4 -0
- package/dist/esm/chunks/chunk-RBVRRQIU.js +1 -0
- package/dist/esm/chunks/chunk-RNZMTQTX.js +1 -0
- package/dist/esm/chunks/chunk-VC46IEJQ.js +1 -0
- package/dist/esm/chunks/diagnose-J667GHGS.js +1 -0
- package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/server.js +1 -0
- package/dist/presets/barba.esm.js +4 -5
- package/dist/tether.diagnose.js +320 -0
- package/dist/tether.diagnose.min.js +1 -0
- package/dist/tether.esm.js +2 -2608
- package/dist/tether.js +637 -833
- package/dist/tether.markets.js +213 -0
- package/dist/tether.markets.min.js +1 -0
- package/dist/tether.min.js +3 -132
- package/dist/tether.min.js.map +4 -4
- package/package.json +52 -11
- package/src/astro.js +73 -0
- package/src/cart.js +79 -7
- package/src/client.js +25 -15
- package/src/config.js +4 -0
- package/src/diagnose.js +1 -1
- package/src/dom.js +32 -9
- package/src/fragments.js +12 -11
- package/src/index.js +80 -160
- package/src/initial.js +66 -0
- package/src/lazy-iife.js +43 -0
- package/src/lazy.js +9 -0
- package/src/loader.js +50 -7
- package/src/markets-ui.js +89 -0
- package/src/markets.js +1 -1
- package/src/mounting.js +129 -0
- package/src/parts/diagnose.js +3 -0
- package/src/parts/markets.js +3 -0
- package/src/product.js +86 -61
- package/src/server.js +179 -0
- package/src/status.js +2 -6
- package/types/astro.d.ts +15 -0
- package/types/barba.d.ts +16 -0
- package/types/index.d.ts +347 -0
- package/types/server.d.ts +94 -0
package/src/index.js
CHANGED
|
@@ -9,43 +9,29 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { cfg, configure, isConfigured, log, warn, reportError } from './config.js';
|
|
11
11
|
import { gql, TetherError, withContext } from './client.js';
|
|
12
|
-
import { Cart } from './cart.js';
|
|
13
|
-
import {
|
|
12
|
+
import { Cart, focusables } from './cart.js';
|
|
13
|
+
import { scopes } from './product.js';
|
|
14
14
|
import { ListScope, listSelector } from './collection.js';
|
|
15
15
|
import {
|
|
16
16
|
getProduct, getProducts, getRecommendations, clearProductCache, primeProduct, normalizeProductKey,
|
|
17
17
|
} from './loader.js';
|
|
18
18
|
import * as plans from './selling-plans.js';
|
|
19
|
-
import { resolveProductKey
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
export const VERSION = '0.
|
|
25
|
-
|
|
26
|
-
const mountedProducts = new WeakSet();
|
|
27
|
-
const mountedLists = new WeakSet();
|
|
28
|
-
// Live roots, for re-rendering after a country switch (pruned when disconnected).
|
|
29
|
-
const liveProducts = new Set();
|
|
30
|
-
const liveLists = new Set();
|
|
31
|
-
const requestToken = new WeakMap(); // product root -> latest request, so a slow old response can't win
|
|
32
|
-
|
|
33
|
-
function pruneLive() {
|
|
34
|
-
liveProducts.forEach((el) => { if (!el.isConnected) liveProducts.delete(el); });
|
|
35
|
-
liveLists.forEach((l) => { if (!l.root.isConnected) liveLists.delete(l); });
|
|
36
|
-
}
|
|
19
|
+
import { resolveProductKey } from './status.js';
|
|
20
|
+
import { attr, sel, get, qsa, emit, formatMoney, busy, parseJSONAttr, toAttributes, imageUrl } from './dom.js';
|
|
21
|
+
import { loadPart, coreModules } from './lazy.js';
|
|
22
|
+
import { mountedLists, liveLists, pruneLive, within, mountProduct, retryFailed, hydrateFromPage } from './mounting.js';
|
|
23
|
+
|
|
24
|
+
export const VERSION = '0.5.0';
|
|
25
|
+
|
|
37
26
|
let initPromise = null;
|
|
38
27
|
let observer = null;
|
|
39
28
|
|
|
40
|
-
function within(root, selector) {
|
|
41
|
-
return [...(root.matches?.(selector) ? [root] : []), ...qsa(root, selector)];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
29
|
/** Mount every product scope and product list inside `root`. Safe to call repeatedly. */
|
|
45
30
|
export function mount(root = document) {
|
|
46
31
|
if (!isConfigured()) return 0;
|
|
47
32
|
let count = 0;
|
|
48
33
|
pruneLive(); // page transitions detach old roots: don't keep them alive
|
|
34
|
+
hydrateFromPage(root); // products the server already rendered: no request needed
|
|
49
35
|
|
|
50
36
|
within(root, listSelector()).forEach((el) => {
|
|
51
37
|
if (mountedLists.has(el)) return;
|
|
@@ -59,7 +45,10 @@ export function mount(root = document) {
|
|
|
59
45
|
|
|
60
46
|
within(root, sel('product')).forEach((el) => { if (mountProduct(el)) count++; });
|
|
61
47
|
|
|
62
|
-
|
|
48
|
+
// The country select lives in the optional markets part: load it only when the page uses it.
|
|
49
|
+
if (within(root, `${sel('country-select')},${sel('country-bind')}`).length) {
|
|
50
|
+
loadPart('markets').then((m) => m.renderCountrySelects(root)).catch((err) => reportError(err, 'markets'));
|
|
51
|
+
}
|
|
63
52
|
|
|
64
53
|
qsa(root, sel('account-link')).forEach((a) => {
|
|
65
54
|
a.setAttribute('href', `https://${cfg.domain}/account`);
|
|
@@ -70,132 +59,10 @@ export function mount(root = document) {
|
|
|
70
59
|
return count;
|
|
71
60
|
}
|
|
72
61
|
|
|
73
|
-
/** Mounts one product root. Returns true when a product request was started. */
|
|
74
|
-
function mountProduct(el) {
|
|
75
|
-
if (mountedProducts.has(el)) return false;
|
|
76
|
-
const { key, fromUrl, raw } = resolveProductKey(el);
|
|
77
|
-
if (!key) {
|
|
78
|
-
if (fromUrl) {
|
|
79
|
-
// Dynamic product page opened without ?handle= (or an empty path).
|
|
80
|
-
mountedProducts.add(el);
|
|
81
|
-
setScopeStatus(el, 'not-found');
|
|
82
|
-
emit('product-not-found', { key: null, source: raw }, el);
|
|
83
|
-
} else if (!el.closest(listSelector())) {
|
|
84
|
-
// List cards get their handle when the list renders.
|
|
85
|
-
warn(`[${attr('product')}] without a value. Bind it to the Shopify handle field, or use "url:handle".`, el);
|
|
86
|
-
}
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
mountedProducts.add(el);
|
|
90
|
-
liveProducts.add(el);
|
|
91
|
-
const token = {};
|
|
92
|
-
requestToken.set(el, token);
|
|
93
|
-
const current = () => requestToken.get(el) === token;
|
|
94
|
-
// A rendered scope keeps showing its content while it refreshes (e.g. country switch).
|
|
95
|
-
if (!scopes.get(el)) setScopeStatus(el, 'loading');
|
|
96
|
-
else el.setAttribute(attr('state'), 'updating');
|
|
97
|
-
getProduct(key)
|
|
98
|
-
.then((p) => {
|
|
99
|
-
if (!current()) return;
|
|
100
|
-
const existing = scopes.get(el);
|
|
101
|
-
if (existing) existing.update(p);
|
|
102
|
-
// eslint-disable-next-line no-new
|
|
103
|
-
else new ProductScope(el, p);
|
|
104
|
-
applyPageTitle(el, p);
|
|
105
|
-
})
|
|
106
|
-
.catch((err) => {
|
|
107
|
-
if (!current()) return;
|
|
108
|
-
const notFound = isNotFound(err);
|
|
109
|
-
setScopeStatus(el, notFound ? 'not-found' : 'error', err);
|
|
110
|
-
if (notFound) {
|
|
111
|
-
emit('product-not-found', { key, source: raw }, el);
|
|
112
|
-
// A typo in a URL isn't worth an alert; a CMS item pointing at a deleted product is.
|
|
113
|
-
if (fromUrl) log(`product not found: ${key}`); else reportError(err, `product ${key}`);
|
|
114
|
-
} else {
|
|
115
|
-
reportError(err, `product ${key}`);
|
|
116
|
-
emit('error', { error: err, context: 'product', key }, el);
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
return true;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
62
|
// ---------------------------------------------------------------------------
|
|
123
63
|
// Markets: country select + switching
|
|
124
64
|
// ---------------------------------------------------------------------------
|
|
125
65
|
|
|
126
|
-
/**
|
|
127
|
-
* select[tether-country-select]: countries the store sells to (Shopify Markets).
|
|
128
|
-
* tether-country-label="{name} ({currency})" sets the option text ({name}, {code}, {currency}, {symbol}).
|
|
129
|
-
* tether-country-bind="name|code|currency|symbol" shows the current country anywhere.
|
|
130
|
-
*/
|
|
131
|
-
function renderCountrySelects(root = document) {
|
|
132
|
-
const selects = within(root, `select${sel('country-select')}`);
|
|
133
|
-
const binds = within(root, sel('country-bind'));
|
|
134
|
-
if (!selects.length && !binds.length) return Promise.resolve();
|
|
135
|
-
return getLocalization().then((loc) => {
|
|
136
|
-
const current = (cfg.country || loc.country?.isoCode || '').toUpperCase();
|
|
137
|
-
const countries = [...(loc.availableCountries || [])].sort((a, b) => a.name.localeCompare(b.name, cfg.locale || undefined));
|
|
138
|
-
const info = countries.find((c) => c.isoCode === current) || loc.country;
|
|
139
|
-
const fill = (tpl, c) => tpl.replace(/\{name\}/g, c.name).replace(/\{code\}/g, c.isoCode)
|
|
140
|
-
.replace(/\{currency\}/g, c.currency?.isoCode || '').replace(/\{symbol\}/g, c.currency?.symbol || '');
|
|
141
|
-
selects.forEach((s) => {
|
|
142
|
-
const tpl = s.getAttribute(attr('country-label')) || '{name} ({currency})';
|
|
143
|
-
s.innerHTML = '';
|
|
144
|
-
countries.forEach((c) => s.appendChild(new Option(fill(tpl, c), c.isoCode)));
|
|
145
|
-
s.value = current;
|
|
146
|
-
if (s.value !== current) s.selectedIndex = -1; // a country the store doesn't sell to
|
|
147
|
-
s.disabled = countries.length < 2 && !s.hasAttribute(attr('always-enabled'));
|
|
148
|
-
});
|
|
149
|
-
binds.forEach((el) => {
|
|
150
|
-
const k = get(el, 'country-bind') || 'name';
|
|
151
|
-
const v = { name: info?.name, code: info?.isoCode, currency: info?.currency?.isoCode, symbol: info?.currency?.symbol }[k];
|
|
152
|
-
el.textContent = v || '';
|
|
153
|
-
});
|
|
154
|
-
}).catch((err) => reportError(err, 'country select'));
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Switches the market: prices, currency and translations follow (@inContext), the cart's
|
|
159
|
-
* buyerIdentity.countryCode is updated, and every product and list on the page re-renders.
|
|
160
|
-
* Tether.setCountry('DE') Tether.setCountry('BE', { language: 'FR' })
|
|
161
|
-
*/
|
|
162
|
-
export async function setCountry(code, { language, remember = true } = {}) {
|
|
163
|
-
const country = toCountry(code);
|
|
164
|
-
if (!country) throw new TetherError(`Not a country code: ${code}`, { code: 'INVALID_COUNTRY' });
|
|
165
|
-
const lang = language === undefined ? cfg.language : toLanguage(language);
|
|
166
|
-
const changed = country !== String(cfg.country || '').toUpperCase() || lang !== cfg.language;
|
|
167
|
-
cfg.country = country;
|
|
168
|
-
cfg.language = lang || undefined;
|
|
169
|
-
if (remember) rememberMarket({ country, language: lang });
|
|
170
|
-
if (!changed) { renderCountrySelects(); return Cart.state; }
|
|
171
|
-
|
|
172
|
-
clearProductCache();
|
|
173
|
-
const jobs = [];
|
|
174
|
-
if (language !== undefined) clearLocalization(); // country names follow the language
|
|
175
|
-
// Only an existing cart moves; switching country never creates one.
|
|
176
|
-
if (Cart.id()) jobs.push(Cart.setBuyerIdentity({ countryCode: country }, { create: false }).catch((err) => reportError(err, 'cart country')));
|
|
177
|
-
|
|
178
|
-
pruneLive();
|
|
179
|
-
liveLists.forEach((list) => jobs.push(list.load()));
|
|
180
|
-
liveProducts.forEach((el) => {
|
|
181
|
-
if (el.closest(listSelector())) return; // re-rendered by its list
|
|
182
|
-
mountedProducts.delete(el);
|
|
183
|
-
mountProduct(el);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
renderCountrySelects();
|
|
187
|
-
emit('country-changed', { country, language: cfg.language });
|
|
188
|
-
await Promise.all(jobs);
|
|
189
|
-
return Cart.state;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** tether-page-title="{title} | My shop" on a product root sets document.title (dynamic product pages). */
|
|
193
|
-
function applyPageTitle(el, p) {
|
|
194
|
-
const tpl = get(el, 'page-title');
|
|
195
|
-
if (!tpl || typeof document === 'undefined') return;
|
|
196
|
-
document.title = tpl.replace(/\{title\}/g, p.title).replace(/\{vendor\}/g, p.vendor || '').replace(/\{type\}/g, p.productType || '');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
66
|
/** Call before a page transition removes `root` (closes the drawer etc.). */
|
|
200
67
|
export function unmount() {
|
|
201
68
|
Cart.close();
|
|
@@ -278,14 +145,43 @@ function bindControls() {
|
|
|
278
145
|
try {
|
|
279
146
|
const cart = await Cart.applyDiscount(code);
|
|
280
147
|
const ok = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === code.toLowerCase())?.applicable;
|
|
148
|
+
// An invalid code doesn't stay in the cart (Shopify would warn about it on every change).
|
|
149
|
+
if (!ok) await Cart.removeDiscount(code).catch(() => {});
|
|
281
150
|
form.classList.toggle('is-invalid', !ok);
|
|
282
151
|
emit(ok ? 'discount-applied' : 'discount-invalid', { code }, form);
|
|
283
152
|
if (ok && input) input.value = '';
|
|
284
153
|
} catch (err) { form.classList.add('is-invalid'); reportError(err, 'discount'); }
|
|
285
154
|
});
|
|
155
|
+
}, true); // capture: before a router (Astro's ClientRouter) turns the submit into a navigation
|
|
156
|
+
document.addEventListener('input', (e) => { e.target.closest?.(sel('discount-form'))?.classList.remove('is-invalid'); });
|
|
157
|
+
|
|
158
|
+
// Astro View Transitions (<ClientRouter />): mount every new page, close the cart while leaving.
|
|
159
|
+
if (cfg.astro !== false) {
|
|
160
|
+
document.addEventListener('astro:page-load', () => mount(document));
|
|
161
|
+
document.addEventListener('astro:before-swap', () => unmount());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Back online after a failed load: try the failed products and lists again.
|
|
165
|
+
window.addEventListener('online', () => {
|
|
166
|
+
retryFailed();
|
|
167
|
+
liveLists.forEach((l) => { if (l.root.getAttribute(attr('state')) === 'error') l.load(); });
|
|
286
168
|
});
|
|
287
169
|
|
|
288
|
-
document.addEventListener('keydown', (e) => {
|
|
170
|
+
document.addEventListener('keydown', (e) => {
|
|
171
|
+
if (!Cart.isOpen()) return;
|
|
172
|
+
if (e.key === 'Escape') { Cart.close(); return; }
|
|
173
|
+
if (e.key !== 'Tab' || cfg.cartFocus === false) return;
|
|
174
|
+
// Keep Tab inside the open cart.
|
|
175
|
+
const d = Cart.drawers().find((x) => x.classList.contains(cfg.cartDrawerOpenClass));
|
|
176
|
+
const f = d ? focusables(d) : [];
|
|
177
|
+
if (!f.length) return;
|
|
178
|
+
const first = f[0];
|
|
179
|
+
const last = f[f.length - 1];
|
|
180
|
+
if (!d.contains(document.activeElement) || (e.shiftKey && document.activeElement === first) || (!e.shiftKey && document.activeElement === last)) {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
(e.shiftKey ? last : first).focus();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
289
185
|
}
|
|
290
186
|
|
|
291
187
|
function dataKeySel() { return `[data-${cfg.prefix}-key]`; }
|
|
@@ -333,17 +229,10 @@ export function init(overrides = {}) {
|
|
|
333
229
|
return initPromise;
|
|
334
230
|
}
|
|
335
231
|
|
|
336
|
-
// Detected market (saved choice, server hint, Webflow locale) wins over the static config.
|
|
337
|
-
const market = detectMarket(cfg.markets);
|
|
338
|
-
if (market.country) cfg.country = market.country;
|
|
339
|
-
if (market.language) cfg.language = market.language;
|
|
340
|
-
if (market.locale && !overrides.locale && !window.TETHER_CONFIG?.locale) cfg.locale = market.locale;
|
|
341
|
-
if (market.sources && Object.keys(market.sources).length) log('market', market);
|
|
342
|
-
|
|
343
232
|
bindControls();
|
|
344
233
|
if (cfg.observe) startObserver();
|
|
345
234
|
|
|
346
|
-
initPromise = Cart.init().then(async (cart) => {
|
|
235
|
+
initPromise = applyMarket(overrides).then(() => Cart.init()).then(async (cart) => {
|
|
347
236
|
// A saved cart from another market: move it to this one, so prices match the page.
|
|
348
237
|
const want = cfg.country ? String(cfg.country).toUpperCase() : null;
|
|
349
238
|
if (cart && want && cart.buyerIdentity?.countryCode && cart.buyerIdentity.countryCode !== want) {
|
|
@@ -355,6 +244,33 @@ export function init(overrides = {}) {
|
|
|
355
244
|
return initPromise;
|
|
356
245
|
}
|
|
357
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Detected market (saved choice, server hint, Webflow locale) wins over the static config.
|
|
249
|
+
* Without markets.detect only the visitor's saved choice applies, read here without loading
|
|
250
|
+
* the markets part.
|
|
251
|
+
*/
|
|
252
|
+
async function applyMarket(overrides) {
|
|
253
|
+
const m = cfg.markets || {};
|
|
254
|
+
if (m.detect === false) return;
|
|
255
|
+
let market = null;
|
|
256
|
+
if (m.detect) {
|
|
257
|
+
try { market = (await loadPart('markets')).detectMarket(m); } catch (err) { reportError(err, 'markets'); return; }
|
|
258
|
+
} else if (m.remember !== false) {
|
|
259
|
+
try { market = JSON.parse(localStorage.getItem(`${cfg.prefix}:market:${cfg.domain}`) || 'null'); } catch { /* none */ }
|
|
260
|
+
if (market && !/^[A-Z]{2}$/.test(market.country || '')) market = null;
|
|
261
|
+
}
|
|
262
|
+
if (!market) return;
|
|
263
|
+
if (market.country) cfg.country = market.country;
|
|
264
|
+
if (market.language) cfg.language = market.language;
|
|
265
|
+
if (market.locale && !overrides.locale) cfg.locale = market.locale; // the visitor's market wins over the default
|
|
266
|
+
log('market', market);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Tether.setCountry('DE'): loads the markets part on first use. */
|
|
270
|
+
export function setCountry(code, opts) {
|
|
271
|
+
return loadPart('markets').then((m) => m.setCountry(code, opts));
|
|
272
|
+
}
|
|
273
|
+
|
|
358
274
|
function startObserver() {
|
|
359
275
|
if (observer || typeof MutationObserver === 'undefined') return;
|
|
360
276
|
let t = null;
|
|
@@ -386,13 +302,16 @@ export const api = {
|
|
|
386
302
|
normalizeProductKey,
|
|
387
303
|
clearProductCache,
|
|
388
304
|
formatMoney,
|
|
305
|
+
imageUrl,
|
|
389
306
|
plans,
|
|
390
307
|
TetherError,
|
|
391
308
|
resolveProductKey,
|
|
392
309
|
setCountry,
|
|
393
|
-
detectMarket: (markets) => detectMarket(markets || cfg.markets),
|
|
394
|
-
getLocalization,
|
|
395
|
-
diagnose: (opts) => diagnose({ ...opts, version: VERSION }),
|
|
310
|
+
detectMarket: (markets) => loadPart('markets').then((m) => m.detectMarket(markets || cfg.markets)),
|
|
311
|
+
getLocalization: () => loadPart('markets').then((m) => m.getLocalization()),
|
|
312
|
+
diagnose: (opts) => loadPart('diagnose').then((m) => m.diagnose({ ...opts, version: VERSION })),
|
|
313
|
+
__core: coreModules,
|
|
314
|
+
__parts: {},
|
|
396
315
|
on(name, fn) {
|
|
397
316
|
const h = (e) => fn(e.detail, e);
|
|
398
317
|
document.addEventListener(`${cfg.prefix}:${name}`, h);
|
|
@@ -405,7 +324,8 @@ export default api;
|
|
|
405
324
|
// Auto-start for the plain <script> setup.
|
|
406
325
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
407
326
|
window.Tether = api;
|
|
408
|
-
|
|
327
|
+
// Not with a framework integration (Astro), which starts Tether itself.
|
|
328
|
+
const auto = window.TETHER_CONFIG && window.TETHER_CONFIG.autoMount !== false && !window.TETHER_INTEGRATION;
|
|
409
329
|
if (auto) {
|
|
410
330
|
const start = () => init().then(() => mount(document));
|
|
411
331
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
|
package/src/initial.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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' } = {}) {
|
|
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
|
+
|
|
30
|
+
return {
|
|
31
|
+
variant: v,
|
|
32
|
+
selected,
|
|
33
|
+
purchase,
|
|
34
|
+
plan: sub ? plan : null,
|
|
35
|
+
/** The plan list (tether-plan-list="all"): selected one gets is-selected, unavailable ones is-unavailable. */
|
|
36
|
+
plans: plans.map((p) => ({
|
|
37
|
+
id: p.id, name: p.name, price: planPrice(v, p.id), savings: planSavings(v, p.id).percentage,
|
|
38
|
+
selected: sub && p.id === plan.id, available: Boolean(allocationFor(v, p.id)),
|
|
39
|
+
})),
|
|
40
|
+
/** Savings of the selected plan in % (tether-bind="subscription-savings"), 0 without one. */
|
|
41
|
+
savings: plan ? planSavings(v, plan.id).percentage : 0,
|
|
42
|
+
price,
|
|
43
|
+
compareAtPrice: onSale ? compareAt : null,
|
|
44
|
+
available: v.availableForSale,
|
|
45
|
+
/** The tether-show conditions, e.g. hidden={!state.show['out-of-stock']}. */
|
|
46
|
+
show: {
|
|
47
|
+
'in-stock': v.availableForSale,
|
|
48
|
+
'out-of-stock': !v.availableForSale,
|
|
49
|
+
backorder: Boolean(v.availableForSale && v.currentlyNotInStock),
|
|
50
|
+
'on-sale': onSale,
|
|
51
|
+
'not-on-sale': !onSale,
|
|
52
|
+
'has-variants': vs.length > 1,
|
|
53
|
+
'single-variant': vs.length === 1,
|
|
54
|
+
'low-stock': q != null && q > 0 && q <= 5,
|
|
55
|
+
'subscription-available': subscriptionAvailable,
|
|
56
|
+
'subscription-unavailable': !subscriptionAvailable,
|
|
57
|
+
'subscription-only': Boolean(product.requiresSellingPlan),
|
|
58
|
+
'one-time-available': !product.requiresSellingPlan,
|
|
59
|
+
'subscription-selected': sub,
|
|
60
|
+
'one-time-selected': !sub,
|
|
61
|
+
},
|
|
62
|
+
/** Option values with no available variant (given the other selected options): class is-unavailable. */
|
|
63
|
+
isUnavailable: (name, value) => !vs.some((x) => x.availableForSale && x.selectedOptions.every((o) =>
|
|
64
|
+
(o.name === name ? o.value === value : selected[o.name] === o.value))),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/lazy-iife.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <script> build: optional parts are separate files next to tether(.min).js, e.g.
|
|
3
|
+
* tether.markets.min.js, loaded on first use. Override the folder with config.partsUrl.
|
|
4
|
+
*/
|
|
5
|
+
import * as config from './config.js';
|
|
6
|
+
import * as client from './client.js';
|
|
7
|
+
import * as dom from './dom.js';
|
|
8
|
+
import * as fragments from './fragments.js';
|
|
9
|
+
import * as loader from './loader.js';
|
|
10
|
+
import * as sellingPlans from './selling-plans.js';
|
|
11
|
+
import * as status from './status.js';
|
|
12
|
+
import * as cart from './cart.js';
|
|
13
|
+
import * as product from './product.js';
|
|
14
|
+
import * as collection from './collection.js';
|
|
15
|
+
import * as mounting from './mounting.js';
|
|
16
|
+
|
|
17
|
+
const { cfg } = config;
|
|
18
|
+
|
|
19
|
+
/** The modules a part may use, shared instead of bundled twice (scripts/build.mjs maps imports to these). */
|
|
20
|
+
export const coreModules = {
|
|
21
|
+
'config.js': config, 'client.js': client, 'dom.js': dom, 'fragments.js': fragments, 'loader.js': loader,
|
|
22
|
+
'selling-plans.js': sellingPlans, 'status.js': status, 'cart.js': cart, 'product.js': product,
|
|
23
|
+
'collection.js': collection, 'mounting.js': mounting,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const self = typeof document !== 'undefined' ? document.currentScript?.src || '' : '';
|
|
27
|
+
const parts = {};
|
|
28
|
+
|
|
29
|
+
export function loadPart(name) {
|
|
30
|
+
if (parts[name]) return parts[name];
|
|
31
|
+
const base = cfg.partsUrl || self.replace(/[^/]*$/, '');
|
|
32
|
+
const min = /\.min\.js(\?|$)/.test(self) || !self ? '.min' : '';
|
|
33
|
+
const url = `${base.replace(/\/?$/, '/')}tether.${name}${min}.js`;
|
|
34
|
+
parts[name] = new Promise((resolve, reject) => {
|
|
35
|
+
const s = document.createElement('script');
|
|
36
|
+
s.src = url;
|
|
37
|
+
s.async = true;
|
|
38
|
+
s.onload = () => (window.Tether?.__parts?.[name] ? resolve(window.Tether.__parts[name]) : reject(new Error(`${url} did not register`)));
|
|
39
|
+
s.onerror = () => { delete parts[name]; reject(new Error(`Could not load ${url}. Host it next to the Tether script, or set partsUrl.`)); };
|
|
40
|
+
document.head.appendChild(s);
|
|
41
|
+
});
|
|
42
|
+
return parts[name];
|
|
43
|
+
}
|
package/src/lazy.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional parts that most pages never need, loaded on first use.
|
|
3
|
+
* ESM / Node: dynamic import (bundlers split these into their own chunks).
|
|
4
|
+
* The <script> build swaps this file for lazy-iife.js (see scripts/build.mjs).
|
|
5
|
+
*/
|
|
6
|
+
export const loadPart = (name) => (name === 'diagnose' ? import('./diagnose.js') : import('./markets-ui.js'));
|
|
7
|
+
|
|
8
|
+
// Only the <script> build shares its modules with separately loaded parts.
|
|
9
|
+
export const coreModules = undefined;
|
package/src/loader.js
CHANGED
|
@@ -74,7 +74,7 @@ async function flush() {
|
|
|
74
74
|
parts.push(`h${i}: product(handle: $h${i}) { ...TetherProduct }`);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
const query = `query TetherProducts(${defs.join(',
|
|
77
|
+
const query = `query TetherProducts(${defs.join(',')}) { ${parts.join(' ')} } ${productFragment()}`;
|
|
78
78
|
log('fetch products', keys);
|
|
79
79
|
|
|
80
80
|
const settle = (k, fn) => {
|
|
@@ -90,7 +90,11 @@ 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)) {
|
|
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 {
|
|
94
98
|
if (gen === generation) productCache.delete(k); // don't cache misses: a fix in Shopify works on next load
|
|
95
99
|
r.reject(new TetherError(`Product not found: ${k}`, { code: 'NOT_FOUND' }));
|
|
96
100
|
}
|
|
@@ -100,7 +104,44 @@ async function flush() {
|
|
|
100
104
|
}
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
export function clearProductCache() { productCache.clear(); generation++; }
|
|
107
|
+
export function clearProductCache() { productCache.clear(); generation++; stale.clear(); freshAt.clear(); }
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Hydration: products rendered on the server (tether/server hydrate()) are used at once and
|
|
111
|
+
// refreshed once in the background, so prices and stock are live without a loading state.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
const stale = new Set(); // keys of products that came from the server and weren't refreshed yet
|
|
115
|
+
const freshAt = new Map(); // key -> when it was last loaded live (this page session)
|
|
116
|
+
const FRESH_MS = 60000;
|
|
117
|
+
|
|
118
|
+
export function hydrateProducts(products) {
|
|
119
|
+
(products || []).forEach((p) => {
|
|
120
|
+
if (!p?.id || !p.handle) return;
|
|
121
|
+
// Loaded live in the last minute (an earlier page of a View Transition visit): that copy is newer.
|
|
122
|
+
if (Date.now() - (freshAt.get(p.handle) || 0) < FRESH_MS && productCache.has(p.handle)) return;
|
|
123
|
+
primeProduct(p);
|
|
124
|
+
stale.add(p.handle);
|
|
125
|
+
stale.add(p.id);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** True once per server-provided product: the caller refreshes it. */
|
|
130
|
+
export function takeStale(key) {
|
|
131
|
+
const k = normalizeProductKey(key);
|
|
132
|
+
if (!stale.has(k)) return false;
|
|
133
|
+
const p = productCache.get(k);
|
|
134
|
+
stale.delete(k);
|
|
135
|
+
// Both keys point to the same product: refresh it once.
|
|
136
|
+
p?.then?.((x) => { stale.delete(x.handle); stale.delete(x.id); });
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fetch these products again (bypassing the cache). */
|
|
141
|
+
export function refreshProducts(keys) {
|
|
142
|
+
keys.forEach((k) => productCache.delete(normalizeProductKey(k)));
|
|
143
|
+
return keys.map((k) => getProduct(k));
|
|
144
|
+
}
|
|
104
145
|
|
|
105
146
|
/**
|
|
106
147
|
* A page of products from a collection (by handle) or from a search query.
|
|
@@ -141,11 +182,13 @@ ${fragment}`, { first, after, query: query || undefined, sortKey: sortKey || und
|
|
|
141
182
|
return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
|
|
142
183
|
}
|
|
143
184
|
|
|
144
|
-
export async function getRecommendations(
|
|
145
|
-
const
|
|
146
|
-
|
|
185
|
+
export async function getRecommendations(product, intent = 'RELATED') {
|
|
186
|
+
const key = normalizeProductKey(typeof product === 'object' ? product?.id : product);
|
|
187
|
+
const byId = key?.startsWith('gid://');
|
|
188
|
+
const data = await gql(`query TetherRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
|
|
189
|
+
productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
|
|
147
190
|
}
|
|
148
|
-
${productFragment()}`, { id:
|
|
191
|
+
${productFragment()}`, byId ? { id: key, intent } : { handle: key, intent });
|
|
149
192
|
const list = data.productRecommendations || [];
|
|
150
193
|
list.forEach(primeProduct);
|
|
151
194
|
return list;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional part, loaded on first use: market detection, select[tether-country-select],
|
|
3
|
+
* tether-country-bind and Tether.setCountry(). See markets.js for the detection rules.
|
|
4
|
+
*/
|
|
5
|
+
import { cfg, reportError } from './config.js';
|
|
6
|
+
import { TetherError } from './client.js';
|
|
7
|
+
import { Cart } from './cart.js';
|
|
8
|
+
import { listSelector } from './collection.js';
|
|
9
|
+
import { clearProductCache } from './loader.js';
|
|
10
|
+
import { attr, sel, get, emit } from './dom.js';
|
|
11
|
+
import { within, pruneLive, liveLists, liveProducts, mountedProducts, mountProduct } from './mounting.js';
|
|
12
|
+
import { rememberMarket, getLocalization, clearLocalization, toCountry, toLanguage } from './markets.js';
|
|
13
|
+
|
|
14
|
+
export { detectMarket, getLocalization } from './markets.js';
|
|
15
|
+
|
|
16
|
+
export /**
|
|
17
|
+
* select[tether-country-select]: countries the store sells to (Shopify Markets).
|
|
18
|
+
* tether-country-label="{name} ({currency})" sets the option text ({name}, {code}, {currency}, {symbol}).
|
|
19
|
+
* tether-country-bind="name|code|currency|symbol" shows the current country anywhere.
|
|
20
|
+
*/
|
|
21
|
+
function renderCountrySelects(root = document) {
|
|
22
|
+
const selects = within(root, `select${sel('country-select')}`);
|
|
23
|
+
const binds = within(root, sel('country-bind'));
|
|
24
|
+
if (!selects.length && !binds.length) return Promise.resolve();
|
|
25
|
+
return getLocalization().then((loc) => {
|
|
26
|
+
const current = (cfg.country || loc.country?.isoCode || '').toUpperCase();
|
|
27
|
+
const countries = [...(loc.availableCountries || [])].sort((a, b) => a.name.localeCompare(b.name, cfg.locale || undefined));
|
|
28
|
+
const info = countries.find((c) => c.isoCode === current) || loc.country;
|
|
29
|
+
const fill = (tpl, c) => tpl.replace(/\{name\}/g, c.name).replace(/\{code\}/g, c.isoCode)
|
|
30
|
+
.replace(/\{currency\}/g, c.currency?.isoCode || '').replace(/\{symbol\}/g, c.currency?.symbol || '');
|
|
31
|
+
selects.forEach((s) => {
|
|
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;
|
|
36
|
+
s.innerHTML = '';
|
|
37
|
+
list.forEach((c) => s.appendChild(new Option(fill(tpl, c), c.isoCode)));
|
|
38
|
+
s.value = current;
|
|
39
|
+
if (s.value !== current) s.selectedIndex = -1; // a country the store doesn't sell to
|
|
40
|
+
s.disabled = list.length < 2 && !s.hasAttribute(attr('always-enabled'));
|
|
41
|
+
});
|
|
42
|
+
binds.forEach((el) => {
|
|
43
|
+
const k = get(el, 'country-bind') || 'name';
|
|
44
|
+
const v = { name: info?.name, code: info?.isoCode, currency: info?.currency?.isoCode, symbol: info?.currency?.symbol }[k];
|
|
45
|
+
el.textContent = v || '';
|
|
46
|
+
});
|
|
47
|
+
}).catch((err) => reportError(err, 'country select'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Switches the market: prices, currency and translations follow (@inContext), the cart's
|
|
52
|
+
* buyerIdentity.countryCode is updated, and every product and list on the page re-renders.
|
|
53
|
+
* Tether.setCountry('DE') Tether.setCountry('BE', { language: 'FR' })
|
|
54
|
+
*/
|
|
55
|
+
export async function setCountry(code, { language, locale, remember = true } = {}) {
|
|
56
|
+
const country = toCountry(code);
|
|
57
|
+
if (!country) throw new TetherError(`Not a country code: ${code}`, { code: 'INVALID_COUNTRY' });
|
|
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
|
+
const lang = language === undefined ? cfg.language : toLanguage(language);
|
|
63
|
+
const changed = country !== String(cfg.country || '').toUpperCase() || lang !== cfg.language || loc !== cfg.locale;
|
|
64
|
+
cfg.country = country;
|
|
65
|
+
cfg.language = lang || undefined;
|
|
66
|
+
cfg.locale = loc;
|
|
67
|
+
if (remember) rememberMarket({ country, language: lang, locale: own });
|
|
68
|
+
if (!changed) { renderCountrySelects(); return Cart.state; }
|
|
69
|
+
|
|
70
|
+
clearProductCache();
|
|
71
|
+
const jobs = [];
|
|
72
|
+
if (language !== undefined) clearLocalization(); // country names follow the language
|
|
73
|
+
// Only an existing cart moves; switching country never creates one.
|
|
74
|
+
if (Cart.id()) jobs.push(Cart.setBuyerIdentity({ countryCode: country }, { create: false }).catch((err) => reportError(err, 'cart country')));
|
|
75
|
+
|
|
76
|
+
pruneLive();
|
|
77
|
+
liveLists.forEach((list) => jobs.push(list.load()));
|
|
78
|
+
liveProducts.forEach((el) => {
|
|
79
|
+
if (el.closest(listSelector())) return; // re-rendered by its list
|
|
80
|
+
mountedProducts.delete(el);
|
|
81
|
+
mountProduct(el);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
renderCountrySelects();
|
|
85
|
+
emit('country-changed', { country, language: cfg.language });
|
|
86
|
+
await Promise.all(jobs);
|
|
87
|
+
return Cart.state;
|
|
88
|
+
}
|
|
89
|
+
|
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;
|