@occluze/tether 0.6.7 → 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 +773 -442
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +648 -318
  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 +331 -242
  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 +3176 -2015
  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 +69 -298
  58. package/src/account.js +280 -323
  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 +320 -488
  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 -287
  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-Q6E2TFEV.js +0 -1
  120. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  121. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  122. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  123. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  124. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  125. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  126. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  127. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  128. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  129. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  130. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  131. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  132. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  133. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  134. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/index.js CHANGED
@@ -1,346 +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.7';
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
- let mountedBody = null; // the page body mount(document) last ran on (Astro's first page-load repeats it)
29
73
 
30
- /** Mount every product scope and product list inside `root`. Safe to call repeatedly. */
31
- export function mount(root = document) {
32
- if (!isConfigured()) return 0;
33
- if (root === document) mountedBody = document.body;
34
- let count = 0;
35
- pruneLive(); // page transitions detach old roots: don't keep them alive
36
- 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;
37
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;
38
88
  within(root, listSelector()).forEach((el) => {
39
89
  if (mountedLists.has(el)) return;
40
90
  mountedLists.add(el);
41
91
  count++;
42
92
  const list = new ListScope(el);
43
93
  liveLists.add(list);
44
- el.addEventListener(`${cfg.prefix}:list-rendered`, (e) => mount(e.detail.container));
45
- 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());
46
99
  });
100
+ return count;
101
+ }
47
102
 
48
- within(root, sel('product')).forEach((el) => { if (mountProduct(el)) count++; });
49
-
50
- // 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) {
51
109
  if (within(root, `${sel('country-select')},${sel('country-bind')}`).length) {
52
- 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
+ });
53
115
  }
54
-
55
- // Customer accounts: their own optional part, when configured and the page (or a login) needs it.
56
- 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
57
- || /[?&]code=/.test(window.location.search) || hasAccountSession())) {
58
- loadPart('account').then((m) => m.mountAccount(root)).catch((err) => reportError(err, 'account'));
59
- }
60
-
61
- // Search as you type: its own optional part, only for pages that have it.
116
+ mountAccountIfUsed(root);
62
117
  if (within(root, sel('search')).length) {
63
- 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
+ });
64
125
  }
126
+ }
65
127
 
66
-
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);
67
146
  settleMarket();
68
147
  Cart.render();
69
148
  log(`mounted ${count} scope(s)`, root);
70
149
  return count;
71
150
  }
72
151
 
73
- // ---------------------------------------------------------------------------
74
- // Markets: country select + switching
75
- // ---------------------------------------------------------------------------
76
-
77
- /** Call before a page transition removes `root` (closes the drawer etc.). */
152
+ /** Call before a page transition removes the page: closes the cart drawer. */
78
153
  export function unmount() {
79
154
  Cart.close();
80
155
  }
81
156
 
157
+ /**
158
+ * @param {Element | null | undefined} el
159
+ * @returns {ProductScope | null} The product scope `el` is in.
160
+ */
82
161
  export function getScope(el) {
83
- const root = el?.closest?.(sel('product'));
84
- return root ? scopes.get(root) || null : null;
162
+ const root = el?.closest(sel('product'));
163
+ return root ? (scopes.get(root) ?? null) : null;
85
164
  }
86
165
 
87
- // ---------------------------------------------------------------------------
88
- // Document-wide controls (delegated, so they survive page swaps)
89
- // ---------------------------------------------------------------------------
90
-
91
- let controlsBound = false;
92
-
93
166
  function bindControls() {
94
167
  if (controlsBound) return;
95
168
  controlsBound = true;
169
+ bindCartControls();
170
+ bindDiscountControls();
171
+ bindExtrasControls();
172
+ bindNavigation({ ready: () => initPromise ?? Promise.resolve(null), mount, unmount });
173
+ }
96
174
 
97
- document.addEventListener('click', (e) => {
98
- const t = e.target;
99
- if (!t?.closest) return;
100
-
101
- if (t.closest(sel('cart-open'))) { e.preventDefault(); Cart.toggle(); return; }
102
- if (t.closest(sel('cart-close'))) { e.preventDefault(); Cart.close(); return; }
103
-
104
- const clear = t.closest(sel('cart-clear'));
105
- if (clear) { e.preventDefault(); busy(clear, () => Cart.clear().catch((err) => reportError(err, 'cart clear'))); return; }
106
-
107
- const checkout = t.closest(sel('checkout'));
108
- if (checkout) { e.preventDefault(); busy(checkout, () => Cart.checkout()); return; }
109
-
110
- const line = t.closest(dataKeySel());
111
- if (line && line.closest(sel('cart-lines'))) {
112
- const id = line.getAttribute(`data-${cfg.prefix}-key`);
113
- const current = Cart.lines.find((l) => l.id === id);
114
- const run = (fn) => { e.preventDefault(); fn().catch((err) => reportError(err, 'cart line')); };
115
- if (t.closest(sel('line-remove'))) { line.classList.add('is-busy'); return run(() => Cart.removeLine(id)); }
116
- if (current && t.closest(sel('line-plus'))) return run(() => Cart.changeQuantity(id, 1));
117
- if (current && t.closest(sel('line-minus'))) return run(() => Cart.changeQuantity(id, -1));
118
- }
119
-
120
- const removeCode = t.closest(sel('discount-remove'));
121
- if (removeCode) {
122
- e.preventDefault();
123
- const code = removeCode.closest(sel('discount-code'))?.getAttribute(attr('discount-code'));
124
- if (code) Cart.removeDiscount(code).catch((err) => reportError(err, 'discount'));
125
- return;
126
- }
127
-
128
- const groupBtn = t.closest(sel('group-add'));
129
- if (groupBtn) { e.preventDefault(); busy(groupBtn, () => addGroup(groupBtn)); }
130
- });
131
-
132
- document.addEventListener('change', (e) => {
133
- const t = e.target;
134
- if (!t?.closest) return;
135
- const lineEl = t.closest(dataKeySel());
136
- const id = lineEl?.getAttribute(`data-${cfg.prefix}-key`);
137
- // The line shows is-busy / aria-busy until Shopify has answered (removed by the next render).
138
- const busyLine = (p, what) => { lineEl.classList.add('is-busy'); lineEl.setAttribute('aria-busy', 'true'); p.catch((err) => reportError(err, what)); };
139
- if (id && t.matches(`input${sel('line', 'quantity')}`)) {
140
- busyLine(Cart.updateLine(id, parseInt(t.value, 10) || 0), 'cart line');
141
- } else if (id && t.matches(`select${sel('line-plan')}`)) {
142
- busyLine(Cart.updateLine(id, { sellingPlanId: t.value || null }), 'cart line plan');
143
- } else if (t.matches(`select${sel('country-select')}`)) {
144
- if (t.value) setCountry(t.value).catch((err) => reportError(err, 'country'));
145
- } else if (t.matches(sel('cart-note'))) {
146
- Cart.setNote(t.value).catch((err) => reportError(err, 'cart note'));
147
- }
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));
148
182
  });
149
-
150
- document.addEventListener('submit', (e) => {
151
- const form = e.target.closest?.(sel('discount-form'));
152
- if (!form) return;
153
- e.preventDefault();
154
- const input = form.querySelector(sel('discount-input')) || form.querySelector('input');
155
- const code = input?.value.trim();
156
- if (!code) return;
157
- busy(form, async () => {
158
- const mark = (bad) => {
159
- form.classList.toggle('is-invalid', bad);
160
- if (input) { if (bad) input.setAttribute('aria-invalid', 'true'); else input.removeAttribute('aria-invalid'); }
161
- };
162
- try {
163
- await Cart.applyDiscount(code);
164
- mark(false);
165
- emit('discount-applied', { code }, form);
166
- if (input) input.value = '';
167
- } catch (err) {
168
- mark(true);
169
- if (err?.code === 'INVALID_DISCOUNT') emit('discount-invalid', { code }, form); else reportError(err, 'discount');
170
- }
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');
171
188
  });
172
- }, true); // capture: before a router (Astro's ClientRouter) turns the submit into a navigation
173
- document.addEventListener('input', (e) => {
174
- const f = e.target.closest?.(sel('discount-form'));
175
- if (f) { f.classList.remove('is-invalid'); e.target.removeAttribute?.('aria-invalid'); }
176
- });
177
-
178
- // Other tabs: the cart or the market changed there (handled by the extras part, loaded then).
179
- window.addEventListener('storage', (e) => {
180
- if (cfg.domain && e.key?.startsWith(`${cfg.prefix}:`)) loadPart('extras').then((m) => m.onStorage(e)).catch(() => {});
181
- });
182
- // Shareable variant links: only when turned on.
183
- document.addEventListener(`${cfg.prefix}:variant-changed`, (e) => {
184
- const root = e.target;
185
- if (cfg.variantUrl || root.hasAttribute?.(attr('variant-url'))) loadPart('extras').then((m) => m.writeVariantUrl(root, e.detail)).catch(() => {});
186
- });
189
+ }
187
190
 
188
- // Astro View Transitions (<ClientRouter />): mount every new page, close the cart while leaving.
189
- if (cfg.astro !== false) {
190
- // Astro fires this on the first load too: wait for init (the visitor's saved market) first.
191
- document.addEventListener('astro:page-load', () => (initPromise || Promise.resolve()).then(() => {
192
- // Navigated to a page of another market (e.g. /de/): switch first, then mount in that market.
193
- // Back on a normal page after one: the visitor's own choice, else the default market.
194
- let target = pageMarket();
195
- if (!target && onPageMarket) {
196
- try { target = JSON.parse(localStorage.getItem(`${cfg.prefix}:market:${cfg.domain}`) || 'null'); } catch { target = null; }
197
- target = target?.country ? target : baseMarket;
198
- }
199
- onPageMarket = Boolean(pageMarket());
200
- const differs = target?.country && (String(target.country).toUpperCase() !== String(cfg.country || '').toUpperCase()
201
- || String(target.language || '').toUpperCase() !== String(cfg.language || '').toUpperCase());
202
- if (differs) setCountry(target.country, { language: target.language ?? null, locale: target.locale, remember: false }).then(() => mount(document), () => mount(document));
203
- else if (mountedBody !== document.body) mount(document); // the first load is already mounted: no second round of requests
204
- }));
205
- document.addEventListener('astro:before-swap', () => { mountedBody = null; 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;
206
206
  }
207
-
208
- // Back online after a failed load: try the failed products and lists again.
209
- window.addEventListener('online', () => {
210
- retryFailed();
211
- liveLists.forEach((l) => { if (l.root.getAttribute(attr('state')) === 'error') l.load(); });
212
- });
213
-
214
- document.addEventListener('keydown', (e) => {
215
- if (!Cart.isOpen()) return;
216
- if (e.key === 'Escape') { Cart.close(); return; }
217
- if (e.key !== 'Tab' || cfg.cartFocus === false) return;
218
- // Keep Tab inside the open cart.
219
- const d = Cart.drawers().find((x) => x.classList.contains(cfg.cartDrawerOpenClass));
220
- const f = d ? focusables(d) : [];
221
- if (!f.length) return;
222
- const first = f[0];
223
- const last = f[f.length - 1];
224
- if (!d.contains(document.activeElement) || (e.shiftKey && document.activeElement === first) || (!e.shiftKey && document.activeElement === last)) {
225
- e.preventDefault();
226
- (e.shiftKey ? last : first).focus();
227
- }
228
- });
229
207
  }
230
208
 
231
- function dataKeySel() { return `[data-${cfg.prefix}-key]`; }
232
-
233
209
  /**
234
- * [tether-group] + [tether-group-add]: adds every product scope inside the
235
- * group in one request. Bundles, build-a-box, "complete the set".
236
- * 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.
237
215
  */
238
- export const addGroup = (btn) => loadPart('extras').then((m) => m.addGroup(btn));
239
-
240
- // ---------------------------------------------------------------------------
241
- // Init
242
- // ---------------------------------------------------------------------------
243
-
244
- let analyticsQueue = null;
245
-
246
216
  export function init(overrides = {}) {
247
217
  if (initPromise && !Object.keys(overrides).length) return initPromise;
248
- configure({ ...(window.TETHER_CONFIG || {}), ...overrides });
249
-
218
+ configure({ ...pageGlobals().TETHER_CONFIG, ...overrides });
250
219
  if (!isConfigured()) {
251
220
  warn('TETHER_CONFIG is missing domain or token. Tether is idle.');
252
221
  initPromise = Promise.resolve(null);
253
222
  return initPromise;
254
223
  }
255
-
256
224
  bindControls();
257
225
  if (cfg.observe) startObserver();
258
- // Analytics (optional part): buffer Tether's events until it has loaded, so none are missed.
259
- if (cfg.analytics && !analyticsQueue) {
260
- analyticsQueue = [];
261
- ['product-ready', 'list-ready', 'cart-updated', 'cart-toggled', 'checkout', 'search'].forEach((n) => document.addEventListener(`${cfg.prefix}:${n}`, (e) => analyticsQueue.push(e)));
262
- document.addEventListener('astro:page-load', () => analyticsQueue.push(new CustomEvent(`${cfg.prefix}:page`)));
263
- loadPart('analytics').then((m) => m.startAnalytics(analyticsQueue)).catch((err) => reportError(err, 'analytics'));
264
- }
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
+ }
265
236
 
266
- initPromise = applyMarket(overrides).then(() => Cart.init()).then(async (cart) => {
267
- // A saved cart from another market: move it to this one, so prices match the page.
268
- const want = cfg.country ? String(cfg.country).toUpperCase() : null;
269
- if (cart && want && cart.buyerIdentity?.countryCode && cart.buyerIdentity.countryCode !== want) {
270
- try { cart = await Cart.setBuyerIdentity({ countryCode: want }, { create: false }); } catch (err) { reportError(err, 'cart country'); }
271
- }
272
- emit('ready', { cart });
273
- 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);
274
257
  });
275
- return initPromise;
258
+ observer.observe(document.body, { childList: true, subtree: true });
276
259
  }
277
260
 
278
261
  /**
279
- * Detected market (saved choice, server hint, Webflow locale) wins over the static config.
280
- * Without markets.detect only the visitor's saved choice applies, read here without loading
281
- * the markets part.
282
- */
283
- /**
284
- * A page built for one market (e.g. /de/ pages): <meta name="tether-page-country" content="DE">,
285
- * 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.
286
263
  */
287
- function pageMarket(doc = document) {
288
- const get = (n) => doc.querySelector(`meta[name="${cfg.prefix}-page-${n}"]`)?.getAttribute('content') || undefined;
289
- const country = get('country');
290
- return country ? { country: country.toUpperCase(), language: get('language'), locale: get('locale') } : null;
291
- }
292
-
293
- let baseMarket = null; // the configured market, before a visitor's or page's market applied
294
- let onPageMarket = false;
295
-
296
- async function applyMarket(overrides) {
297
- const m = cfg.markets || {};
298
- baseMarket = { country: cfg.country, language: cfg.language, locale: cfg.locale };
299
- const page = pageMarket();
300
- onPageMarket = Boolean(page);
301
- if (page) {
302
- cfg.country = page.country;
303
- cfg.language = page.language ? page.language.toUpperCase() : undefined;
304
- if (page.locale) cfg.locale = page.locale;
305
- return;
306
- }
307
- if (m.detect === false) return;
308
- let market = null;
309
- if (m.detect) {
310
- try { market = (await loadPart('markets')).detectMarket(m); } catch (err) { reportError(err, 'markets'); return; }
311
- } else if (m.remember !== false) {
312
- try { market = JSON.parse(localStorage.getItem(`${cfg.prefix}:market:${cfg.domain}`) || 'null'); } catch { /* none */ }
313
- if (market && !/^[A-Z]{2}$/.test(market.country || '')) market = null;
314
- }
315
- if (!market) return;
316
- if (market.country) cfg.country = market.country;
317
- if (market.language) cfg.language = market.language;
318
- if (market.locale && !overrides.locale) cfg.locale = market.locale; // the visitor's market wins over the default
319
- log('market', market);
320
- }
264
+ const detectMarket = (markets) => loadPart('markets').then((part) => part.detectMarket(markets ?? cfg.markets));
321
265
 
322
- function hasAccountSession() {
323
- 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; }
324
- }
266
+ const getLocalization = () => loadPart('markets').then((part) => part.getLocalization());
325
267
 
326
- /** Customer accounts (optional part, loaded on first use). See src/account.js. */
327
- 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 }));
328
270
 
329
- /** Tether.setCountry('DE'): loads the markets part on first use. */
330
- export function setCountry(code, opts) {
331
- return loadPart('markets').then((m) => m.setCountry(code, opts));
332
- }
333
-
334
- function startObserver() {
335
- if (observer || typeof MutationObserver === 'undefined') return;
336
- let t = null;
337
- const queued = new Set();
338
- observer = new MutationObserver((muts) => {
339
- muts.forEach((m) => m.addedNodes.forEach((n) => { if (n.nodeType === 1) queued.add(n); }));
340
- clearTimeout(t);
341
- t = setTimeout(() => { queued.forEach((n) => { if (n.isConnected) mount(n); }); queued.clear(); }, 50);
342
- });
343
- 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
+ };
344
288
  }
345
289
 
346
290
  export const api = {
@@ -348,8 +292,14 @@ export const api = {
348
292
  init,
349
293
  mount,
350
294
  unmount,
351
- get config() { return cfg; },
352
- 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
+ },
353
303
  query: gql,
354
304
  withContext,
355
305
  cart: Cart,
@@ -368,28 +318,36 @@ export const api = {
368
318
  TetherError,
369
319
  resolveProductKey,
370
320
  setCountry,
371
- detectMarket: (markets) => loadPart('markets').then((m) => m.detectMarket(markets || cfg.markets)),
372
- getLocalization: () => loadPart('markets').then((m) => m.getLocalization()),
373
- diagnose: (opts) => loadPart('diagnose').then((m) => m.diagnose({ ...opts, version: VERSION })),
321
+ detectMarket,
322
+ getLocalization,
323
+ diagnose,
374
324
  __core: coreModules,
375
- __parts: {},
376
- on(name, fn) {
377
- const h = (e) => fn(e.detail, e);
378
- document.addEventListener(`${cfg.prefix}:${name}`, h);
379
- return () => document.removeEventListener(`${cfg.prefix}:${name}`, h);
380
- },
325
+ // The `<script>` build's parts register here when their file has loaded.
326
+ __parts: /** @type {Record<string, unknown>} */ ({}),
327
+ on,
381
328
  };
382
329
 
330
+ /** @typedef {typeof api} TetherApi */
331
+
383
332
  export default api;
384
333
 
385
- // Auto-start for the plain <script> setup.
386
- if (typeof window !== 'undefined' && typeof document !== 'undefined') {
387
- window.Tether = api;
388
- // Not with a framework integration (Astro), which starts Tether itself.
389
- const auto = window.TETHER_CONFIG && window.TETHER_CONFIG.autoMount !== false && !window.TETHER_INTEGRATION;
390
- if (auto) {
391
- const start = () => init().then(() => mount(document));
392
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
393
- else start();
394
- }
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();
395
351
  }
352
+
353
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') autoStart();