@occluze/tether 0.6.6 → 0.6.8

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 (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +776 -432
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +655 -321
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +334 -243
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3171 -1998
  36. package/dist/tether.markets.js +231 -126
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +68 -284
  58. package/src/account.js +281 -317
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +322 -481
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -285
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
  120. package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
  121. package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
  122. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  123. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  124. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  125. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  126. package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
  127. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  128. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  129. package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
  130. package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
  131. package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
  132. package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
  133. package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
  134. package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/index.js CHANGED
@@ -1,344 +1,290 @@
1
1
  /**
2
- * Tether — Shopify <-> Webflow (or any HTML) sync via the Storefront API.
2
+ * Tether's entry: `window.Tether`, `init()`, `mount()` and the start of the plain `<script>` setup.
3
3
  *
4
4
  * <script>window.TETHER_CONFIG = { domain: 'x.myshopify.com', token: '...' }</script>
5
5
  * <script src="tether.min.js" defer></script>
6
6
  *
7
- * With page transitions (Barba, Swup): set autoMount: false, call
8
- * Tether.init() once and Tether.mount(container) after every page enter.
7
+ * With page transitions (Barba, Swup): set `autoMount: false`, call `Tether.init()` once and
8
+ * `Tether.mount(container)` after every page enter.
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, focusables } from './cart.js';
12
+ import { Cart } from './cart.js';
13
13
  import { scopes } from './product.js';
14
14
  import { ListScope, listSelector } from './collection.js';
15
15
  import {
16
- getProduct, getProducts, getRecommendations, clearProductCache, primeProduct, normalizeProductKey,
16
+ getProduct,
17
+ getProducts,
18
+ getRecommendations,
19
+ clearProductCache,
20
+ primeProduct,
21
+ normalizeProductKey,
17
22
  } from './loader.js';
18
23
  import * as plans from './selling-plans.js';
19
24
  import { resolveProductKey } from './status.js';
20
- import { attr, sel, get, qsa, emit, formatMoney, busy, parseJSONAttr, toAttributes, imageUrl } from './dom.js';
25
+ import { sel, emit, formatMoney, imageUrl } from './dom.js';
21
26
  import { loadPart, coreModules } from './lazy.js';
22
- import { mountedLists, liveLists, pruneLive, within, mountProduct, retryFailed, hydrateFromPage, settleMarket, trackMarket } from './mounting.js';
27
+ import {
28
+ mountedLists,
29
+ liveLists,
30
+ pruneLive,
31
+ within,
32
+ mountProduct,
33
+ hydrateFromPage,
34
+ settleMarket,
35
+ trackMarket,
36
+ } from './mounting.js';
37
+ import { applyStartMarket, setCountry } from './page-market.js';
38
+ import { accountApi, mountAccountIfUsed } from './account-api.js';
39
+ import { bindCartControls } from './controls/cart-controls.js';
40
+ import { bindDiscountControls } from './controls/discount.js';
41
+ import { addGroup, bindExtrasControls } from './controls/extras.js';
42
+ import { bindNavigation, rememberDocumentMount } from './controls/navigation.js';
43
+ /** @import { TetherConfig, TetherOptions, MarketsConfig } from './config.js' */
44
+ /** @import { ShopifyCart } from './cart/store.js' */
45
+ /** @import { ProductScope } from './product.js' */
46
+ /** @import { DiagnoseOptions } from './diagnose.js' */
47
+
48
+ export { addGroup, setCountry };
49
+
50
+ export const VERSION = '0.6.8';
51
+
52
+ /** The events the analytics part reports, buffered from `init()` until the part has loaded. */
53
+ const ANALYTICS_EVENTS = ['product-ready', 'list-ready', 'cart-updated', 'cart-toggled', 'checkout', 'search'];
54
+
55
+ /** Milliseconds `observe` waits after the last added node, so a burst of additions mounts once. */
56
+ const OBSERVE_DEBOUNCE_MS = 50;
23
57
 
24
- export const VERSION = '0.6.6';
58
+ /**
59
+ * @typedef {object} TetherGlobals What the page sets on `window` for Tether, and `window.Tether` itself.
60
+ * @property {TetherOptions} [TETHER_CONFIG] The site's config; `configure()` validates it.
61
+ * @property {string} [TETHER_INTEGRATION] Set by a framework integration (Astro) that starts Tether itself.
62
+ * @property {TetherApi} [Tether]
63
+ */
25
64
 
65
+ /** @returns {Window & TetherGlobals} */
66
+ const pageGlobals = () => window;
67
+
68
+ /** @type {Promise<TetherApi | null> | null} */
26
69
  let initPromise = null;
70
+
71
+ /** @type {MutationObserver | null} */
27
72
  let observer = null;
28
73
 
29
- /** Mount every product scope and product list inside `root`. Safe to call repeatedly. */
30
- export function mount(root = document) {
31
- if (!isConfigured()) return 0;
32
- let count = 0;
33
- pruneLive(); // page transitions detach old roots: don't keep them alive
34
- hydrateFromPage(root); // products the server already rendered: no request needed
74
+ /** @type {Event[] | null} The analytics part's queue, created once by the first `init()` with `analytics`. */
75
+ let analyticsQueue = null;
76
+
77
+ /** @type {boolean} The document-wide listeners are bound once, by the first configured `init()`. */
78
+ let controlsBound = false;
35
79
 
80
+ /**
81
+ * Mounts the lists of `root` and the product scopes in them as they render.
82
+ *
83
+ * @param {ParentNode} root
84
+ * @returns {number} The lists mounted.
85
+ */
86
+ function mountLists(root) {
87
+ let count = 0;
36
88
  within(root, listSelector()).forEach((el) => {
37
89
  if (mountedLists.has(el)) return;
38
90
  mountedLists.add(el);
39
91
  count++;
40
92
  const list = new ListScope(el);
41
93
  liveLists.add(list);
42
- el.addEventListener(`${cfg.prefix}:list-rendered`, (e) => mount(e.detail.container));
43
- trackMarket(list.load());
94
+ el.addEventListener(`${cfg.prefix}:list-rendered`, (event) => {
95
+ // The list emits this CustomEvent once its products are in the page.
96
+ mount(/** @type {CustomEvent<{ container: ParentNode }>} */ (event).detail.container);
97
+ });
98
+ void trackMarket(list.load());
44
99
  });
100
+ return count;
101
+ }
45
102
 
46
- within(root, sel('product')).forEach((el) => { if (mountProduct(el)) count++; });
47
-
48
- // The country select lives in the optional markets part: load it only when the page uses it.
103
+ /**
104
+ * Loads each optional part `root` uses: the country select, customer accounts and search as you type.
105
+ *
106
+ * @param {ParentNode} root
107
+ */
108
+ function mountParts(root) {
49
109
  if (within(root, `${sel('country-select')},${sel('country-bind')}`).length) {
50
- loadPart('markets').then((m) => m.renderCountrySelects(root)).catch((err) => reportError(err, 'markets'));
110
+ loadPart('markets')
111
+ .then((part) => part.renderCountrySelects(root))
112
+ .catch((/** @type {unknown} */ error) => {
113
+ reportError(error, 'markets');
114
+ });
51
115
  }
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, ['account-login', 'account-bind', 'account-link', 'orders', 'order', 'addresses', 'subscriptions', 'profile-form', 'address-form'].map((n) => `[${attr(n)}]`).join(',') + `,[${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.
116
+ mountAccountIfUsed(root);
60
117
  if (within(root, sel('search')).length) {
61
- loadPart('search').then((m) => m.mountSearch(root)).catch((err) => reportError(err, 'search'));
118
+ loadPart('search')
119
+ .then((part) => {
120
+ part.mountSearch(root);
121
+ })
122
+ .catch((/** @type {unknown} */ error) => {
123
+ reportError(error, 'search');
124
+ });
62
125
  }
126
+ }
63
127
 
64
-
128
+ /**
129
+ * Mounts every product scope and product list inside `root`. Safe to call repeatedly.
130
+ *
131
+ * @param {ParentNode} [root]
132
+ * @returns {number} The scopes and lists newly mounted.
133
+ */
134
+ export function mount(root = document) {
135
+ if (!isConfigured()) return 0;
136
+ if (root === document) rememberDocumentMount();
137
+ // Page transitions detach old roots: they aren't kept alive.
138
+ pruneLive();
139
+ // Products the server rendered into the page need no request.
140
+ hydrateFromPage(root.ownerDocument ?? document);
141
+ let count = mountLists(root);
142
+ within(root, sel('product')).forEach((el) => {
143
+ if (mountProduct(el)) count++;
144
+ });
145
+ mountParts(root);
65
146
  settleMarket();
66
147
  Cart.render();
67
148
  log(`mounted ${count} scope(s)`, root);
68
149
  return count;
69
150
  }
70
151
 
71
- // ---------------------------------------------------------------------------
72
- // Markets: country select + switching
73
- // ---------------------------------------------------------------------------
74
-
75
- /** Call before a page transition removes `root` (closes the drawer etc.). */
152
+ /** Call before a page transition removes the page: closes the cart drawer. */
76
153
  export function unmount() {
77
154
  Cart.close();
78
155
  }
79
156
 
157
+ /**
158
+ * @param {Element | null | undefined} el
159
+ * @returns {ProductScope | null} The product scope `el` is in.
160
+ */
80
161
  export function getScope(el) {
81
- const root = el?.closest?.(sel('product'));
82
- return root ? scopes.get(root) || null : null;
162
+ const root = el?.closest(sel('product'));
163
+ return root ? (scopes.get(root) ?? null) : null;
83
164
  }
84
165
 
85
- // ---------------------------------------------------------------------------
86
- // Document-wide controls (delegated, so they survive page swaps)
87
- // ---------------------------------------------------------------------------
88
-
89
- let controlsBound = false;
90
-
91
166
  function bindControls() {
92
167
  if (controlsBound) return;
93
168
  controlsBound = true;
169
+ bindCartControls();
170
+ bindDiscountControls();
171
+ bindExtrasControls();
172
+ bindNavigation({ ready: () => initPromise ?? Promise.resolve(null), mount, unmount });
173
+ }
94
174
 
95
- document.addEventListener('click', (e) => {
96
- const t = e.target;
97
- if (!t?.closest) return;
98
-
99
- if (t.closest(sel('cart-open'))) { e.preventDefault(); Cart.toggle(); return; }
100
- if (t.closest(sel('cart-close'))) { e.preventDefault(); Cart.close(); return; }
101
-
102
- const clear = t.closest(sel('cart-clear'));
103
- if (clear) { e.preventDefault(); busy(clear, () => Cart.clear().catch((err) => reportError(err, 'cart clear'))); return; }
104
-
105
- const checkout = t.closest(sel('checkout'));
106
- if (checkout) { e.preventDefault(); busy(checkout, () => Cart.checkout()); return; }
107
-
108
- const line = t.closest(dataKeySel());
109
- if (line && line.closest(sel('cart-lines'))) {
110
- const id = line.getAttribute(`data-${cfg.prefix}-key`);
111
- const current = Cart.lines.find((l) => l.id === id);
112
- const run = (fn) => { e.preventDefault(); fn().catch((err) => reportError(err, 'cart line')); };
113
- if (t.closest(sel('line-remove'))) { line.classList.add('is-busy'); return run(() => Cart.removeLine(id)); }
114
- if (current && t.closest(sel('line-plus'))) return run(() => Cart.changeQuantity(id, 1));
115
- if (current && t.closest(sel('line-minus'))) return run(() => Cart.changeQuantity(id, -1));
116
- }
117
-
118
- const removeCode = t.closest(sel('discount-remove'));
119
- if (removeCode) {
120
- e.preventDefault();
121
- const code = removeCode.closest(sel('discount-code'))?.getAttribute(attr('discount-code'));
122
- if (code) Cart.removeDiscount(code).catch((err) => reportError(err, 'discount'));
123
- return;
124
- }
125
-
126
- const groupBtn = t.closest(sel('group-add'));
127
- if (groupBtn) { e.preventDefault(); busy(groupBtn, () => addGroup(groupBtn)); }
128
- });
129
-
130
- document.addEventListener('change', (e) => {
131
- const t = e.target;
132
- if (!t?.closest) return;
133
- const lineEl = t.closest(dataKeySel());
134
- const id = lineEl?.getAttribute(`data-${cfg.prefix}-key`);
135
- // The line shows is-busy / aria-busy until Shopify has answered (removed by the next render).
136
- const busyLine = (p, what) => { lineEl.classList.add('is-busy'); lineEl.setAttribute('aria-busy', 'true'); p.catch((err) => reportError(err, what)); };
137
- if (id && t.matches(`input${sel('line', 'quantity')}`)) {
138
- busyLine(Cart.updateLine(id, parseInt(t.value, 10) || 0), 'cart line');
139
- } else if (id && t.matches(`select${sel('line-plan')}`)) {
140
- busyLine(Cart.updateLine(id, { sellingPlanId: t.value || null }), 'cart line plan');
141
- } else if (t.matches(`select${sel('country-select')}`)) {
142
- if (t.value) setCountry(t.value).catch((err) => reportError(err, 'country'));
143
- } else if (t.matches(sel('cart-note'))) {
144
- Cart.setNote(t.value).catch((err) => reportError(err, 'cart note'));
145
- }
175
+ /** Buffers Tether's events until the analytics part has loaded, so none are missed. */
176
+ function startAnalyticsQueue() {
177
+ /** @type {Event[]} */
178
+ const queue = [];
179
+ analyticsQueue = queue;
180
+ ANALYTICS_EVENTS.forEach((name) => {
181
+ document.addEventListener(`${cfg.prefix}:${name}`, (event) => queue.push(event));
146
182
  });
147
-
148
- document.addEventListener('submit', (e) => {
149
- const form = e.target.closest?.(sel('discount-form'));
150
- if (!form) return;
151
- e.preventDefault();
152
- const input = form.querySelector(sel('discount-input')) || form.querySelector('input');
153
- const code = input?.value.trim();
154
- if (!code) return;
155
- busy(form, async () => {
156
- const mark = (bad) => {
157
- form.classList.toggle('is-invalid', bad);
158
- if (input) { if (bad) input.setAttribute('aria-invalid', 'true'); else input.removeAttribute('aria-invalid'); }
159
- };
160
- try {
161
- await Cart.applyDiscount(code);
162
- mark(false);
163
- emit('discount-applied', { code }, form);
164
- if (input) input.value = '';
165
- } catch (err) {
166
- mark(true);
167
- if (err?.code === 'INVALID_DISCOUNT') emit('discount-invalid', { code }, form); else reportError(err, 'discount');
168
- }
183
+ document.addEventListener('astro:page-load', () => queue.push(new CustomEvent(`${cfg.prefix}:page`)));
184
+ loadPart('analytics')
185
+ .then((part) => part.startAnalytics(queue))
186
+ .catch((/** @type {unknown} */ error) => {
187
+ reportError(error, 'analytics');
169
188
  });
170
- }, true); // capture: before a router (Astro's ClientRouter) turns the submit into a navigation
171
- document.addEventListener('input', (e) => {
172
- const f = e.target.closest?.(sel('discount-form'));
173
- if (f) { f.classList.remove('is-invalid'); e.target.removeAttribute?.('aria-invalid'); }
174
- });
175
-
176
- // Other tabs: the cart or the market changed there (handled by the extras part, loaded then).
177
- window.addEventListener('storage', (e) => {
178
- if (cfg.domain && e.key?.startsWith(`${cfg.prefix}:`)) loadPart('extras').then((m) => m.onStorage(e)).catch(() => {});
179
- });
180
- // Shareable variant links: only when turned on.
181
- document.addEventListener(`${cfg.prefix}:variant-changed`, (e) => {
182
- const root = e.target;
183
- if (cfg.variantUrl || root.hasAttribute?.(attr('variant-url'))) loadPart('extras').then((m) => m.writeVariantUrl(root, e.detail)).catch(() => {});
184
- });
189
+ }
185
190
 
186
- // Astro View Transitions (<ClientRouter />): mount every new page, close the cart while leaving.
187
- if (cfg.astro !== false) {
188
- // Astro fires this on the first load too: wait for init (the visitor's saved market) first.
189
- document.addEventListener('astro:page-load', () => (initPromise || Promise.resolve()).then(() => {
190
- // Navigated to a page of another market (e.g. /de/): switch first, then mount in that market.
191
- // Back on a normal page after one: the visitor's own choice, else the default market.
192
- let target = pageMarket();
193
- if (!target && onPageMarket) {
194
- try { target = JSON.parse(localStorage.getItem(`${cfg.prefix}:market:${cfg.domain}`) || 'null'); } catch { target = null; }
195
- target = target?.country ? target : baseMarket;
196
- }
197
- onPageMarket = Boolean(pageMarket());
198
- const differs = target?.country && (String(target.country).toUpperCase() !== String(cfg.country || '').toUpperCase()
199
- || String(target.language || '').toUpperCase() !== String(cfg.language || '').toUpperCase());
200
- if (differs) setCountry(target.country, { language: target.language ?? null, locale: target.locale, remember: false }).then(() => mount(document), () => mount(document));
201
- else mount(document);
202
- }));
203
- document.addEventListener('astro:before-swap', () => unmount());
191
+ /**
192
+ * A saved cart from another market moves to this one, so its prices match the page's.
193
+ *
194
+ * @param {ShopifyCart | null} cart
195
+ * @returns {Promise<ShopifyCart | null>}
196
+ */
197
+ async function cartInPageMarket(cart) {
198
+ const country = cfg.country?.toUpperCase();
199
+ const cartCountry = cart?.buyerIdentity.countryCode;
200
+ if (!cart || !country || !cartCountry || cartCountry === country) return cart;
201
+ try {
202
+ return await Cart.setBuyerIdentity({ countryCode: country }, { create: false });
203
+ } catch (error) {
204
+ reportError(error, 'cart country');
205
+ return cart;
204
206
  }
205
-
206
- // Back online after a failed load: try the failed products and lists again.
207
- window.addEventListener('online', () => {
208
- retryFailed();
209
- liveLists.forEach((l) => { if (l.root.getAttribute(attr('state')) === 'error') l.load(); });
210
- });
211
-
212
- document.addEventListener('keydown', (e) => {
213
- if (!Cart.isOpen()) return;
214
- if (e.key === 'Escape') { Cart.close(); return; }
215
- if (e.key !== 'Tab' || cfg.cartFocus === false) return;
216
- // Keep Tab inside the open cart.
217
- const d = Cart.drawers().find((x) => x.classList.contains(cfg.cartDrawerOpenClass));
218
- const f = d ? focusables(d) : [];
219
- if (!f.length) return;
220
- const first = f[0];
221
- const last = f[f.length - 1];
222
- if (!d.contains(document.activeElement) || (e.shiftKey && document.activeElement === first) || (!e.shiftKey && document.activeElement === last)) {
223
- e.preventDefault();
224
- (e.shiftKey ? last : first).focus();
225
- }
226
- });
227
207
  }
228
208
 
229
- function dataKeySel() { return `[data-${cfg.prefix}-key]`; }
230
-
231
209
  /**
232
- * [tether-group] + [tether-group-add]: adds every product scope inside the
233
- * group in one request. Bundles, build-a-box, "complete the set".
234
- * Lines get attribute _group (and the group's tether-line-attributes).
210
+ * Configures Tether (`window.TETHER_CONFIG` with `overrides` over it), applies the visitor's market and loads the
211
+ * cart. A second call without overrides returns the first one's promise.
212
+ *
213
+ * @param {TetherOptions} [overrides]
214
+ * @returns {Promise<TetherApi | null>} `null` without a domain and token, when Tether stays idle.
235
215
  */
236
- export const addGroup = (btn) => loadPart('extras').then((m) => m.addGroup(btn));
237
-
238
- // ---------------------------------------------------------------------------
239
- // Init
240
- // ---------------------------------------------------------------------------
241
-
242
- let analyticsQueue = null;
243
-
244
216
  export function init(overrides = {}) {
245
217
  if (initPromise && !Object.keys(overrides).length) return initPromise;
246
- configure({ ...(window.TETHER_CONFIG || {}), ...overrides });
247
-
218
+ configure({ ...pageGlobals().TETHER_CONFIG, ...overrides });
248
219
  if (!isConfigured()) {
249
220
  warn('TETHER_CONFIG is missing domain or token. Tether is idle.');
250
221
  initPromise = Promise.resolve(null);
251
222
  return initPromise;
252
223
  }
253
-
254
224
  bindControls();
255
225
  if (cfg.observe) startObserver();
256
- // Analytics (optional part): buffer Tether's events until it has loaded, so none are missed.
257
- if (cfg.analytics && !analyticsQueue) {
258
- analyticsQueue = [];
259
- ['product-ready', 'list-ready', 'cart-updated', 'cart-toggled', 'checkout', 'search'].forEach((n) => document.addEventListener(`${cfg.prefix}:${n}`, (e) => analyticsQueue.push(e)));
260
- document.addEventListener('astro:page-load', () => analyticsQueue.push(new CustomEvent(`${cfg.prefix}:page`)));
261
- loadPart('analytics').then((m) => m.startAnalytics(analyticsQueue)).catch((err) => reportError(err, 'analytics'));
262
- }
226
+ if (cfg.analytics && !analyticsQueue) startAnalyticsQueue();
227
+ initPromise = applyStartMarket(overrides)
228
+ .then(() => Cart.init())
229
+ .then(cartInPageMarket)
230
+ .then((cart) => {
231
+ emit('ready', { cart });
232
+ return api;
233
+ });
234
+ return initPromise;
235
+ }
263
236
 
264
- initPromise = applyMarket(overrides).then(() => Cart.init()).then(async (cart) => {
265
- // A saved cart from another market: move it to this one, so prices match the page.
266
- const want = cfg.country ? String(cfg.country).toUpperCase() : null;
267
- if (cart && want && cart.buyerIdentity?.countryCode && cart.buyerIdentity.countryCode !== want) {
268
- try { cart = await Cart.setBuyerIdentity({ countryCode: want }, { create: false }); } catch (err) { reportError(err, 'cart country'); }
269
- }
270
- emit('ready', { cart });
271
- return api;
237
+ /** `observe`: mounts the elements added to the page later. */
238
+ function startObserver() {
239
+ if (observer || typeof MutationObserver === 'undefined') return;
240
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
241
+ let timer;
242
+ /** @type {Set<Element>} */
243
+ const queued = new Set();
244
+ observer = new MutationObserver((mutations) => {
245
+ mutations.forEach((mutation) => {
246
+ mutation.addedNodes.forEach((node) => {
247
+ if (node instanceof window.Element) queued.add(node);
248
+ });
249
+ });
250
+ clearTimeout(timer);
251
+ timer = setTimeout(() => {
252
+ queued.forEach((node) => {
253
+ if (node.isConnected) mount(node);
254
+ });
255
+ queued.clear();
256
+ }, OBSERVE_DEBOUNCE_MS);
272
257
  });
273
- return initPromise;
258
+ observer.observe(document.body, { childList: true, subtree: true });
274
259
  }
275
260
 
276
261
  /**
277
- * Detected market (saved choice, server hint, Webflow locale) wins over the static config.
278
- * Without markets.detect only the visitor's saved choice applies, read here without loading
279
- * the markets part.
280
- */
281
- /**
282
- * A page built for one market (e.g. /de/ pages): <meta name="tether-page-country" content="DE">,
283
- * tether-page-language, tether-page-locale. Wins over the visitor's saved choice, isn't remembered.
262
+ * @param {MarketsConfig | null} [markets] Default `markets` of the config.
284
263
  */
285
- function pageMarket(doc = document) {
286
- const get = (n) => doc.querySelector(`meta[name="${cfg.prefix}-page-${n}"]`)?.getAttribute('content') || undefined;
287
- const country = get('country');
288
- return country ? { country: country.toUpperCase(), language: get('language'), locale: get('locale') } : null;
289
- }
290
-
291
- let baseMarket = null; // the configured market, before a visitor's or page's market applied
292
- let onPageMarket = false;
293
-
294
- async function applyMarket(overrides) {
295
- const m = cfg.markets || {};
296
- baseMarket = { country: cfg.country, language: cfg.language, locale: cfg.locale };
297
- const page = pageMarket();
298
- onPageMarket = Boolean(page);
299
- if (page) {
300
- cfg.country = page.country;
301
- cfg.language = page.language ? page.language.toUpperCase() : undefined;
302
- if (page.locale) cfg.locale = page.locale;
303
- return;
304
- }
305
- if (m.detect === false) return;
306
- let market = null;
307
- if (m.detect) {
308
- try { market = (await loadPart('markets')).detectMarket(m); } catch (err) { reportError(err, 'markets'); return; }
309
- } else if (m.remember !== false) {
310
- try { market = JSON.parse(localStorage.getItem(`${cfg.prefix}:market:${cfg.domain}`) || 'null'); } catch { /* none */ }
311
- if (market && !/^[A-Z]{2}$/.test(market.country || '')) market = null;
312
- }
313
- if (!market) return;
314
- if (market.country) cfg.country = market.country;
315
- if (market.language) cfg.language = market.language;
316
- if (market.locale && !overrides.locale) cfg.locale = market.locale; // the visitor's market wins over the default
317
- log('market', market);
318
- }
264
+ const detectMarket = (markets) => loadPart('markets').then((part) => part.detectMarket(markets ?? cfg.markets));
319
265
 
320
- function hasAccountSession() {
321
- 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; }
322
- }
266
+ const getLocalization = () => loadPart('markets').then((part) => part.getLocalization());
323
267
 
324
- /** Customer accounts (optional part, loaded on first use). See src/account.js. */
325
- const accountApi = new Proxy({}, { get: (_, n) => (...a) => loadPart('account').then((m) => m[n](...a)) });
268
+ /** @param {Omit<DiagnoseOptions, 'version'>} [options] */
269
+ const diagnose = (options) => loadPart('diagnose').then((part) => part.diagnose({ ...options, version: VERSION }));
326
270
 
327
- /** Tether.setCountry('DE'): loads the markets part on first use. */
328
- export function setCountry(code, opts) {
329
- return loadPart('markets').then((m) => m.setCountry(code, opts));
330
- }
331
-
332
- function startObserver() {
333
- if (observer || typeof MutationObserver === 'undefined') return;
334
- let t = null;
335
- const queued = new Set();
336
- observer = new MutationObserver((muts) => {
337
- muts.forEach((m) => m.addedNodes.forEach((n) => { if (n.nodeType === 1) queued.add(n); }));
338
- clearTimeout(t);
339
- t = setTimeout(() => { queued.forEach((n) => { if (n.isConnected) mount(n); }); queued.clear(); }, 50);
340
- });
341
- observer.observe(document.body, { childList: true, subtree: true });
271
+ /**
272
+ * Listens to the Tether event `<prefix>:<name>`.
273
+ *
274
+ * @param {string} name
275
+ * @param {(detail: unknown, event: CustomEvent) => void} listener
276
+ * @returns {() => void} Stops listening.
277
+ */
278
+ function on(name, listener) {
279
+ /** @param {Event} event */
280
+ const handler = (event) => {
281
+ const custom = /** @type {CustomEvent} Tether's events are CustomEvents. */ (event);
282
+ listener(custom.detail, custom);
283
+ };
284
+ document.addEventListener(`${cfg.prefix}:${name}`, handler);
285
+ return () => {
286
+ document.removeEventListener(`${cfg.prefix}:${name}`, handler);
287
+ };
342
288
  }
343
289
 
344
290
  export const api = {
@@ -346,8 +292,14 @@ export const api = {
346
292
  init,
347
293
  mount,
348
294
  unmount,
349
- get config() { return cfg; },
350
- get ready() { return initPromise || Promise.resolve(null); },
295
+ /** @returns {TetherConfig} */
296
+ get config() {
297
+ return cfg;
298
+ },
299
+ /** @returns {Promise<TetherApi | null>} */
300
+ get ready() {
301
+ return initPromise ?? Promise.resolve(null);
302
+ },
351
303
  query: gql,
352
304
  withContext,
353
305
  cart: Cart,
@@ -366,28 +318,36 @@ export const api = {
366
318
  TetherError,
367
319
  resolveProductKey,
368
320
  setCountry,
369
- detectMarket: (markets) => loadPart('markets').then((m) => m.detectMarket(markets || cfg.markets)),
370
- getLocalization: () => loadPart('markets').then((m) => m.getLocalization()),
371
- diagnose: (opts) => loadPart('diagnose').then((m) => m.diagnose({ ...opts, version: VERSION })),
321
+ detectMarket,
322
+ getLocalization,
323
+ diagnose,
372
324
  __core: coreModules,
373
- __parts: {},
374
- on(name, fn) {
375
- const h = (e) => fn(e.detail, e);
376
- document.addEventListener(`${cfg.prefix}:${name}`, h);
377
- return () => document.removeEventListener(`${cfg.prefix}:${name}`, h);
378
- },
325
+ // The `<script>` build's parts register here when their file has loaded.
326
+ __parts: /** @type {Record<string, unknown>} */ ({}),
327
+ on,
379
328
  };
380
329
 
330
+ /** @typedef {typeof api} TetherApi */
331
+
381
332
  export default api;
382
333
 
383
- // Auto-start for the plain <script> setup.
384
- if (typeof window !== 'undefined' && typeof document !== 'undefined') {
385
- window.Tether = api;
386
- // Not with a framework integration (Astro), which starts Tether itself.
387
- const auto = window.TETHER_CONFIG && window.TETHER_CONFIG.autoMount !== false && !window.TETHER_INTEGRATION;
388
- if (auto) {
389
- const start = () => init().then(() => mount(document));
390
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
391
- else start();
392
- }
334
+ /**
335
+ * The plain `<script>` setup starts by itself, unless `autoMount: false` or a framework integration (Astro), which
336
+ * starts Tether itself.
337
+ */
338
+ function autoStart() {
339
+ const globals = pageGlobals();
340
+ globals.Tether = api;
341
+ if (!globals.TETHER_CONFIG || globals.TETHER_CONFIG.autoMount === false || globals.TETHER_INTEGRATION) return;
342
+ const start = () => {
343
+ init()
344
+ .then(() => mount(document))
345
+ .catch((/** @type {unknown} */ error) => {
346
+ reportError(error, 'init');
347
+ });
348
+ };
349
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
350
+ else start();
393
351
  }
352
+
353
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') autoStart();