@occluze/tether 0.7.0 → 0.7.1

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 (132) hide show
  1. package/README.md +21 -21
  2. package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-CXMA5RPZ.js} +1 -1
  3. package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IZO7FEOZ.js} +1 -1
  4. package/dist/esm/chunks/{analytics-2V632IRV.js → analytics-PG5FPRDN.js} +1 -1
  5. package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-4HD4J5JC.js} +1 -1
  6. package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-6TFMLT4A.js} +2 -2
  7. package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-BBVUVPMO.js} +1 -1
  8. package/dist/esm/chunks/chunk-EW5SYO6L.js +1 -0
  9. package/dist/esm/chunks/chunk-HJY2JSNB.js +1 -0
  10. package/dist/esm/chunks/chunk-MQYB6BH4.js +1 -0
  11. package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-N3V45JPS.js} +1 -1
  12. package/dist/esm/chunks/{chunk-YLUULATY.js → chunk-RPMBCAGS.js} +1 -1
  13. package/dist/esm/chunks/{chunk-MPDWM2NN.js → chunk-VFNHIGCS.js} +1 -1
  14. package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-WT7RGGJU.js} +1 -1
  15. package/dist/esm/chunks/chunk-YN6LEBEN.js +1 -0
  16. package/dist/esm/chunks/chunk-Z6HS3H7K.js +1 -0
  17. package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-XCEBQKBO.js} +1 -1
  18. package/dist/esm/chunks/extras-4HU3VSBQ.js +1 -0
  19. package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-M4IQ62CT.js} +1 -1
  20. package/dist/esm/chunks/search-A5DS2IOS.js +1 -0
  21. package/dist/esm/index.js +1 -1
  22. package/dist/esm/server.js +2 -2
  23. package/dist/tether.account-pages.js +13 -3
  24. package/dist/tether.account-pages.min.js +1 -1
  25. package/dist/tether.account.js +13 -3
  26. package/dist/tether.account.min.js +1 -1
  27. package/dist/tether.analytics.js +18 -3
  28. package/dist/tether.analytics.min.js +1 -1
  29. package/dist/tether.diagnose.js +18 -3
  30. package/dist/tether.diagnose.min.js +2 -2
  31. package/dist/tether.extras.js +19 -6
  32. package/dist/tether.extras.min.js +1 -1
  33. package/dist/tether.js +162 -112
  34. package/dist/tether.markets.js +18 -3
  35. package/dist/tether.markets.min.js +1 -1
  36. package/dist/tether.min.js +3 -3
  37. package/dist/tether.min.js.map +4 -4
  38. package/dist/tether.search.js +18 -3
  39. package/dist/tether.search.min.js +1 -1
  40. package/dist/types/server/src/shared/cart-id.d.ts +2 -0
  41. package/dist/types/src/cart/mutations.d.ts +6 -0
  42. package/dist/types/src/cart/store.d.ts +5 -2
  43. package/dist/types/src/cart.d.ts +2 -2
  44. package/dist/types/src/client.d.ts +53 -19
  45. package/dist/types/src/config.d.ts +6 -1
  46. package/dist/types/src/index.d.ts +2 -2
  47. package/dist/types/src/product-shape.d.ts +3 -1
  48. package/package.json +1 -2
  49. package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
  50. package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
  51. package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
  52. package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
  53. package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
  54. package/dist/esm/chunks/search-UJ73YBQ5.js +0 -1
  55. package/src/account/addresses-view.ts +0 -58
  56. package/src/account/answers.ts +0 -79
  57. package/src/account/markup.ts +0 -36
  58. package/src/account/operations.ts +0 -145
  59. package/src/account/order-lines.ts +0 -75
  60. package/src/account/order-list.ts +0 -107
  61. package/src/account/order-page.ts +0 -137
  62. package/src/account/page-answers.ts +0 -317
  63. package/src/account/page-events.ts +0 -253
  64. package/src/account/server-client.ts +0 -183
  65. package/src/account/subscriptions-view.ts +0 -115
  66. package/src/account-api.ts +0 -98
  67. package/src/account-marks.ts +0 -35
  68. package/src/account-pages.ts +0 -81
  69. package/src/account.ts +0 -284
  70. package/src/amounts.ts +0 -64
  71. package/src/analytics-shopify.ts +0 -246
  72. package/src/analytics.ts +0 -315
  73. package/src/api-version.ts +0 -79
  74. package/src/astro.ts +0 -272
  75. package/src/cart/cart-shape.ts +0 -25
  76. package/src/cart/checkout.ts +0 -101
  77. package/src/cart/drawer.ts +0 -125
  78. package/src/cart/mutations.ts +0 -237
  79. package/src/cart/render.ts +0 -266
  80. package/src/cart/store.ts +0 -279
  81. package/src/cart.ts +0 -345
  82. package/src/client.ts +0 -391
  83. package/src/collection.ts +0 -175
  84. package/src/config-sections.ts +0 -284
  85. package/src/config.ts +0 -480
  86. package/src/controls/cart-controls.ts +0 -123
  87. package/src/controls/discount.ts +0 -75
  88. package/src/controls/extras.ts +0 -54
  89. package/src/controls/navigation.ts +0 -77
  90. package/src/diagnose-markup.ts +0 -298
  91. package/src/diagnose.ts +0 -294
  92. package/src/dom.ts +0 -268
  93. package/src/extras.ts +0 -136
  94. package/src/fragments.ts +0 -261
  95. package/src/globals.d.ts +0 -15
  96. package/src/images.ts +0 -91
  97. package/src/index.ts +0 -415
  98. package/src/initial.ts +0 -101
  99. package/src/lazy-iife.ts +0 -197
  100. package/src/lazy.ts +0 -42
  101. package/src/loader.ts +0 -363
  102. package/src/market-codes.ts +0 -6
  103. package/src/markets-ui.ts +0 -227
  104. package/src/markets.ts +0 -246
  105. package/src/markup.ts +0 -127
  106. package/src/mounting.ts +0 -264
  107. package/src/page-market.ts +0 -110
  108. package/src/part-integrity.ts +0 -7
  109. package/src/parts/account-pages.ts +0 -7
  110. package/src/parts/account.ts +0 -7
  111. package/src/parts/analytics.ts +0 -7
  112. package/src/parts/diagnose.ts +0 -7
  113. package/src/parts/extras.ts +0 -7
  114. package/src/parts/markets.ts +0 -7
  115. package/src/parts/search.ts +0 -7
  116. package/src/presets/barba.ts +0 -194
  117. package/src/product-events.ts +0 -115
  118. package/src/product-render.ts +0 -218
  119. package/src/product-shape.ts +0 -49
  120. package/src/product-state.ts +0 -318
  121. package/src/product.ts +0 -308
  122. package/src/repeaters.ts +0 -161
  123. package/src/search.ts +0 -305
  124. package/src/selling-plans.ts +0 -145
  125. package/src/server.ts +0 -211
  126. package/src/shopify-values.ts +0 -91
  127. package/src/status.ts +0 -106
  128. package/src/storage.ts +0 -34
  129. package/src/store-html.ts +0 -359
  130. package/src/storefront/catalog.ts +0 -196
  131. package/src/storefront/json-ld.ts +0 -144
  132. package/src/variant-render.ts +0 -139
package/src/config.ts DELETED
@@ -1,480 +0,0 @@
1
- /**
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
- * Every option is validated here, once, where it arrives: values that end up in query text, URLs, attribute names or
5
- * storage keys never reach the code that uses them unchecked.
6
- */
7
- import { STOREFRONT_API_VERSION } from '../server/src/shared/api-versions.ts';
8
- import { isQuarterlyVersion } from './api-version.ts';
9
- import {
10
- accountOption,
11
- analyticsOption,
12
- domainOption,
13
- embedOriginsOption,
14
- marketsOption,
15
- metafieldsOption,
16
- partsUrlOption,
17
- } from './config-sections.ts';
18
-
19
- export { MARKET_SOURCES } from './config-sections.ts';
20
- import { toCountry, toLanguage } from './market-codes.ts';
21
- import type { ApiVersion } from '../server/src/shared/api-versions.ts';
22
- import type { ShopDomain } from './config-sections.ts';
23
- import type { Money } from './shopify-values.ts';
24
- import type { CountryCode, LanguageCode } from './market-codes.ts';
25
-
26
- /** Where the visitor's market comes from (`markets.ts`). */
27
- export interface MarketsConfig {
28
- /** True: every source; a list: only those. */
29
- detect?: boolean | ('saved' | 'server' | 'webflow')[];
30
- /** Webflow locale (subdirectory or html lang) to market. */
31
- locales?: Record<string, { country?: string; language?: string; locale?: string }>;
32
- /** The cookie a server sets with the visitor's country. */
33
- cookie?: string;
34
- /** Remember the visitor's choice (default true). */
35
- remember?: boolean;
36
- /** With `locales`: ignore an html lang that isn't in the map. */
37
- strict?: boolean;
38
- }
39
-
40
- /** The optional analytics part. */
41
- export interface AnalyticsConfig {
42
- /** GA4/GTM events, into `window.dataLayer` or the named array. */
43
- dataLayer?: boolean | string;
44
- /** Shopify's own analytics. `proxy`: the site serves the Storefront API on its own domain. */
45
- shopify?: boolean | { consent?: 'no-banner' | 'default-banner' | 'custom-banner'; proxy?: boolean };
46
- onEvent?: (name: string, data: Record<string, unknown>) => void;
47
- }
48
-
49
- /** The optional customer accounts part, which runs through the site's Worker. */
50
- export interface AccountConfig {
51
- /** The Worker's account routes, a URL or a path on this site. */
52
- server: string;
53
- /** The site's own order page, with `{id}` for the numeric order id. */
54
- orderUrl?: string;
55
- /** Shopify's subscription management page. */
56
- subscriptionsUrl?: string;
57
- /** A separate page for payment methods (default `subscriptionsUrl`). */
58
- paymentUrl?: string;
59
- /** Where Shopify sends the visitor after logout (default the home page). */
60
- logoutUrl?: string;
61
- /** The language of Shopify's login pages. */
62
- locale?: string;
63
- /** Replaces `location.assign` for the login redirect (popups, tests). */
64
- navigate?: (url: string) => void;
65
- }
66
-
67
- export interface MetafieldIdentifier {
68
- namespace: string;
69
- key: string;
70
- }
71
- /** `'namespace.key'` or the identifier itself. */
72
- export type MetafieldRef = string | MetafieldIdentifier;
73
- /** Shopify's note on a cart change. */
74
- export interface CartWarning {
75
- code: string;
76
- message: string;
77
- target?: string;
78
- }
79
- export type CheckoutTarget = '_self' | '_blank';
80
- type PurchaseKind = 'one-time' | 'subscription';
81
-
82
- /** Every option, with its default filled in. */
83
- export interface TetherConfig {
84
- /** The store's domain, without protocol or path; `''` until one is set. */
85
- domain: ShopDomain | '';
86
- /** The public Storefront API access token. */
87
- token: string;
88
- /** A dated Shopify API version (or `unstable`, with a warning). */
89
- apiVersion: ApiVersion | 'unstable';
90
- /** The market's country, for `@inContext(country:)`. */
91
- country: CountryCode | undefined;
92
- /** The market's language, for `@inContext(language:)`. */
93
- language: LanguageCode | undefined;
94
- /** An `Intl` locale for money and dates. */
95
- locale: string | undefined;
96
- markets: MarketsConfig | null;
97
- /** The attribute prefix: `tether` reads `tether-product`, `tether-bind`. */
98
- prefix: string;
99
- /** A folder (`/product`) or a template with `{handle}` / `{id}`. */
100
- productPath: string;
101
- collectionPath: string;
102
- /** The product metafields to fetch. */
103
- metafields: MetafieldRef[];
104
- /** Origins (`https://video.example`) a description's iframes may load from, besides the built-in video players. */
105
- embedOrigins: string[];
106
- imagesFirst: number;
107
- variantsFirst: number;
108
- sellingPlanGroupsFirst: number;
109
- sellingPlansFirst: number;
110
- collectionFirst: number;
111
- /** Retries of a request that failed on the network, a 429/5xx or Shopify's throttling. */
112
- retries: number;
113
- /** Refresh server-rendered products once in the background. */
114
- revalidate: boolean;
115
- /** The localStorage key of the cart id (default `<prefix>:cart:<domain>`). */
116
- storageKey: string;
117
- cartDrawerOpenClass: string;
118
- openCartOnAdd: boolean;
119
- /** Move focus into the open cart, keep Tab inside it, restore it on close. */
120
- cartFocus: boolean;
121
- checkoutTarget: CheckoutTarget;
122
- defaultPurchase: PurchaseKind;
123
- /** `?variant=` follows the visitor's choice on the page's main product. */
124
- variantUrl: boolean;
125
- /** Seconds a Shopify answer may be cached; 0 leaves it to Shopify, which can be hours. */
126
- maxAge: number;
127
- /** `tether-show="low-stock"` at this many or fewer (needs inventory access). */
128
- lowStock: number;
129
- /** Milliseconds the search part waits after the last keystroke. */
130
- searchDelay: number;
131
- /** Mount the document on DOMContentLoaded. */
132
- autoMount: boolean;
133
- /** Mount elements added later (CMS load more, filters). */
134
- observe: boolean;
135
- /** Mount on Astro's `astro:page-load` and close the cart on navigation. */
136
- astro: boolean;
137
- /** The folder of the optional parts, for the `<script>` build. */
138
- partsUrl: string | null;
139
- analytics: AnalyticsConfig | null;
140
- account: AccountConfig | null;
141
- /** The site's text for a cart warning; nothing keeps Shopify's. */
142
- warningText: ((warning: CartWarning) => string | null | undefined) | null;
143
- formatMoney: ((money: Money, locale: string | undefined) => string) | null;
144
- /** Default: `ScrollTrigger.refresh()` when present. */
145
- onLayoutChange: ((reason: string) => void) | null;
146
- onCartOpen: ((drawers: HTMLElement[]) => void) | null;
147
- onCartClose: ((drawers: HTMLElement[]) => void) | null;
148
- onLineEnter: ((node: HTMLElement) => void) | null;
149
- /** Animates a line out, then calls `done()`. */
150
- onLineLeave: ((node: HTMLElement, done: () => void) => void) | null;
151
- onValueChange: ((element: HTMLElement, previousText: string) => void) | null;
152
- /** Every failure that reaches the visitor. */
153
- onError: ((error: unknown, context: string) => void) | null;
154
- debug: boolean;
155
- }
156
-
157
- /** The options a site writes as plain text, which `resolveConfig` checks into their typed form. */
158
- type ParsedFromText = 'domain' | 'apiVersion' | 'country' | 'language' | 'storageKey';
159
-
160
- /**
161
- * What a site passes: any option, the market as the codes people write (`'de'`, `'pt-BR'`), and `null` for the
162
- * default storage key.
163
- */
164
- export type TetherOptions = {
165
- [Key in Exclude<keyof TetherConfig, ParsedFromText>]?: TetherConfig[Key];
166
- } & Partial<Record<Exclude<ParsedFromText, 'storageKey'>, string | undefined>> & {
167
- storageKey?: string | null | undefined;
168
- };
169
-
170
- export const DEFAULTS: Readonly<TetherConfig> = {
171
- domain: '',
172
- token: '',
173
- apiVersion: STOREFRONT_API_VERSION,
174
- country: undefined,
175
- language: undefined,
176
- locale: undefined,
177
- markets: null,
178
- prefix: 'tether',
179
- productPath: '/product',
180
- collectionPath: '/collection',
181
- metafields: [],
182
- embedOrigins: [],
183
- imagesFirst: 25,
184
- variantsFirst: 100,
185
- sellingPlanGroupsFirst: 10,
186
- sellingPlansFirst: 25,
187
- collectionFirst: 24,
188
- retries: 2,
189
- revalidate: true,
190
- storageKey: '',
191
- cartDrawerOpenClass: 'is-open',
192
- openCartOnAdd: true,
193
- cartFocus: true,
194
- checkoutTarget: '_self',
195
- defaultPurchase: 'one-time',
196
- variantUrl: false,
197
- maxAge: 60,
198
- lowStock: 5,
199
- searchDelay: 150,
200
- autoMount: true,
201
- observe: false,
202
- astro: true,
203
- partsUrl: null,
204
- analytics: null,
205
- account: null,
206
- warningText: null,
207
- formatMoney: null,
208
- onLayoutChange: null,
209
- onCartOpen: null,
210
- onCartClose: null,
211
- onLineEnter: null,
212
- onLineLeave: null,
213
- onValueChange: null,
214
- onError: null,
215
- debug: false,
216
- };
217
-
218
- export const cfg: TetherConfig = { ...DEFAULTS };
219
-
220
- /**
221
- * @returns A view of `target` and the objects in it that reads the live values and refuses every change: a write
222
- * throws in strict-mode code (as on a frozen object) and warns in any code.
223
- */
224
- function readOnlyView<T extends object>(target: T): T {
225
- const refuse = (key: string | symbol) => {
226
- warn(`Tether.config is read-only (${String(key)}): pass options to Tether.init() instead`);
227
- return false;
228
- };
229
- return new Proxy(target, {
230
- get(object, key) {
231
- const value: unknown = Reflect.get(object, key);
232
- // The config's objects are its own copies (`resolveConfig`), never frozen, so a view of each is allowed.
233
- return typeof value === 'object' && value !== null ? readOnlyView(value) : value;
234
- },
235
- set: (_object, key) => refuse(key),
236
- deleteProperty: (_object, key) => refuse(key),
237
- defineProperty: (_object, key) => refuse(key),
238
- });
239
- }
240
-
241
- /**
242
- * `cfg` as `Tether.config` shows it. A value written there would skip the validation `configure()` does, so the view
243
- * is read-only; `Tether.init(options)` is how a site changes the config.
244
- */
245
- export const configView = readOnlyView(cfg);
246
-
247
- /** The prefix becomes part of attribute names, CSS selectors and event names, so it is kept to those characters. */
248
- const PREFIX = /^[a-z][a-z0-9-]*$/;
249
-
250
- type CountOption =
251
- | 'imagesFirst'
252
- | 'variantsFirst'
253
- | 'sellingPlanGroupsFirst'
254
- | 'sellingPlansFirst'
255
- | 'collectionFirst'
256
- | 'retries'
257
- | 'maxAge'
258
- | 'lowStock'
259
- | 'searchDelay';
260
- type HookOption =
261
- | 'warningText'
262
- | 'formatMoney'
263
- | 'onLayoutChange'
264
- | 'onCartOpen'
265
- | 'onCartClose'
266
- | 'onLineEnter'
267
- | 'onLineLeave'
268
- | 'onValueChange'
269
- | 'onError';
270
- type SwitchOption =
271
- 'revalidate' | 'openCartOnAdd' | 'cartFocus' | 'variantUrl' | 'autoMount' | 'observe' | 'astro' | 'debug';
272
- type TextOption = 'token' | 'productPath' | 'collectionPath' | 'cartDrawerOpenClass';
273
-
274
- const SWITCH_OPTIONS: readonly SwitchOption[] = [
275
- 'revalidate',
276
- 'openCartOnAdd',
277
- 'cartFocus',
278
- 'variantUrl',
279
- 'autoMount',
280
- 'observe',
281
- 'astro',
282
- 'debug',
283
- ];
284
- const TEXT_OPTIONS: readonly TextOption[] = ['token', 'productPath', 'collectionPath', 'cartDrawerOpenClass'];
285
-
286
- /** Options that are written into queries as list sizes or used as counts. */
287
- const COUNT_OPTIONS: readonly CountOption[] = [
288
- 'imagesFirst',
289
- 'variantsFirst',
290
- 'sellingPlanGroupsFirst',
291
- 'sellingPlansFirst',
292
- 'collectionFirst',
293
- 'retries',
294
- 'maxAge',
295
- 'lowStock',
296
- 'searchDelay',
297
- ];
298
- const HOOK_OPTIONS: readonly HookOption[] = [
299
- 'warningText',
300
- 'formatMoney',
301
- 'onLayoutChange',
302
- 'onCartOpen',
303
- 'onCartClose',
304
- 'onLineEnter',
305
- 'onLineLeave',
306
- 'onValueChange',
307
- 'onError',
308
- ];
309
-
310
- /** @returns `fallback`, with a warning unless `value` was left out. */
311
- function invalidOption<T>(name: string, value: unknown, fallback: T): T {
312
- if (value !== undefined) warn(`invalid ${name} ${JSON.stringify(value)}: using ${JSON.stringify(fallback)}`);
313
- return fallback;
314
- }
315
-
316
- /** Warns about an option without a default to fall back to, which is left out (`config-sections.ts` too). */
317
- function dropped(name: string, value: unknown): void {
318
- warn(`invalid ${name} ${JSON.stringify(value)}: ignored`);
319
- }
320
-
321
- /**
322
- * Warns about each key that is no option, so a misspelt one (`openCartOnAd`) is seen; none of them reaches `cfg`.
323
- */
324
- function warnUnknownKeys(options: Readonly<Record<string, unknown>>): void {
325
- for (const key of Object.keys(options)) {
326
- if (!(key in DEFAULTS)) warn(`unknown option ${JSON.stringify(key)}: ignored`);
327
- }
328
- }
329
-
330
- /** @returns A site's hook, `null` for anything that isn't a function. */
331
- function hookOption(value: unknown): ((...args: never[]) => unknown) | null {
332
- return typeof value === 'function' ? (value as (...args: never[]) => unknown) : null;
333
- }
334
-
335
- function hookOptions(options: Readonly<Record<string, unknown>>): Pick<TetherConfig, HookOption> {
336
- const hooks = Object.fromEntries(HOOK_OPTIONS.map((name) => [name, hookOption(options[name])]));
337
- // A function from the site can only be checked for being callable; how it is called is the documented signature
338
- // of its option.
339
- return hooks as Pick<TetherConfig, HookOption>;
340
- }
341
-
342
- /** @returns `value` when it is a non-negative integer, else the default. */
343
- function countOption(name: CountOption, value: unknown): number {
344
- if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value;
345
- return invalidOption(name, value, DEFAULTS[name]);
346
- }
347
-
348
- function switchOption(name: SwitchOption, value: unknown): boolean {
349
- return typeof value === 'boolean' ? value : invalidOption(name, value, DEFAULTS[name]);
350
- }
351
-
352
- function textOption(name: TextOption, value: unknown): string {
353
- return typeof value === 'string' ? value : invalidOption(name, value, DEFAULTS[name]);
354
- }
355
-
356
- /** @returns An `Intl` locale as written, `undefined` for one `Intl` doesn't accept. */
357
- function localeOption(value: unknown): string | undefined {
358
- if (value === undefined || value === null || value === '') return undefined;
359
- try {
360
- if (typeof value === 'string' && Intl.getCanonicalLocales(value).length) return value;
361
- } catch {
362
- // RangeError: not a locale (falls through to the warning).
363
- }
364
- dropped('locale', value);
365
- return undefined;
366
- }
367
-
368
- /** @param choices The first one is the default. */
369
- function choiceOption<Choice extends string>(
370
- name: string,
371
- value: unknown,
372
- choices: readonly [Choice, ...Choice[]],
373
- ): Choice {
374
- return choices.find((choice) => choice === value) ?? invalidOption(name, value, choices[0]);
375
- }
376
-
377
- /**
378
- * @returns A quarterly version, or `unstable` with a warning; the default for anything else, which would be written
379
- * into the endpoint's path.
380
- */
381
- function apiVersionOption(value: unknown): ApiVersion | 'unstable' {
382
- if (value === 'unstable') {
383
- warn('apiVersion "unstable" changes without notice. Pin a dated version (e.g. 2026-07).');
384
- return value;
385
- }
386
- return isQuarterlyVersion(value) ? value : invalidOption('apiVersion', value, DEFAULTS.apiVersion);
387
- }
388
-
389
- function codeOption<Code>(name: string, value: unknown, parse: (value: unknown) => Code | null): Code | undefined {
390
- if (value === undefined || value === null || value === '') return undefined;
391
- const code = parse(value);
392
- if (code !== null) return code;
393
- dropped(name, value);
394
- return undefined;
395
- }
396
-
397
- /**
398
- * The configuration `options` describe: defaults filled in, every value validated (an invalid one warns and falls
399
- * back to its default), hooks that aren't functions dropped so callers only check for null.
400
- *
401
- * @param options What the site set, or any object (the server helpers take their options from code and `.env`).
402
- */
403
- export function resolveConfig(options: TetherOptions | Readonly<Record<string, unknown>>): TetherConfig {
404
- warnUnknownKeys(options);
405
- const domain = domainOption(options.domain, dropped);
406
- const prefix =
407
- typeof options.prefix === 'string' && PREFIX.test(options.prefix)
408
- ? options.prefix
409
- : invalidOption('prefix', options.prefix, DEFAULTS.prefix);
410
- const resolved: TetherConfig = {
411
- ...DEFAULTS,
412
- ...hookOptions(options),
413
- domain,
414
- apiVersion: apiVersionOption(options.apiVersion),
415
- country: codeOption('country', options.country, toCountry),
416
- locale: localeOption(options.locale),
417
- partsUrl: partsUrlOption(options.partsUrl, dropped),
418
- language: codeOption('language', options.language, toLanguage),
419
- markets: marketsOption(options.markets, dropped),
420
- analytics: analyticsOption(options.analytics, dropped),
421
- account: accountOption(options.account, dropped, warn),
422
- prefix,
423
- storageKey:
424
- typeof options.storageKey === 'string' && options.storageKey ? options.storageKey : `${prefix}:cart:${domain}`,
425
- metafields: metafieldsOption(options.metafields ?? [], dropped),
426
- embedOrigins: embedOriginsOption(options.embedOrigins ?? [], dropped),
427
- checkoutTarget: choiceOption('checkoutTarget', options.checkoutTarget, ['_self', '_blank']),
428
- defaultPurchase: choiceOption('defaultPurchase', options.defaultPurchase, ['one-time', 'subscription']),
429
- };
430
- for (const name of COUNT_OPTIONS) resolved[name] = countOption(name, options[name]);
431
- for (const name of SWITCH_OPTIONS) resolved[name] = switchOption(name, options[name]);
432
- for (const name of TEXT_OPTIONS) resolved[name] = textOption(name, options[name]);
433
- return resolved;
434
- }
435
-
436
- /**
437
- * @param value A `markets` option from code other than the config (`Tether.detectMarket(markets)`).
438
- * @returns It validated as the config's own is, with the same warnings.
439
- */
440
- export const parseMarkets = (value: unknown): MarketsConfig | null => marketsOption(value, dropped);
441
-
442
- /**
443
- * Replaces the shared configuration with `options` over the defaults (see `resolveConfig`).
444
- *
445
- * @returns The shared `cfg`.
446
- */
447
- export function configure(options: TetherOptions | Readonly<Record<string, unknown>> = {}): TetherConfig {
448
- const resolved = resolveConfig(options);
449
- for (const key of Object.keys(cfg)) {
450
- if (!(key in resolved)) Reflect.deleteProperty(cfg, key);
451
- }
452
- return Object.assign(cfg, resolved);
453
- }
454
-
455
- /** @returns True once a domain and a token are set. */
456
- export const isConfigured = (): boolean => Boolean(cfg.domain && cfg.token);
457
-
458
- export function log(...args: unknown[]): void {
459
- if (cfg.debug) console.log(`[${cfg.prefix}]`, ...args);
460
- }
461
-
462
- export function warn(...args: unknown[]): void {
463
- console.warn(`[${cfg.prefix}]`, ...args);
464
- }
465
-
466
- /**
467
- * Logs a failure that reaches the visitor and hands it to `onError`. A throwing `onError` is logged, never rethrown:
468
- * the site's hook must not break the render that reported the failure.
469
- *
470
- * @param context What failed, e.g. `product coffee-box`.
471
- */
472
- export function reportError(error: unknown, context: string): void {
473
- warn(context, error instanceof Error && error.message ? error.message : error);
474
- if (!cfg.onError) return;
475
- try {
476
- cfg.onError(error, context);
477
- } catch (hookError) {
478
- warn('onError threw', hookError);
479
- }
480
- }
@@ -1,123 +0,0 @@
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.ts';
7
- import { Cart, trapFocus } from '../cart.ts';
8
- import { sel, busy } from '../dom.ts';
9
-
10
- /** @returns The attribute a rendered cart line carries its line id in. */
11
- const lineKeyAttribute = (): string => `data-${cfg.prefix}-key`;
12
-
13
- const isElement = (target: EventTarget | null): target is Element => target instanceof window.Element;
14
-
15
- /** @returns The rendered cart line `target` is in, with its line id. */
16
- function lineOf(target: Element): { line: Element; id: string } | null {
17
- const line = target.closest(`[${lineKeyAttribute()}]`);
18
- const id = line?.getAttribute(lineKeyAttribute());
19
- return line && id ? { line, id } : null;
20
- }
21
-
22
- function reportFailure(change: Promise<unknown>, context: string): void {
23
- change.catch((error: unknown) => {
24
- reportError(error, context);
25
- });
26
- }
27
-
28
- /** A line's remove, + and - buttons. */
29
- function onLineClick(event: MouseEvent, target: Element): void {
30
- const found = lineOf(target);
31
- if (!found?.line.closest(sel('cart-lines'))) return;
32
- const { line, id } = found;
33
- if (target.closest(sel('line-remove'))) {
34
- event.preventDefault();
35
- line.classList.add('is-busy');
36
- reportFailure(Cart.removeLine(id), 'cart line');
37
- return;
38
- }
39
- const delta = lineDelta(target);
40
- if (!delta || !Cart.lines.some((candidate) => candidate.id === id)) return;
41
- event.preventDefault();
42
- reportFailure(Cart.changeQuantity(id, delta), 'cart line');
43
- }
44
-
45
- /** @returns The quantity change of a + or - button, 0 for anything else. */
46
- function lineDelta(target: Element): 1 | -1 | 0 {
47
- if (target.closest(sel('line-plus'))) return 1;
48
- if (target.closest(sel('line-minus'))) return -1;
49
- return 0;
50
- }
51
-
52
- function onClick(event: MouseEvent): void {
53
- const { target } = event;
54
- if (!isElement(target)) return;
55
- if (target.closest(sel('cart-open'))) {
56
- event.preventDefault();
57
- Cart.toggle();
58
- return;
59
- }
60
- if (target.closest(sel('cart-close'))) {
61
- event.preventDefault();
62
- Cart.close();
63
- return;
64
- }
65
- const clear = target.closest(sel('cart-clear'));
66
- if (clear) {
67
- event.preventDefault();
68
- void busy(clear, () =>
69
- Cart.clear().catch((error: unknown) => {
70
- reportError(error, 'cart clear');
71
- }),
72
- );
73
- return;
74
- }
75
- const checkout = target.closest(sel('checkout'));
76
- if (checkout) {
77
- event.preventDefault();
78
- void busy(checkout, () => Cart.checkout());
79
- return;
80
- }
81
- onLineClick(event, target);
82
- }
83
-
84
- /** The line shows `is-busy` and `aria-busy` until Shopify has answered; the next render removes them. */
85
- function showLineBusy(line: Element, change: Promise<unknown>, context: string): void {
86
- line.classList.add('is-busy');
87
- line.setAttribute('aria-busy', 'true');
88
- reportFailure(change, context);
89
- }
90
-
91
- function onChange(event: Event): void {
92
- const { target } = event;
93
- if (!isElement(target)) return;
94
- const found = lineOf(target);
95
- if (found && target instanceof window.HTMLInputElement && target.matches(sel('line', 'quantity'))) {
96
- showLineBusy(found.line, Cart.updateLine(found.id, parseInt(target.value, 10) || 0), 'cart line');
97
- } else if (found && target instanceof window.HTMLSelectElement && target.matches(sel('line-plan'))) {
98
- showLineBusy(found.line, Cart.updateLine(found.id, { sellingPlanId: target.value || null }), 'cart line plan');
99
- } else if (hasValue(target) && target.matches(sel('cart-note'))) {
100
- reportFailure(Cart.setNote(target.value), 'cart note');
101
- }
102
- }
103
-
104
- const hasValue = (target: Element): target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement =>
105
- target instanceof window.HTMLInputElement ||
106
- target instanceof window.HTMLTextAreaElement ||
107
- target instanceof window.HTMLSelectElement;
108
-
109
- function onKeydown(event: KeyboardEvent): void {
110
- if (!Cart.isOpen()) return;
111
- if (event.key === 'Escape') {
112
- Cart.close();
113
- return;
114
- }
115
- trapFocus(event);
116
- }
117
-
118
- /** Binds the document-wide listeners, once per page load of the script. */
119
- export function bindCartControls(): void {
120
- document.addEventListener('click', onClick);
121
- document.addEventListener('change', onChange);
122
- document.addEventListener('keydown', onKeydown);
123
- }
@@ -1,75 +0,0 @@
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.ts';
7
- import { TetherError } from '../client.ts';
8
- import { Cart } from '../cart.ts';
9
- import { attr, sel, busy, emit } from '../dom.ts';
10
-
11
- const isElement = (target: EventTarget | null): target is Element => target instanceof window.Element;
12
-
13
- function codeInputOf(form: Element): HTMLInputElement | null {
14
- const input = form.querySelector(sel('discount-input')) ?? form.querySelector('input');
15
- return input instanceof window.HTMLInputElement ? input : null;
16
- }
17
-
18
- function markInvalid(form: Element, input: HTMLInputElement | null, invalid: boolean): void {
19
- form.classList.toggle('is-invalid', invalid);
20
- if (invalid) input?.setAttribute('aria-invalid', 'true');
21
- else input?.removeAttribute('aria-invalid');
22
- }
23
-
24
- async function applyCode(form: Element, input: HTMLInputElement | null, code: string): Promise<void> {
25
- try {
26
- await Cart.applyDiscount(code);
27
- markInvalid(form, input, false);
28
- emit('discount-applied', { code }, form);
29
- if (input) input.value = '';
30
- } catch (error) {
31
- markInvalid(form, input, true);
32
- if (error instanceof TetherError && error.code === 'INVALID_DISCOUNT') emit('discount-invalid', { code }, form);
33
- else reportError(error, 'discount');
34
- }
35
- }
36
-
37
- function onSubmit(event: SubmitEvent): void {
38
- const { target } = event;
39
- const form = isElement(target) ? target.closest(sel('discount-form')) : null;
40
- if (!form) return;
41
- event.preventDefault();
42
- const input = codeInputOf(form);
43
- const code = input?.value.trim();
44
- if (!code) return;
45
- void busy(form, () => applyCode(form, input, code));
46
- }
47
-
48
- function onInput(event: Event): void {
49
- const { target } = event;
50
- if (!isElement(target)) return;
51
- const form = target.closest(sel('discount-form'));
52
- if (!form) return;
53
- form.classList.remove('is-invalid');
54
- target.removeAttribute('aria-invalid');
55
- }
56
-
57
- function onClick(event: MouseEvent): void {
58
- const { target } = event;
59
- const remove = isElement(target) ? target.closest(sel('discount-remove')) : null;
60
- if (!remove) return;
61
- event.preventDefault();
62
- const code = remove.closest(sel('discount-code'))?.getAttribute(attr('discount-code'));
63
- if (!code) return;
64
- Cart.removeDiscount(code).catch((error: unknown) => {
65
- reportError(error, 'discount');
66
- });
67
- }
68
-
69
- /** Binds the document-wide listeners, once per page load of the script. */
70
- export function bindDiscountControls(): void {
71
- // Capture: the submit is handled before a router (Astro's ClientRouter) turns it into a navigation.
72
- document.addEventListener('submit', onSubmit, true);
73
- document.addEventListener('input', onInput);
74
- document.addEventListener('click', onClick);
75
- }