@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/package.json CHANGED
@@ -1,32 +1,49 @@
1
1
  {
2
2
  "name": "@occluze/tether",
3
- "version": "0.3.5",
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/tether.esm.js",
7
- "module": "dist/tether.esm.js",
6
+ "main": "dist/esm/index.js",
7
+ "module": "dist/esm/index.js",
8
8
  "exports": {
9
- ".": "./dist/tether.esm.js",
10
- "./presets/barba": "./dist/presets/barba.esm.js"
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": "Ravi Klaassens",
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 { ProductScope, scopes } from './product.js';
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, setScopeStatus, isNotFound } from './status.js';
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.3.5';
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
- renderCountrySelects(root);
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
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && Cart.isOpen()) Cart.close(); });
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);
@@ -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(', ')}) {\n ${parts.join('\n ')}\n}\n${productFragment()}`;
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
+
@@ -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
+ }