@occluze/tether 0.3.5 → 0.4.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 -6
- package/dist/esm/chunks/chunk-GHIUMD5H.js +1 -0
- package/dist/esm/chunks/chunk-KYQ5OGBX.js +1 -0
- package/dist/esm/chunks/chunk-LI3YDMGF.js +4 -0
- package/dist/esm/chunks/diagnose-ZXXXJIQE.js +1 -0
- package/dist/esm/chunks/markets-ui-AG6Y5VUE.js +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/presets/barba.esm.js +4 -5
- package/dist/tether.diagnose.js +319 -0
- package/dist/tether.diagnose.min.js +1 -0
- package/dist/tether.esm.js +2 -2608
- package/dist/tether.js +416 -780
- package/dist/tether.markets.js +207 -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 +29 -11
- package/src/cart.js +32 -0
- package/src/config.js +2 -0
- package/src/index.js +64 -156
- package/src/lazy-iife.js +43 -0
- package/src/lazy.js +9 -0
- package/src/loader.js +1 -1
- package/src/markets-ui.js +81 -0
- package/src/mounting.js +89 -0
- package/src/parts/diagnose.js +3 -0
- package/src/parts/markets.js +3 -0
- package/src/product.js +53 -52
- package/src/status.js +1 -0
- package/types/barba.d.ts +16 -0
- package/types/index.d.ts +332 -0
package/package.json
CHANGED
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@occluze/tether",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Free, open-source alternative to Smootify and Storesynk (Shopyflow): Shopify products, cart and subscriptions in Webflow with HTML attributes. Optional Shopify to Webflow CMS sync.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "dist/
|
|
7
|
-
"module": "dist/
|
|
6
|
+
"main": "dist/esm/index.js",
|
|
7
|
+
"module": "dist/esm/index.js",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
10
|
-
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./types/index.d.ts",
|
|
11
|
+
"default": "./dist/esm/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./presets/barba": {
|
|
14
|
+
"types": "./types/barba.d.ts",
|
|
15
|
+
"default": "./dist/presets/barba.esm.js"
|
|
16
|
+
},
|
|
17
|
+
"./dist/*": "./dist/*"
|
|
11
18
|
},
|
|
12
19
|
"files": [
|
|
13
20
|
"dist",
|
|
14
21
|
"src",
|
|
15
22
|
"LICENSE",
|
|
16
|
-
"README.md"
|
|
23
|
+
"README.md",
|
|
24
|
+
"types/index.d.ts",
|
|
25
|
+
"types/barba.d.ts"
|
|
17
26
|
],
|
|
18
27
|
"scripts": {
|
|
19
28
|
"build": "node scripts/build.mjs",
|
|
20
29
|
"dev": "node scripts/build.mjs --watch",
|
|
21
|
-
"test": "node --test test/*.test.js server/test/*.test.js",
|
|
22
|
-
"smoke": "node scripts/smoke.mjs"
|
|
30
|
+
"test": "node --test test/*.test.js server/test/*.test.js && npm run test:types",
|
|
31
|
+
"smoke": "node scripts/smoke.mjs",
|
|
32
|
+
"pretest": "node scripts/build.mjs",
|
|
33
|
+
"presmoke": "node scripts/build.mjs",
|
|
34
|
+
"test:types": "tsc -p types/tsconfig.json",
|
|
35
|
+
"release": "node scripts/release.mjs"
|
|
23
36
|
},
|
|
24
37
|
"devDependencies": {
|
|
25
38
|
"esbuild": "^0.25.0",
|
|
26
|
-
"jsdom": "^26.0.0"
|
|
39
|
+
"jsdom": "^26.0.0",
|
|
40
|
+
"typescript": "^5.9.3"
|
|
27
41
|
},
|
|
28
42
|
"license": "MIT",
|
|
29
|
-
"author":
|
|
43
|
+
"author": {
|
|
44
|
+
"name": "Ravi Klaassens",
|
|
45
|
+
"url": "https://occluze.com"
|
|
46
|
+
},
|
|
30
47
|
"repository": {
|
|
31
48
|
"type": "git",
|
|
32
49
|
"url": "git+https://github.com/ravi-klaassens/tether.git"
|
|
@@ -55,5 +72,6 @@
|
|
|
55
72
|
],
|
|
56
73
|
"publishConfig": {
|
|
57
74
|
"access": "public"
|
|
58
|
-
}
|
|
75
|
+
},
|
|
76
|
+
"types": "types/index.d.ts"
|
|
59
77
|
}
|
package/src/cart.js
CHANGED
|
@@ -34,6 +34,23 @@ function mutation(name, args, inner) {
|
|
|
34
34
|
${cartFragment()}`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
38
|
+
let returnFocus = null;
|
|
39
|
+
|
|
40
|
+
/** Focusable elements inside an open drawer, in order (visible ones only). */
|
|
41
|
+
export const focusables = (d) => qsa(d, FOCUSABLE).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
42
|
+
|
|
43
|
+
/** aria-expanded on the open buttons; dialog semantics on drawers (unless the markup has its own). */
|
|
44
|
+
function ariaState(open) {
|
|
45
|
+
qsa(document, sel('cart-open')).forEach((b) => b.setAttribute('aria-expanded', String(open)));
|
|
46
|
+
qsa(document, sel('cart-drawer')).forEach((d) => {
|
|
47
|
+
if (!d.hasAttribute('role')) d.setAttribute('role', 'dialog');
|
|
48
|
+
if (!d.hasAttribute('aria-label') && !d.hasAttribute('aria-labelledby')) d.setAttribute('aria-label', get(d, 'cart-label') || 'Cart');
|
|
49
|
+
d.setAttribute('aria-modal', String(open));
|
|
50
|
+
if (!d.classList.contains(cfg.cartDrawerOpenClass)) d.setAttribute('aria-hidden', 'true');
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
const isMissingCart = (err) => /does not exist|not found|invalid id/i.test(err?.message || '');
|
|
38
55
|
|
|
39
56
|
export const Cart = {
|
|
@@ -255,6 +272,16 @@ export const Cart = {
|
|
|
255
272
|
if (this.isOpen()) return;
|
|
256
273
|
const ds = this.drawers().filter((d) => get(d, 'cart-drawer') !== 'manual');
|
|
257
274
|
ds.forEach((d) => { d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
|
|
275
|
+
ariaState(true);
|
|
276
|
+
if (cfg.cartFocus !== false && ds[0]) {
|
|
277
|
+
// Move focus into the cart (after the open transition has made it visible).
|
|
278
|
+
returnFocus = document.activeElement;
|
|
279
|
+
setTimeout(() => {
|
|
280
|
+
const target = focusables(ds[0])[0] || ds[0];
|
|
281
|
+
if (target === ds[0] && !ds[0].hasAttribute('tabindex')) ds[0].tabIndex = -1;
|
|
282
|
+
target.focus?.({ preventScroll: true });
|
|
283
|
+
}, 60);
|
|
284
|
+
}
|
|
258
285
|
if (typeof cfg.onCartOpen === 'function') cfg.onCartOpen(ds);
|
|
259
286
|
emit('cart-toggled', { open: true });
|
|
260
287
|
},
|
|
@@ -262,7 +289,11 @@ export const Cart = {
|
|
|
262
289
|
close() {
|
|
263
290
|
if (!this.isOpen()) return;
|
|
264
291
|
const ds = this.drawers();
|
|
292
|
+
const hadFocus = ds.some((d) => d.contains(document.activeElement));
|
|
265
293
|
ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); });
|
|
294
|
+
ariaState(false);
|
|
295
|
+
if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
|
296
|
+
returnFocus = null;
|
|
266
297
|
if (typeof cfg.onCartClose === 'function') cfg.onCartClose(ds);
|
|
267
298
|
emit('cart-toggled', { open: false });
|
|
268
299
|
},
|
|
@@ -276,6 +307,7 @@ export const Cart = {
|
|
|
276
307
|
const count = cart ? cart.totalQuantity : 0;
|
|
277
308
|
const money = (k) => (cart ? formatMoney(cart.cost?.[k]) : '');
|
|
278
309
|
|
|
310
|
+
ariaState(this.isOpen());
|
|
279
311
|
qsa(document, sel('cart-count')).forEach((el) => setTextTracked(el, count));
|
|
280
312
|
qsa(document, sel('cart-subtotal')).forEach((el) => setTextTracked(el, money('subtotalAmount')));
|
|
281
313
|
qsa(document, sel('cart-total')).forEach((el) => setTextTracked(el, money('totalAmount')));
|
package/src/config.js
CHANGED
|
@@ -30,6 +30,7 @@ export const DEFAULTS = {
|
|
|
30
30
|
storageKey: null, // defaults to `tether:cart:<domain>`
|
|
31
31
|
cartDrawerOpenClass: 'is-open',
|
|
32
32
|
openCartOnAdd: true,
|
|
33
|
+
cartFocus: true, // move focus into the open cart, keep Tab inside it, restore it on close
|
|
33
34
|
checkoutTarget: '_self', // '_self' or '_blank'
|
|
34
35
|
|
|
35
36
|
// Subscriptions
|
|
@@ -38,6 +39,7 @@ export const DEFAULTS = {
|
|
|
38
39
|
// Mounting
|
|
39
40
|
autoMount: true, // mount document on DOMContentLoaded
|
|
40
41
|
observe: false, // auto-mount nodes added later (CMS load-more, filters)
|
|
42
|
+
partsUrl: null, // folder of tether.markets/diagnose(.min).js for the <script> build (default: next to it)
|
|
41
43
|
|
|
42
44
|
// Formatting
|
|
43
45
|
formatMoney: null, // (money, locale) => string
|
package/src/index.js
CHANGED
|
@@ -9,38 +9,23 @@
|
|
|
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 { diagnose } from './diagnose.js';
|
|
21
|
-
import { detectMarket, rememberMarket, getLocalization, clearLocalization, toCountry, toLanguage } from './markets.js';
|
|
19
|
+
import { resolveProductKey } from './status.js';
|
|
22
20
|
import { attr, sel, get, qsa, emit, formatMoney, busy, parseJSONAttr, toAttributes } from './dom.js';
|
|
21
|
+
import { loadPart, coreModules } from './lazy.js';
|
|
22
|
+
import { mountedLists, liveLists, pruneLive, within, mountProduct, retryFailed } from './mounting.js';
|
|
23
23
|
|
|
24
|
-
export const VERSION = '0.
|
|
24
|
+
export const VERSION = '0.4.0';
|
|
25
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
|
-
}
|
|
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;
|
|
@@ -59,7 +44,10 @@ export function mount(root = document) {
|
|
|
59
44
|
|
|
60
45
|
within(root, sel('product')).forEach((el) => { if (mountProduct(el)) count++; });
|
|
61
46
|
|
|
62
|
-
|
|
47
|
+
// The country select lives in the optional markets part: load it only when the page uses it.
|
|
48
|
+
if (within(root, `${sel('country-select')},${sel('country-bind')}`).length) {
|
|
49
|
+
loadPart('markets').then((m) => m.renderCountrySelects(root)).catch((err) => reportError(err, 'markets'));
|
|
50
|
+
}
|
|
63
51
|
|
|
64
52
|
qsa(root, sel('account-link')).forEach((a) => {
|
|
65
53
|
a.setAttribute('href', `https://${cfg.domain}/account`);
|
|
@@ -70,132 +58,10 @@ export function mount(root = document) {
|
|
|
70
58
|
return count;
|
|
71
59
|
}
|
|
72
60
|
|
|
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
61
|
// ---------------------------------------------------------------------------
|
|
123
62
|
// Markets: country select + switching
|
|
124
63
|
// ---------------------------------------------------------------------------
|
|
125
64
|
|
|
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
65
|
/** Call before a page transition removes `root` (closes the drawer etc.). */
|
|
200
66
|
export function unmount() {
|
|
201
67
|
Cart.close();
|
|
@@ -285,7 +151,27 @@ function bindControls() {
|
|
|
285
151
|
});
|
|
286
152
|
});
|
|
287
153
|
|
|
288
|
-
|
|
154
|
+
// Back online after a failed load: try the failed products and lists again.
|
|
155
|
+
window.addEventListener('online', () => {
|
|
156
|
+
retryFailed();
|
|
157
|
+
liveLists.forEach((l) => { if (l.root.getAttribute(attr('state')) === 'error') l.load(); });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
document.addEventListener('keydown', (e) => {
|
|
161
|
+
if (!Cart.isOpen()) return;
|
|
162
|
+
if (e.key === 'Escape') { Cart.close(); return; }
|
|
163
|
+
if (e.key !== 'Tab' || cfg.cartFocus === false) return;
|
|
164
|
+
// Keep Tab inside the open cart.
|
|
165
|
+
const d = Cart.drawers().find((x) => x.classList.contains(cfg.cartDrawerOpenClass));
|
|
166
|
+
const f = d ? focusables(d) : [];
|
|
167
|
+
if (!f.length) return;
|
|
168
|
+
const first = f[0];
|
|
169
|
+
const last = f[f.length - 1];
|
|
170
|
+
if (!d.contains(document.activeElement) || (e.shiftKey && document.activeElement === first) || (!e.shiftKey && document.activeElement === last)) {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
(e.shiftKey ? last : first).focus();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
289
175
|
}
|
|
290
176
|
|
|
291
177
|
function dataKeySel() { return `[data-${cfg.prefix}-key]`; }
|
|
@@ -333,17 +219,10 @@ export function init(overrides = {}) {
|
|
|
333
219
|
return initPromise;
|
|
334
220
|
}
|
|
335
221
|
|
|
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
222
|
bindControls();
|
|
344
223
|
if (cfg.observe) startObserver();
|
|
345
224
|
|
|
346
|
-
initPromise = Cart.init().then(async (cart) => {
|
|
225
|
+
initPromise = applyMarket(overrides).then(() => Cart.init()).then(async (cart) => {
|
|
347
226
|
// A saved cart from another market: move it to this one, so prices match the page.
|
|
348
227
|
const want = cfg.country ? String(cfg.country).toUpperCase() : null;
|
|
349
228
|
if (cart && want && cart.buyerIdentity?.countryCode && cart.buyerIdentity.countryCode !== want) {
|
|
@@ -355,6 +234,33 @@ export function init(overrides = {}) {
|
|
|
355
234
|
return initPromise;
|
|
356
235
|
}
|
|
357
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Detected market (saved choice, server hint, Webflow locale) wins over the static config.
|
|
239
|
+
* Without markets.detect only the visitor's saved choice applies, read here without loading
|
|
240
|
+
* the markets part.
|
|
241
|
+
*/
|
|
242
|
+
async function applyMarket(overrides) {
|
|
243
|
+
const m = cfg.markets || {};
|
|
244
|
+
if (m.detect === false) return;
|
|
245
|
+
let market = null;
|
|
246
|
+
if (m.detect) {
|
|
247
|
+
try { market = (await loadPart('markets')).detectMarket(m); } catch (err) { reportError(err, 'markets'); return; }
|
|
248
|
+
} else if (m.remember !== false) {
|
|
249
|
+
try { market = JSON.parse(localStorage.getItem(`${cfg.prefix}:market:${cfg.domain}`) || 'null'); } catch { /* none */ }
|
|
250
|
+
if (market && !/^[A-Z]{2}$/.test(market.country || '')) market = null;
|
|
251
|
+
}
|
|
252
|
+
if (!market) return;
|
|
253
|
+
if (market.country) cfg.country = market.country;
|
|
254
|
+
if (market.language) cfg.language = market.language;
|
|
255
|
+
if (market.locale && !overrides.locale && !window.TETHER_CONFIG?.locale) cfg.locale = market.locale;
|
|
256
|
+
log('market', market);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Tether.setCountry('DE'): loads the markets part on first use. */
|
|
260
|
+
export function setCountry(code, opts) {
|
|
261
|
+
return loadPart('markets').then((m) => m.setCountry(code, opts));
|
|
262
|
+
}
|
|
263
|
+
|
|
358
264
|
function startObserver() {
|
|
359
265
|
if (observer || typeof MutationObserver === 'undefined') return;
|
|
360
266
|
let t = null;
|
|
@@ -390,9 +296,11 @@ export const api = {
|
|
|
390
296
|
TetherError,
|
|
391
297
|
resolveProductKey,
|
|
392
298
|
setCountry,
|
|
393
|
-
detectMarket: (markets) => detectMarket(markets || cfg.markets),
|
|
394
|
-
getLocalization,
|
|
395
|
-
diagnose: (opts) => diagnose({ ...opts, version: VERSION }),
|
|
299
|
+
detectMarket: (markets) => loadPart('markets').then((m) => m.detectMarket(markets || cfg.markets)),
|
|
300
|
+
getLocalization: () => loadPart('markets').then((m) => m.getLocalization()),
|
|
301
|
+
diagnose: (opts) => loadPart('diagnose').then((m) => m.diagnose({ ...opts, version: VERSION })),
|
|
302
|
+
__core: coreModules,
|
|
303
|
+
__parts: {},
|
|
396
304
|
on(name, fn) {
|
|
397
305
|
const h = (e) => fn(e.detail, e);
|
|
398
306
|
document.addEventListener(`${cfg.prefix}:${name}`, h);
|
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) => {
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
s.innerHTML = '';
|
|
34
|
+
countries.forEach((c) => s.appendChild(new Option(fill(tpl, c), c.isoCode)));
|
|
35
|
+
s.value = current;
|
|
36
|
+
if (s.value !== current) s.selectedIndex = -1; // a country the store doesn't sell to
|
|
37
|
+
s.disabled = countries.length < 2 && !s.hasAttribute(attr('always-enabled'));
|
|
38
|
+
});
|
|
39
|
+
binds.forEach((el) => {
|
|
40
|
+
const k = get(el, 'country-bind') || 'name';
|
|
41
|
+
const v = { name: info?.name, code: info?.isoCode, currency: info?.currency?.isoCode, symbol: info?.currency?.symbol }[k];
|
|
42
|
+
el.textContent = v || '';
|
|
43
|
+
});
|
|
44
|
+
}).catch((err) => reportError(err, 'country select'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Switches the market: prices, currency and translations follow (@inContext), the cart's
|
|
49
|
+
* buyerIdentity.countryCode is updated, and every product and list on the page re-renders.
|
|
50
|
+
* Tether.setCountry('DE') Tether.setCountry('BE', { language: 'FR' })
|
|
51
|
+
*/
|
|
52
|
+
export async function setCountry(code, { language, remember = true } = {}) {
|
|
53
|
+
const country = toCountry(code);
|
|
54
|
+
if (!country) throw new TetherError(`Not a country code: ${code}`, { code: 'INVALID_COUNTRY' });
|
|
55
|
+
const lang = language === undefined ? cfg.language : toLanguage(language);
|
|
56
|
+
const changed = country !== String(cfg.country || '').toUpperCase() || lang !== cfg.language;
|
|
57
|
+
cfg.country = country;
|
|
58
|
+
cfg.language = lang || undefined;
|
|
59
|
+
if (remember) rememberMarket({ country, language: lang });
|
|
60
|
+
if (!changed) { renderCountrySelects(); return Cart.state; }
|
|
61
|
+
|
|
62
|
+
clearProductCache();
|
|
63
|
+
const jobs = [];
|
|
64
|
+
if (language !== undefined) clearLocalization(); // country names follow the language
|
|
65
|
+
// Only an existing cart moves; switching country never creates one.
|
|
66
|
+
if (Cart.id()) jobs.push(Cart.setBuyerIdentity({ countryCode: country }, { create: false }).catch((err) => reportError(err, 'cart country')));
|
|
67
|
+
|
|
68
|
+
pruneLive();
|
|
69
|
+
liveLists.forEach((list) => jobs.push(list.load()));
|
|
70
|
+
liveProducts.forEach((el) => {
|
|
71
|
+
if (el.closest(listSelector())) return; // re-rendered by its list
|
|
72
|
+
mountedProducts.delete(el);
|
|
73
|
+
mountProduct(el);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
renderCountrySelects();
|
|
77
|
+
emit('country-changed', { country, language: cfg.language });
|
|
78
|
+
await Promise.all(jobs);
|
|
79
|
+
return Cart.state;
|
|
80
|
+
}
|
|
81
|
+
|
package/src/mounting.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Product scopes and lists that are mounted, shared by index.js and the optional markets part. */
|
|
2
|
+
import { cfg, log, warn, reportError } from './config.js';
|
|
3
|
+
import { ProductScope, scopes } from './product.js';
|
|
4
|
+
import { listSelector } from './collection.js';
|
|
5
|
+
import { getProduct } from './loader.js';
|
|
6
|
+
import { resolveProductKey, setScopeStatus, isNotFound } from './status.js';
|
|
7
|
+
import { attr, get, qsa, emit } from './dom.js';
|
|
8
|
+
|
|
9
|
+
export const mountedProducts = new WeakSet();
|
|
10
|
+
export const mountedLists = new WeakSet();
|
|
11
|
+
// Live roots, for re-rendering after a country switch (pruned when disconnected).
|
|
12
|
+
export const liveProducts = new Set();
|
|
13
|
+
export const liveLists = new Set();
|
|
14
|
+
const requestToken = new WeakMap(); // product root -> latest request, so a slow old response can't win
|
|
15
|
+
|
|
16
|
+
export function pruneLive() {
|
|
17
|
+
liveProducts.forEach((el) => { if (!el.isConnected) liveProducts.delete(el); });
|
|
18
|
+
liveLists.forEach((l) => { if (!l.root.isConnected) liveLists.delete(l); });
|
|
19
|
+
}
|
|
20
|
+
export function within(root, selector) {
|
|
21
|
+
return [...(root.matches?.(selector) ? [root] : []), ...qsa(root, selector)];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export /** Mounts one product root. Returns true when a product request was started. */
|
|
25
|
+
function mountProduct(el) {
|
|
26
|
+
if (mountedProducts.has(el)) return false;
|
|
27
|
+
const { key, fromUrl, raw } = resolveProductKey(el);
|
|
28
|
+
if (!key) {
|
|
29
|
+
if (fromUrl) {
|
|
30
|
+
// Dynamic product page opened without ?handle= (or an empty path).
|
|
31
|
+
mountedProducts.add(el);
|
|
32
|
+
setScopeStatus(el, 'not-found');
|
|
33
|
+
emit('product-not-found', { key: null, source: raw }, el);
|
|
34
|
+
} else if (!el.closest(listSelector())) {
|
|
35
|
+
// List cards get their handle when the list renders.
|
|
36
|
+
warn(`[${attr('product')}] without a value. Bind it to the Shopify handle field, or use "url:handle".`, el);
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
mountedProducts.add(el);
|
|
41
|
+
liveProducts.add(el);
|
|
42
|
+
const token = {};
|
|
43
|
+
requestToken.set(el, token);
|
|
44
|
+
const current = () => requestToken.get(el) === token;
|
|
45
|
+
// A rendered scope keeps showing its content while it refreshes (e.g. country switch).
|
|
46
|
+
if (!scopes.get(el)) setScopeStatus(el, 'loading');
|
|
47
|
+
else el.setAttribute(attr('state'), 'updating');
|
|
48
|
+
getProduct(key)
|
|
49
|
+
.then((p) => {
|
|
50
|
+
if (!current()) return;
|
|
51
|
+
const existing = scopes.get(el);
|
|
52
|
+
if (existing) existing.update(p);
|
|
53
|
+
// eslint-disable-next-line no-new
|
|
54
|
+
else new ProductScope(el, p);
|
|
55
|
+
applyPageTitle(el, p);
|
|
56
|
+
})
|
|
57
|
+
.catch((err) => {
|
|
58
|
+
if (!current()) return;
|
|
59
|
+
const notFound = isNotFound(err);
|
|
60
|
+
setScopeStatus(el, notFound ? 'not-found' : 'error', err);
|
|
61
|
+
if (notFound) {
|
|
62
|
+
emit('product-not-found', { key, source: raw }, el);
|
|
63
|
+
// A typo in a URL isn't worth an alert; a CMS item pointing at a deleted product is.
|
|
64
|
+
if (fromUrl) log(`product not found: ${key}`); else reportError(err, `product ${key}`);
|
|
65
|
+
} else {
|
|
66
|
+
reportError(err, `product ${key}`);
|
|
67
|
+
emit('error', { error: err, context: 'product', key }, el);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** tether-page-title="{title} | My shop" on a product root sets document.title (dynamic product pages). */
|
|
74
|
+
function applyPageTitle(el, p) {
|
|
75
|
+
const tpl = get(el, 'page-title');
|
|
76
|
+
if (!tpl || typeof document === 'undefined') return;
|
|
77
|
+
document.title = tpl.replace(/\{title\}/g, p.title).replace(/\{vendor\}/g, p.vendor || '').replace(/\{type\}/g, p.productType || '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/** Product scopes that failed on a network/API error: loaded again when the browser is back online. */
|
|
82
|
+
export function retryFailed() {
|
|
83
|
+
pruneLive();
|
|
84
|
+
liveProducts.forEach((el) => {
|
|
85
|
+
if (el.getAttribute(attr('state')) !== 'error' || el.getAttribute(attr('error')) === 'not-found') return;
|
|
86
|
+
mountedProducts.delete(el);
|
|
87
|
+
mountProduct(el);
|
|
88
|
+
});
|
|
89
|
+
}
|