@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/config.js CHANGED
@@ -1,85 +1,349 @@
1
1
  /**
2
- * Runtime config. One shared object, mutated by configure(), read everywhere.
2
+ * The runtime configuration. `cfg` is one shared object that every browser module imports; `configure()` replaces its
3
+ * contents in place, so those imports see the new values, and `setCountry()` changes its market keys the same way.
4
+ * Options that end up in query text or attribute names are validated here, once, where they arrive.
3
5
  */
6
+ /** @import { Money } from './shopify-values.js' */
4
7
 
8
+ /**
9
+ * @typedef {object} MarketsConfig Where the visitor's market comes from (`markets.js`).
10
+ * @property {boolean | ('saved' | 'server' | 'webflow')[]} [detect] true: every source; a list: only those.
11
+ * @property {Record<string, { country?: string, language?: string, locale?: string }>} [locales] Webflow locale
12
+ * (subdirectory or html lang) to market.
13
+ * @property {string} [cookie] The cookie a server sets with the visitor's country.
14
+ * @property {boolean} [remember] Remember the visitor's choice (default true).
15
+ * @property {boolean} [strict] With `locales`: ignore an html lang that isn't in the map.
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} AnalyticsConfig The optional analytics part.
20
+ * @property {boolean | string} [dataLayer] GA4/GTM events, into `window.dataLayer` or the named array.
21
+ * @property {boolean | { consent?: 'no-banner' | 'default-banner' | 'custom-banner', proxy?: string }} [shopify]
22
+ * Shopify's own analytics.
23
+ * @property {(name: string, data: Record<string, unknown>) => void} [onEvent]
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} AccountConfig The optional customer accounts part.
28
+ * @property {string} [clientId] Browser mode: the Customer Account API client id.
29
+ * @property {string} [server] Server mode: the Worker's account routes, a URL or a path on this site.
30
+ * @property {string} [orderUrl] The site's own order page, with `{id}` for the numeric order id.
31
+ * @property {string} [subscriptionsUrl] Shopify's subscription management page.
32
+ * @property {string} [paymentUrl] A separate page for payment methods (default `subscriptionsUrl`).
33
+ * @property {string} [callbackUrl] Where Shopify sends the visitor after login (default the current page).
34
+ * @property {string} [logoutUrl] Where Shopify sends the visitor after logout (default the home page).
35
+ * @property {boolean} [remember] Keep the login across tabs and restarts (localStorage, not sessionStorage).
36
+ * @property {string} [tokenUrl] The site's own token endpoint, for when the browser can't call Shopify's.
37
+ * @property {string} [shopDomain] The *.myshopify.com domain, when `domain` is another one.
38
+ * @property {string} [locale] The language of Shopify's login pages.
39
+ * @property {(url: string) => void} [navigate] Replaces `location.assign` for the login redirect (popups, tests).
40
+ */
41
+
42
+ /** @typedef {{ namespace: string, key: string }} MetafieldIdentifier */
43
+ /** @typedef {string | MetafieldIdentifier} MetafieldRef `'namespace.key'` or the identifier itself. */
44
+ /** @typedef {{ code: string, message: string, target?: string }} CartWarning Shopify's note on a cart change. */
45
+ /** @typedef {'_self' | '_blank'} CheckoutTarget */
46
+ /** @typedef {'one-time' | 'subscription'} PurchaseKind */
47
+
48
+ /**
49
+ * @typedef {object} TetherConfig Every option, with its default filled in.
50
+ * @property {string} domain The store's domain, without protocol or path.
51
+ * @property {string} token The public Storefront API access token.
52
+ * @property {string} apiVersion A dated Shopify API version.
53
+ * @property {string | undefined} country An ISO country code for `@inContext(country:)`.
54
+ * @property {string | undefined} language A language for `@inContext(language:)`.
55
+ * @property {string | undefined} locale An `Intl` locale for money and dates.
56
+ * @property {MarketsConfig | null} markets
57
+ * @property {string} prefix The attribute prefix: `tether` reads `tether-product`, `tether-bind`.
58
+ * @property {string} productPath A folder (`/product`) or a template with `{handle}` / `{id}`.
59
+ * @property {string} collectionPath
60
+ * @property {MetafieldRef[]} metafields The product metafields to fetch.
61
+ * @property {number} imagesFirst
62
+ * @property {number} variantsFirst
63
+ * @property {number} sellingPlanGroupsFirst
64
+ * @property {number} sellingPlansFirst
65
+ * @property {number} collectionFirst
66
+ * @property {number} retries Retries of a request that failed on the network, a 429/5xx or Shopify's throttling.
67
+ * @property {boolean} revalidate Refresh server-rendered products once in the background.
68
+ * @property {string} storageKey The localStorage key of the cart id (default `<prefix>:cart:<domain>`).
69
+ * @property {string} cartDrawerOpenClass
70
+ * @property {boolean} openCartOnAdd
71
+ * @property {boolean} cartFocus Move focus into the open cart, keep Tab inside it, restore it on close.
72
+ * @property {CheckoutTarget} checkoutTarget
73
+ * @property {PurchaseKind} defaultPurchase
74
+ * @property {boolean} variantUrl `?variant=` follows the visitor's choice on the page's main product.
75
+ * @property {number} maxAge Seconds a Shopify answer may be cached; 0 leaves it to Shopify, which can be hours.
76
+ * @property {number} lowStock `tether-show="low-stock"` at this many or fewer (needs inventory access).
77
+ * @property {number} searchDelay Milliseconds the search part waits after the last keystroke.
78
+ * @property {boolean} autoMount Mount the document on DOMContentLoaded.
79
+ * @property {boolean} observe Mount elements added later (CMS load more, filters).
80
+ * @property {boolean} astro Mount on Astro's `astro:page-load` and close the cart on navigation.
81
+ * @property {string | null} partsUrl The folder of the optional parts, for the `<script>` build.
82
+ * @property {AnalyticsConfig | null} analytics
83
+ * @property {AccountConfig | null} account
84
+ * @property {((warning: CartWarning) => string | null | undefined) | null} warningText The site's text for a cart
85
+ * warning; nothing keeps Shopify's.
86
+ * @property {((money: Money, locale: string | undefined) => string) | null} formatMoney
87
+ * @property {((reason: string) => void) | null} onLayoutChange Default: `ScrollTrigger.refresh()` when present.
88
+ * @property {((drawers: HTMLElement[]) => void) | null} onCartOpen
89
+ * @property {((drawers: HTMLElement[]) => void) | null} onCartClose
90
+ * @property {((node: HTMLElement) => void) | null} onLineEnter
91
+ * @property {((node: HTMLElement, done: () => void) => void) | null} onLineLeave Animates a line out, then calls
92
+ * `done()`.
93
+ * @property {((element: HTMLElement, previousText: string) => void) | null} onValueChange
94
+ * @property {((error: unknown, context: string) => void) | null} onError Every failure that reaches the visitor.
95
+ * @property {boolean} debug
96
+ */
97
+
98
+ /** @typedef {{ [Key in keyof TetherConfig]?: TetherConfig[Key] }} TetherOptions */
99
+
100
+ /** @type {Readonly<TetherConfig>} */
5
101
  export const DEFAULTS = {
6
- // Shopify Storefront API
7
- domain: '', // 'your-store.myshopify.com'
8
- token: '', // public Storefront API access token
102
+ domain: '',
103
+ token: '',
9
104
  apiVersion: '2026-07',
10
- country: undefined, // ISO code, e.g. 'NL' -> @inContext(country: NL)
11
- language: undefined, // e.g. 'NL' -> @inContext(language: NL)
12
- locale: undefined, // Intl locale for money/dates, e.g. 'nl-NL'
13
- markets: null, // { detect: true, locales: { de: { country: 'DE', language: 'DE' } }, cookie, remember } (markets.js)
14
-
15
- // Markup
16
- prefix: 'tether', // attribute prefix: tether-product, tether-bind, ...
17
- productPath: '/product', // Webflow CMS template path for products
105
+ country: undefined,
106
+ language: undefined,
107
+ locale: undefined,
108
+ markets: null,
109
+ prefix: 'tether',
110
+ productPath: '/product',
18
111
  collectionPath: '/collection',
19
-
20
- // Fetching
21
- metafields: [], // ['custom.material', { namespace, key }]
112
+ metafields: [],
22
113
  imagesFirst: 25,
23
114
  variantsFirst: 100,
24
115
  sellingPlanGroupsFirst: 10,
25
116
  sellingPlansFirst: 25,
26
117
  collectionFirst: 24,
27
118
  retries: 2,
28
- revalidate: true, // products from the server (tether/server hydrate()): refresh once in the background
29
-
30
- // Cart
31
- storageKey: null, // defaults to `tether:cart:<domain>`
119
+ revalidate: true,
120
+ storageKey: '',
32
121
  cartDrawerOpenClass: 'is-open',
33
122
  openCartOnAdd: true,
34
- cartFocus: true, // move focus into the open cart, keep Tab inside it, restore it on close
35
- checkoutTarget: '_self', // '_self' or '_blank'
36
-
37
- // Subscriptions
38
- defaultPurchase: 'one-time', // 'one-time' | 'subscription'
39
- variantUrl: false, // true: ?variant= follows the visitor's choice on the main product (shareable links)
40
- maxAge: 60, // seconds a Shopify answer may be cached (0 = Shopify's own caching, can be hours)
41
- lowStock: 5, // tether-show="low-stock" at this many or fewer (needs inventory access)
42
-
43
- // Mounting
44
- autoMount: true, // mount document on DOMContentLoaded
45
- observe: false, // auto-mount nodes added later (CMS load-more, filters)
46
- astro: true, // mount on Astro View Transitions (astro:page-load); false if you do it yourself
47
- partsUrl: null, // folder of tether.markets/diagnose(.min).js for the <script> build (default: next to it)
48
-
49
- // Formatting
50
- formatMoney: null, // (money, locale) => string
51
-
52
- // Hooks (all optional)
53
- onLayoutChange: null, // (reason) => void. Default refreshes ScrollTrigger if present
54
- onCartOpen: null, // (drawers) => void, e.g. lock scroll
55
- onCartClose: null, // (drawers) => void
56
- onLineEnter: null, // (node) => void, animate a new cart line in
57
- onLineLeave: null, // (node, done) => void, animate out, then call done()
58
- onValueChange: null, // (el, previousText) => void, e.g. text scramble
59
- onError: null, // (error, context) => void
60
-
123
+ cartFocus: true,
124
+ checkoutTarget: '_self',
125
+ defaultPurchase: 'one-time',
126
+ variantUrl: false,
127
+ maxAge: 60,
128
+ lowStock: 5,
129
+ searchDelay: 150,
130
+ autoMount: true,
131
+ observe: false,
132
+ astro: true,
133
+ partsUrl: null,
134
+ analytics: null,
135
+ account: null,
136
+ warningText: null,
137
+ formatMoney: null,
138
+ onLayoutChange: null,
139
+ onCartOpen: null,
140
+ onCartClose: null,
141
+ onLineEnter: null,
142
+ onLineLeave: null,
143
+ onValueChange: null,
144
+ onError: null,
61
145
  debug: false,
62
146
  };
63
147
 
148
+ /** @type {TetherConfig} */
64
149
  export const cfg = { ...DEFAULTS };
65
150
 
66
- export function configure(overrides = {}) {
67
- Object.keys(cfg).forEach((k) => delete cfg[k]);
68
- Object.assign(cfg, DEFAULTS, overrides);
69
- if (cfg.domain) cfg.domain = String(cfg.domain).replace(/^https?:\/\//, '').replace(/\/.*$/, '');
70
- if (!cfg.storageKey) cfg.storageKey = `${cfg.prefix}:cart:${cfg.domain}`;
71
- if (cfg.apiVersion === 'unstable') warn('apiVersion "unstable" changes without notice. Pin a dated version (e.g. 2026-07).');
72
- return cfg;
151
+ /** The prefix becomes part of attribute names, CSS selectors and event names, so it is kept to those characters. */
152
+ const PREFIX = /^[a-z][a-z0-9-]*$/;
153
+ /**
154
+ * Metafield identifiers are written into the product fragment's text: a GraphQL fragment can't declare variables, so
155
+ * they can't be sent as variables. Only these characters are let through, which covers Shopify's namespaces and keys.
156
+ */
157
+ const METAFIELD_PART = /^[A-Za-z0-9_.-]+$/;
158
+ /** A namespace is the same, or an app-reserved one: `$app` or `$app:<name>`. */
159
+ const METAFIELD_NAMESPACE = /^(\$app(:[A-Za-z0-9_-]+)?|[A-Za-z0-9_.-]+)$/;
160
+
161
+ /**
162
+ * @typedef {'imagesFirst' | 'variantsFirst' | 'sellingPlanGroupsFirst' | 'sellingPlansFirst' | 'collectionFirst'
163
+ * | 'retries' | 'maxAge' | 'lowStock'} CountOption
164
+ */
165
+ /**
166
+ * @typedef {'warningText' | 'formatMoney' | 'onLayoutChange' | 'onCartOpen' | 'onCartClose' | 'onLineEnter'
167
+ * | 'onLineLeave' | 'onValueChange' | 'onError'} HookOption
168
+ */
169
+
170
+ /** @type {readonly CountOption[]} Options that are written into queries as list sizes or used as counts. */
171
+ const COUNT_OPTIONS = [
172
+ 'imagesFirst',
173
+ 'variantsFirst',
174
+ 'sellingPlanGroupsFirst',
175
+ 'sellingPlansFirst',
176
+ 'collectionFirst',
177
+ 'retries',
178
+ 'maxAge',
179
+ 'lowStock',
180
+ ];
181
+ /** @type {readonly HookOption[]} */
182
+ const HOOK_OPTIONS = [
183
+ 'warningText',
184
+ 'formatMoney',
185
+ 'onLayoutChange',
186
+ 'onCartOpen',
187
+ 'onCartClose',
188
+ 'onLineEnter',
189
+ 'onLineLeave',
190
+ 'onValueChange',
191
+ 'onError',
192
+ ];
193
+
194
+ /**
195
+ * @template T
196
+ * @param {string} name
197
+ * @param {unknown} value
198
+ * @param {T} fallback
199
+ * @returns {T} `fallback`, with a warning unless `value` was left out.
200
+ */
201
+ function invalidOption(name, value, fallback) {
202
+ if (value !== undefined) warn(`invalid ${name} ${JSON.stringify(value)}: using ${JSON.stringify(fallback)}`);
203
+ return fallback;
204
+ }
205
+
206
+ /**
207
+ * @param {CountOption} name
208
+ * @param {unknown} value
209
+ * @returns {number} `value` when it is a non-negative integer, else the default.
210
+ */
211
+ function countOption(name, value) {
212
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value;
213
+ return invalidOption(name, value, DEFAULTS[name]);
73
214
  }
74
215
 
216
+ /**
217
+ * @template {string} Choice
218
+ * @param {string} name
219
+ * @param {unknown} value
220
+ * @param {readonly [Choice, ...Choice[]]} choices The first one is the default.
221
+ * @returns {Choice}
222
+ */
223
+ function choiceOption(name, value, choices) {
224
+ return choices.find((choice) => choice === value) ?? invalidOption(name, value, choices[0]);
225
+ }
226
+
227
+ /**
228
+ * @param {unknown} value
229
+ * @returns {string} The store's domain without protocol or path: `https://shop.example/x` → `shop.example`.
230
+ */
231
+ function domainOption(value) {
232
+ if (typeof value !== 'string') return '';
233
+ return value.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
234
+ }
235
+
236
+ /**
237
+ * @param {unknown} ref
238
+ * @returns {MetafieldIdentifier | null} The identifier of `'namespace.key'` or `{ namespace, key }`, when both parts are
239
+ * valid identifiers.
240
+ */
241
+ export function toMetafieldIdentifier(ref) {
242
+ /** @type {unknown} */
243
+ let namespace;
244
+ /** @type {unknown} */
245
+ let key;
246
+ if (typeof ref === 'string') {
247
+ const dot = ref.indexOf('.');
248
+ namespace = dot > 0 ? ref.slice(0, dot) : '';
249
+ key = ref.slice(dot + 1);
250
+ } else if (typeof ref === 'object' && ref !== null) {
251
+ namespace = Reflect.get(ref, 'namespace');
252
+ key = Reflect.get(ref, 'key');
253
+ }
254
+ if (typeof namespace !== 'string' || typeof key !== 'string') return null;
255
+ return METAFIELD_NAMESPACE.test(namespace) && METAFIELD_PART.test(key) ? { namespace, key } : null;
256
+ }
257
+
258
+ /**
259
+ * @param {unknown} value
260
+ * @returns {MetafieldRef[]} The refs that name a valid identifier, as written; each other one is skipped with a warning.
261
+ */
262
+ function metafieldsOption(value) {
263
+ if (!Array.isArray(value)) return invalidOption('metafields', value, []);
264
+ /** @type {MetafieldRef[]} */
265
+ const refs = [];
266
+ for (const ref of /** @type {unknown[]} */ (value)) {
267
+ const identifier = toMetafieldIdentifier(ref);
268
+ if (identifier) refs.push(typeof ref === 'string' ? ref : identifier);
269
+ else invalidOption('metafield', ref, null);
270
+ }
271
+ return refs;
272
+ }
273
+
274
+ /**
275
+ * The configuration `options` describe: defaults filled in, counts, prefix, choices and metafields validated (an
276
+ * invalid value warns and falls back to its default), hooks that aren't functions dropped so callers only check for
277
+ * null.
278
+ *
279
+ * @param {TetherOptions} options
280
+ * @returns {TetherConfig}
281
+ */
282
+ export function resolveConfig(options) {
283
+ const domain = domainOption(options.domain);
284
+ const prefix =
285
+ typeof options.prefix === 'string' && PREFIX.test(options.prefix)
286
+ ? options.prefix
287
+ : invalidOption('prefix', options.prefix, DEFAULTS.prefix);
288
+ /** @type {TetherConfig} */
289
+ const resolved = {
290
+ ...DEFAULTS,
291
+ ...options,
292
+ domain,
293
+ prefix,
294
+ storageKey: options.storageKey || `${prefix}:cart:${domain}`,
295
+ metafields: metafieldsOption(options.metafields ?? []),
296
+ checkoutTarget: choiceOption('checkoutTarget', options.checkoutTarget, ['_self', '_blank']),
297
+ defaultPurchase: choiceOption('defaultPurchase', options.defaultPurchase, ['one-time', 'subscription']),
298
+ };
299
+ for (const name of COUNT_OPTIONS) resolved[name] = countOption(name, options[name]);
300
+ for (const name of HOOK_OPTIONS) if (typeof resolved[name] !== 'function') resolved[name] = null;
301
+ if (resolved.apiVersion === 'unstable') {
302
+ warn('apiVersion "unstable" changes without notice. Pin a dated version (e.g. 2026-07).');
303
+ }
304
+ return resolved;
305
+ }
306
+
307
+ /**
308
+ * Replaces the shared configuration with `options` over the defaults (see `resolveConfig`).
309
+ *
310
+ * @param {TetherOptions} [options]
311
+ * @returns {TetherConfig} The shared `cfg`.
312
+ */
313
+ export function configure(options = {}) {
314
+ const resolved = resolveConfig(options);
315
+ for (const key of Object.keys(cfg)) {
316
+ if (!(key in resolved)) Reflect.deleteProperty(cfg, key);
317
+ }
318
+ return Object.assign(cfg, resolved);
319
+ }
320
+
321
+ /** @returns {boolean} True once a domain and a token are set. */
75
322
  export const isConfigured = () => Boolean(cfg.domain && cfg.token);
76
323
 
77
- export function log(...a) { if (cfg.debug) console.log(`[${cfg.prefix}]`, ...a); }
78
- export function warn(...a) { console.warn(`[${cfg.prefix}]`, ...a); }
324
+ /** @param {...unknown} args */
325
+ export function log(...args) {
326
+ if (cfg.debug) console.log(`[${cfg.prefix}]`, ...args);
327
+ }
328
+
329
+ /** @param {...unknown} args */
330
+ export function warn(...args) {
331
+ console.warn(`[${cfg.prefix}]`, ...args);
332
+ }
79
333
 
80
- export function reportError(err, context) {
81
- warn(context, err && (err.message || err.code) ? err.message || err.code : err);
82
- if (typeof cfg.onError === 'function') {
83
- try { cfg.onError(err, context); } catch { /* never let a hook break us */ }
334
+ /**
335
+ * Logs a failure that reaches the visitor and hands it to `onError`. A throwing `onError` is logged, never rethrown:
336
+ * the site's hook must not break the render that reported the failure.
337
+ *
338
+ * @param {unknown} error
339
+ * @param {string} context What failed, e.g. `product coffee-box`.
340
+ */
341
+ export function reportError(error, context) {
342
+ warn(context, error instanceof Error && error.message ? error.message : error);
343
+ if (!cfg.onError) return;
344
+ try {
345
+ cfg.onError(error, context);
346
+ } catch (hookError) {
347
+ warn('onError threw', hookError);
84
348
  }
85
349
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The cart's markup controls, delegated from the document so they keep working across page swaps: open, close,
3
+ * clear and checkout buttons, a line's +, - and remove buttons, its quantity input and plan select, the cart note, and
4
+ * the keyboard of the open drawer (Escape closes it, Tab stays inside it).
5
+ */
6
+ import { cfg, reportError } from '../config.js';
7
+ import { Cart, trapFocus } from '../cart.js';
8
+ import { sel, busy } from '../dom.js';
9
+
10
+ /** @returns {string} The attribute a rendered cart line carries its line id in. */
11
+ const lineKeyAttribute = () => `data-${cfg.prefix}-key`;
12
+
13
+ /**
14
+ * @param {EventTarget | null} target
15
+ * @returns {target is Element}
16
+ */
17
+ const isElement = (target) => target instanceof window.Element;
18
+
19
+ /**
20
+ * @param {Element} target
21
+ * @returns {{ line: Element, id: string } | null} The rendered cart line `target` is in, with its line id.
22
+ */
23
+ function lineOf(target) {
24
+ const line = target.closest(`[${lineKeyAttribute()}]`);
25
+ const id = line?.getAttribute(lineKeyAttribute());
26
+ return line && id ? { line, id } : null;
27
+ }
28
+
29
+ /**
30
+ * @param {Promise<unknown>} change
31
+ * @param {string} context
32
+ */
33
+ function reportFailure(change, context) {
34
+ change.catch((/** @type {unknown} */ error) => {
35
+ reportError(error, context);
36
+ });
37
+ }
38
+
39
+ /**
40
+ * A line's remove, + and - buttons.
41
+ *
42
+ * @param {MouseEvent} event
43
+ * @param {Element} target
44
+ */
45
+ function onLineClick(event, target) {
46
+ const found = lineOf(target);
47
+ if (!found?.line.closest(sel('cart-lines'))) return;
48
+ const { line, id } = found;
49
+ if (target.closest(sel('line-remove'))) {
50
+ event.preventDefault();
51
+ line.classList.add('is-busy');
52
+ reportFailure(Cart.removeLine(id), 'cart line');
53
+ return;
54
+ }
55
+ const delta = lineDelta(target);
56
+ if (!delta || !Cart.lines.some((candidate) => candidate.id === id)) return;
57
+ event.preventDefault();
58
+ reportFailure(Cart.changeQuantity(id, delta), 'cart line');
59
+ }
60
+
61
+ /**
62
+ * @param {Element} target
63
+ * @returns {1 | -1 | 0} The quantity change of a + or - button, 0 for anything else.
64
+ */
65
+ function lineDelta(target) {
66
+ if (target.closest(sel('line-plus'))) return 1;
67
+ if (target.closest(sel('line-minus'))) return -1;
68
+ return 0;
69
+ }
70
+
71
+ /** @param {MouseEvent} event */
72
+ function onClick(event) {
73
+ const { target } = event;
74
+ if (!isElement(target)) return;
75
+ if (target.closest(sel('cart-open'))) {
76
+ event.preventDefault();
77
+ Cart.toggle();
78
+ return;
79
+ }
80
+ if (target.closest(sel('cart-close'))) {
81
+ event.preventDefault();
82
+ Cart.close();
83
+ return;
84
+ }
85
+ const clear = target.closest(sel('cart-clear'));
86
+ if (clear) {
87
+ event.preventDefault();
88
+ void busy(clear, () =>
89
+ Cart.clear().catch((/** @type {unknown} */ error) => {
90
+ reportError(error, 'cart clear');
91
+ }),
92
+ );
93
+ return;
94
+ }
95
+ const checkout = target.closest(sel('checkout'));
96
+ if (checkout) {
97
+ event.preventDefault();
98
+ void busy(checkout, () => Cart.checkout());
99
+ return;
100
+ }
101
+ onLineClick(event, target);
102
+ }
103
+
104
+ /**
105
+ * The line shows `is-busy` and `aria-busy` until Shopify has answered; the next render removes them.
106
+ *
107
+ * @param {Element} line
108
+ * @param {Promise<unknown>} change
109
+ * @param {string} context
110
+ */
111
+ function showLineBusy(line, change, context) {
112
+ line.classList.add('is-busy');
113
+ line.setAttribute('aria-busy', 'true');
114
+ reportFailure(change, context);
115
+ }
116
+
117
+ /** @param {Event} event */
118
+ function onChange(event) {
119
+ const { target } = event;
120
+ if (!isElement(target)) return;
121
+ const found = lineOf(target);
122
+ if (found && target instanceof window.HTMLInputElement && target.matches(sel('line', 'quantity'))) {
123
+ showLineBusy(found.line, Cart.updateLine(found.id, parseInt(target.value, 10) || 0), 'cart line');
124
+ } else if (found && target instanceof window.HTMLSelectElement && target.matches(sel('line-plan'))) {
125
+ showLineBusy(found.line, Cart.updateLine(found.id, { sellingPlanId: target.value || null }), 'cart line plan');
126
+ } else if (hasValue(target) && target.matches(sel('cart-note'))) {
127
+ reportFailure(Cart.setNote(target.value), 'cart note');
128
+ }
129
+ }
130
+
131
+ /**
132
+ * @param {Element} target
133
+ * @returns {target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement}
134
+ */
135
+ const hasValue = (target) =>
136
+ target instanceof window.HTMLInputElement ||
137
+ target instanceof window.HTMLTextAreaElement ||
138
+ target instanceof window.HTMLSelectElement;
139
+
140
+ /** @param {KeyboardEvent} event */
141
+ function onKeydown(event) {
142
+ if (!Cart.isOpen()) return;
143
+ if (event.key === 'Escape') {
144
+ Cart.close();
145
+ return;
146
+ }
147
+ trapFocus(event);
148
+ }
149
+
150
+ /** Binds the document-wide listeners, once per page load of the script. */
151
+ export function bindCartControls() {
152
+ document.addEventListener('click', onClick);
153
+ document.addEventListener('change', onChange);
154
+ document.addEventListener('keydown', onKeydown);
155
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The discount code form (`tether-discount-form` with `tether-discount-input`) and the remove buttons of applied codes
3
+ * (`tether-discount-remove` inside `tether-discount-code`), delegated from the document. A code Shopify refuses marks
4
+ * the form `is-invalid` (and the input `aria-invalid`) until the visitor types again.
5
+ */
6
+ import { reportError } from '../config.js';
7
+ import { TetherError } from '../client.js';
8
+ import { Cart } from '../cart.js';
9
+ import { attr, sel, busy, emit } from '../dom.js';
10
+
11
+ /**
12
+ * @param {EventTarget | null} target
13
+ * @returns {target is Element}
14
+ */
15
+ const isElement = (target) => target instanceof window.Element;
16
+
17
+ /**
18
+ * @param {Element} form
19
+ * @returns {HTMLInputElement | null}
20
+ */
21
+ function codeInputOf(form) {
22
+ const input = form.querySelector(sel('discount-input')) ?? form.querySelector('input');
23
+ return input instanceof window.HTMLInputElement ? input : null;
24
+ }
25
+
26
+ /**
27
+ * @param {Element} form
28
+ * @param {HTMLInputElement | null} input
29
+ * @param {boolean} invalid
30
+ */
31
+ function markInvalid(form, input, invalid) {
32
+ form.classList.toggle('is-invalid', invalid);
33
+ if (invalid) input?.setAttribute('aria-invalid', 'true');
34
+ else input?.removeAttribute('aria-invalid');
35
+ }
36
+
37
+ /**
38
+ * @param {Element} form
39
+ * @param {HTMLInputElement | null} input
40
+ * @param {string} code
41
+ */
42
+ async function applyCode(form, input, code) {
43
+ try {
44
+ await Cart.applyDiscount(code);
45
+ markInvalid(form, input, false);
46
+ emit('discount-applied', { code }, form);
47
+ if (input) input.value = '';
48
+ } catch (error) {
49
+ markInvalid(form, input, true);
50
+ if (error instanceof TetherError && error.code === 'INVALID_DISCOUNT') emit('discount-invalid', { code }, form);
51
+ else reportError(error, 'discount');
52
+ }
53
+ }
54
+
55
+ /** @param {SubmitEvent} event */
56
+ function onSubmit(event) {
57
+ const { target } = event;
58
+ const form = isElement(target) ? target.closest(sel('discount-form')) : null;
59
+ if (!form) return;
60
+ event.preventDefault();
61
+ const input = codeInputOf(form);
62
+ const code = input?.value.trim();
63
+ if (!code) return;
64
+ void busy(form, () => applyCode(form, input, code));
65
+ }
66
+
67
+ /** @param {Event} event */
68
+ function onInput(event) {
69
+ const { target } = event;
70
+ if (!isElement(target)) return;
71
+ const form = target.closest(sel('discount-form'));
72
+ if (!form) return;
73
+ form.classList.remove('is-invalid');
74
+ target.removeAttribute('aria-invalid');
75
+ }
76
+
77
+ /** @param {MouseEvent} event */
78
+ function onClick(event) {
79
+ const { target } = event;
80
+ const remove = isElement(target) ? target.closest(sel('discount-remove')) : null;
81
+ if (!remove) return;
82
+ event.preventDefault();
83
+ const code = remove.closest(sel('discount-code'))?.getAttribute(attr('discount-code'));
84
+ if (!code) return;
85
+ Cart.removeDiscount(code).catch((/** @type {unknown} */ error) => {
86
+ reportError(error, 'discount');
87
+ });
88
+ }
89
+
90
+ /** Binds the document-wide listeners, once per page load of the script. */
91
+ export function bindDiscountControls() {
92
+ // Capture: the submit is handled before a router (Astro's ClientRouter) turns it into a navigation.
93
+ document.addEventListener('submit', onSubmit, true);
94
+ document.addEventListener('input', onInput);
95
+ document.addEventListener('click', onClick);
96
+ }