@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.
Files changed (45) hide show
  1. package/README.md +12 -7
  2. package/dist/esm/astro.js +2 -0
  3. package/dist/esm/chunks/chunk-HHMOPDZG.js +1 -0
  4. package/dist/esm/chunks/chunk-HXKV6QGB.js +4 -0
  5. package/dist/esm/chunks/chunk-RBVRRQIU.js +1 -0
  6. package/dist/esm/chunks/chunk-RNZMTQTX.js +1 -0
  7. package/dist/esm/chunks/chunk-VC46IEJQ.js +1 -0
  8. package/dist/esm/chunks/diagnose-J667GHGS.js +1 -0
  9. package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +1 -0
  10. package/dist/esm/index.js +1 -0
  11. package/dist/esm/server.js +1 -0
  12. package/dist/presets/barba.esm.js +4 -5
  13. package/dist/tether.diagnose.js +320 -0
  14. package/dist/tether.diagnose.min.js +1 -0
  15. package/dist/tether.esm.js +2 -2608
  16. package/dist/tether.js +637 -833
  17. package/dist/tether.markets.js +213 -0
  18. package/dist/tether.markets.min.js +1 -0
  19. package/dist/tether.min.js +3 -132
  20. package/dist/tether.min.js.map +4 -4
  21. package/package.json +52 -11
  22. package/src/astro.js +73 -0
  23. package/src/cart.js +79 -7
  24. package/src/client.js +25 -15
  25. package/src/config.js +4 -0
  26. package/src/diagnose.js +1 -1
  27. package/src/dom.js +32 -9
  28. package/src/fragments.js +12 -11
  29. package/src/index.js +80 -160
  30. package/src/initial.js +66 -0
  31. package/src/lazy-iife.js +43 -0
  32. package/src/lazy.js +9 -0
  33. package/src/loader.js +50 -7
  34. package/src/markets-ui.js +89 -0
  35. package/src/markets.js +1 -1
  36. package/src/mounting.js +129 -0
  37. package/src/parts/diagnose.js +3 -0
  38. package/src/parts/markets.js +3 -0
  39. package/src/product.js +86 -61
  40. package/src/server.js +179 -0
  41. package/src/status.js +2 -6
  42. package/types/astro.d.ts +15 -0
  43. package/types/barba.d.ts +16 -0
  44. package/types/index.d.ts +347 -0
  45. 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 { 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';
22
- import { attr, sel, get, qsa, emit, formatMoney, busy, parseJSONAttr, toAttributes } from './dom.js';
23
-
24
- export const VERSION = '0.3.6';
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
- renderCountrySelects(root);
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) => { if (e.key === 'Escape' && Cart.isOpen()) Cart.close(); });
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
- const auto = window.TETHER_CONFIG && window.TETHER_CONFIG.autoMount !== false;
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
+ }
@@ -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) => {
@@ -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)) { if (gen === generation) primeProduct(got.get(k)); r.resolve(got.get(k)); } else {
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(productId, intent = 'RELATED') {
145
- const data = await gql(`query TetherRecs($id: ID!, $intent: ProductRecommendationIntent) {
146
- productRecommendations(productId: $id, intent: $intent) { ...TetherProduct }
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: productId, intent });
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;