@occluze/tether 0.4.0 → 0.6.0

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