@occluze/tether 0.6.8 → 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 (215) hide show
  1. package/README.md +28 -28
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-CXMA5RPZ.js +1 -0
  4. package/dist/esm/chunks/account-pages-IZO7FEOZ.js +1 -0
  5. package/dist/esm/chunks/analytics-PG5FPRDN.js +1 -0
  6. package/dist/esm/chunks/chunk-4HD4J5JC.js +1 -0
  7. package/dist/esm/chunks/chunk-6TFMLT4A.js +4 -0
  8. package/dist/esm/chunks/chunk-BBVUVPMO.js +1 -0
  9. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  10. package/dist/esm/chunks/chunk-EW5SYO6L.js +1 -0
  11. package/dist/esm/chunks/chunk-HJY2JSNB.js +1 -0
  12. package/dist/esm/chunks/chunk-MQYB6BH4.js +1 -0
  13. package/dist/esm/chunks/chunk-N3V45JPS.js +1 -0
  14. package/dist/esm/chunks/chunk-RPMBCAGS.js +1 -0
  15. package/dist/esm/chunks/chunk-VFNHIGCS.js +1 -0
  16. package/dist/esm/chunks/chunk-WT7RGGJU.js +1 -0
  17. package/dist/esm/chunks/chunk-YN6LEBEN.js +1 -0
  18. package/dist/esm/chunks/chunk-Z6HS3H7K.js +1 -0
  19. package/dist/esm/chunks/diagnose-XCEBQKBO.js +14 -0
  20. package/dist/esm/chunks/extras-4HU3VSBQ.js +1 -0
  21. package/dist/esm/chunks/markets-ui-M4IQ62CT.js +1 -0
  22. package/dist/esm/chunks/search-A5DS2IOS.js +1 -0
  23. package/dist/esm/index.js +1 -1
  24. package/dist/esm/server.js +2 -2
  25. package/dist/presets/barba.esm.js +19 -23
  26. package/dist/tether.account-pages.js +332 -367
  27. package/dist/tether.account-pages.min.js +1 -1
  28. package/dist/tether.account.js +253 -492
  29. package/dist/tether.account.min.js +1 -1
  30. package/dist/tether.analytics.js +82 -89
  31. package/dist/tether.analytics.min.js +1 -1
  32. package/dist/tether.diagnose.js +52 -34
  33. package/dist/tether.diagnose.min.js +2 -2
  34. package/dist/tether.extras.js +42 -37
  35. package/dist/tether.extras.min.js +1 -1
  36. package/dist/tether.js +1361 -935
  37. package/dist/tether.markets.js +89 -91
  38. package/dist/tether.markets.min.js +1 -1
  39. package/dist/tether.min.js +0 -0
  40. package/dist/tether.min.js.map +4 -4
  41. package/dist/tether.search.js +60 -69
  42. package/dist/tether.search.min.js +1 -1
  43. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  44. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  45. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  46. package/dist/types/server/src/shared/cart-id.d.ts +2 -0
  47. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  48. package/dist/types/server/src/shared/json.d.ts +25 -0
  49. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  50. package/dist/types/src/account/addresses-view.d.ts +5 -0
  51. package/dist/types/src/account/answers.d.ts +40 -0
  52. package/dist/types/src/account/markup.d.ts +19 -0
  53. package/dist/types/src/account/operations.d.ts +41 -0
  54. package/dist/types/src/account/order-lines.d.ts +13 -0
  55. package/dist/types/src/account/order-list.d.ts +12 -0
  56. package/dist/types/src/account/order-page.d.ts +6 -0
  57. package/dist/types/src/account/page-answers.d.ts +182 -0
  58. package/dist/types/src/account/page-events.d.ts +2 -0
  59. package/dist/types/src/account/server-client.d.ts +60 -0
  60. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  61. package/dist/types/src/account-api.d.ts +18 -0
  62. package/dist/types/src/account-marks.d.ts +15 -0
  63. package/dist/types/src/account-pages.d.ts +8 -0
  64. package/dist/types/src/account.d.ts +64 -0
  65. package/dist/types/src/amounts.d.ts +12 -0
  66. package/dist/types/src/analytics-shopify.d.ts +41 -0
  67. package/dist/types/src/analytics.d.ts +2 -0
  68. package/dist/types/src/api-version.d.ts +28 -0
  69. package/dist/types/src/astro.d.ts +42 -0
  70. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  71. package/dist/types/src/cart/checkout.d.ts +16 -0
  72. package/dist/types/src/cart/drawer.d.ts +18 -0
  73. package/dist/types/src/cart/mutations.d.ts +85 -0
  74. package/dist/types/src/cart/render.d.ts +17 -0
  75. package/dist/types/src/cart/store.d.ts +185 -0
  76. package/dist/types/src/cart.d.ts +106 -0
  77. package/dist/types/src/client.d.ts +134 -0
  78. package/dist/types/src/collection.d.ts +25 -0
  79. package/dist/types/src/config-sections.d.ts +40 -0
  80. package/dist/types/src/config.d.ts +190 -0
  81. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  82. package/dist/types/src/controls/discount.d.ts +2 -0
  83. package/dist/types/src/controls/extras.d.ts +5 -0
  84. package/dist/types/src/controls/navigation.d.ts +12 -0
  85. package/dist/types/src/diagnose-markup.d.ts +19 -0
  86. package/dist/types/src/diagnose.d.ts +60 -0
  87. package/dist/types/src/dom.d.ts +75 -0
  88. package/dist/types/src/extras.d.ts +21 -0
  89. package/dist/types/src/fragments.d.ts +158 -0
  90. package/dist/types/src/globals.d.ts +15 -0
  91. package/dist/types/src/images.d.ts +20 -0
  92. package/dist/types/src/index.d.ts +167 -0
  93. package/dist/types/src/initial.d.ts +56 -0
  94. package/dist/types/src/lazy.d.ts +28 -0
  95. package/dist/types/src/loader.d.ts +84 -0
  96. package/dist/types/src/market-codes.d.ts +6 -0
  97. package/dist/types/src/markets-ui.d.ts +32 -0
  98. package/dist/types/src/markets.d.ts +72 -0
  99. package/dist/types/src/markup.d.ts +131 -0
  100. package/dist/types/src/mounting.d.ts +46 -0
  101. package/dist/types/src/page-market.d.ts +27 -0
  102. package/dist/types/src/presets/barba.d.ts +56 -0
  103. package/dist/types/src/product-events.d.ts +2 -0
  104. package/dist/types/src/product-render.d.ts +17 -0
  105. package/dist/types/src/product-shape.d.ts +90 -0
  106. package/dist/types/src/product-state.d.ts +110 -0
  107. package/dist/types/src/product.d.ts +66 -0
  108. package/dist/types/src/repeaters.d.ts +16 -0
  109. package/dist/types/src/search.d.ts +33 -0
  110. package/dist/types/src/selling-plans.d.ts +64 -0
  111. package/dist/types/src/server.d.ts +88 -0
  112. package/dist/types/src/shopify-values.d.ts +46 -0
  113. package/dist/types/src/status.d.ts +33 -0
  114. package/dist/types/src/storage.d.ts +12 -0
  115. package/dist/types/src/store-html.d.ts +39 -0
  116. package/dist/types/src/storefront/catalog.d.ts +38 -0
  117. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  118. package/dist/types/src/variant-render.d.ts +14 -0
  119. package/package.json +24 -24
  120. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  121. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  122. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  123. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  124. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  125. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  126. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  127. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  128. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  129. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  130. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  131. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  132. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  133. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  134. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  135. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  136. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  137. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  138. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  139. package/src/account/addresses-view.js +0 -92
  140. package/src/account/browser-session.js +0 -359
  141. package/src/account/markup.js +0 -48
  142. package/src/account/operations.js +0 -180
  143. package/src/account/order-lines.js +0 -96
  144. package/src/account/order-list.js +0 -91
  145. package/src/account/order-page.js +0 -192
  146. package/src/account/page-events.js +0 -288
  147. package/src/account/server-client.js +0 -172
  148. package/src/account/shared.js +0 -111
  149. package/src/account/shopify-auth.js +0 -150
  150. package/src/account/subscriptions-view.js +0 -158
  151. package/src/account-api.js +0 -118
  152. package/src/account-links.js +0 -35
  153. package/src/account-pages.js +0 -89
  154. package/src/account.js +0 -342
  155. package/src/amounts.js +0 -36
  156. package/src/analytics-shopify.js +0 -241
  157. package/src/analytics.js +0 -352
  158. package/src/api-version.js +0 -85
  159. package/src/astro.js +0 -266
  160. package/src/cart/checkout.js +0 -97
  161. package/src/cart/drawer.js +0 -138
  162. package/src/cart/mutations.js +0 -234
  163. package/src/cart/render.js +0 -294
  164. package/src/cart/store.js +0 -326
  165. package/src/cart.js +0 -391
  166. package/src/client.js +0 -267
  167. package/src/collection.js +0 -183
  168. package/src/config.js +0 -349
  169. package/src/controls/cart-controls.js +0 -155
  170. package/src/controls/discount.js +0 -96
  171. package/src/controls/extras.js +0 -63
  172. package/src/controls/navigation.js +0 -83
  173. package/src/diagnose-markup.js +0 -333
  174. package/src/diagnose.js +0 -295
  175. package/src/dom.js +0 -287
  176. package/src/extras.js +0 -145
  177. package/src/fragments.js +0 -237
  178. package/src/images.js +0 -104
  179. package/src/index.js +0 -353
  180. package/src/initial.js +0 -87
  181. package/src/lazy-iife.js +0 -116
  182. package/src/lazy.js +0 -39
  183. package/src/loader.js +0 -337
  184. package/src/market-codes.js +0 -35
  185. package/src/markets-ui.js +0 -204
  186. package/src/markets.js +0 -334
  187. package/src/markup.js +0 -64
  188. package/src/mounting.js +0 -277
  189. package/src/page-market.js +0 -116
  190. package/src/parts/account-pages.js +0 -6
  191. package/src/parts/account.js +0 -6
  192. package/src/parts/analytics.js +0 -6
  193. package/src/parts/diagnose.js +0 -6
  194. package/src/parts/extras.js +0 -6
  195. package/src/parts/markets.js +0 -6
  196. package/src/parts/search.js +0 -6
  197. package/src/presets/barba.js +0 -190
  198. package/src/product-events.js +0 -130
  199. package/src/product-render.js +0 -257
  200. package/src/product-state.js +0 -318
  201. package/src/product.js +0 -344
  202. package/src/repeaters.js +0 -173
  203. package/src/search.js +0 -323
  204. package/src/selling-plans.js +0 -154
  205. package/src/server.js +0 -215
  206. package/src/shopify-values.js +0 -89
  207. package/src/status.js +0 -126
  208. package/src/store-html.js +0 -0
  209. package/src/storefront/catalog.js +0 -199
  210. package/src/storefront/json-ld.js +0 -167
  211. package/src/variant-render.js +0 -165
  212. package/types/astro.d.ts +0 -28
  213. package/types/barba.d.ts +0 -16
  214. package/types/index.d.ts +0 -577
  215. package/types/server.d.ts +0 -182
package/src/collection.js DELETED
@@ -1,183 +0,0 @@
1
- /**
2
- * Product lists: [tether-collection="handle"], [tether-products="search query"] and [tether-recommendations]. The
3
- * first child of [tether-list] is the card template; every card becomes a product scope, so all product attributes
4
- * work inside cards (including quick-add with a subscription toggle).
5
- */
6
- import { reportError } from './config.js';
7
- import { TetherError } from './client.js';
8
- import {
9
- attr,
10
- sel,
11
- get,
12
- has,
13
- scoped,
14
- emit,
15
- setText,
16
- setStoreHtml,
17
- setImage,
18
- repeat,
19
- notifyLayoutChanged,
20
- busy,
21
- show,
22
- } from './dom.js';
23
- import { getProducts, getRecommendations } from './loader.js';
24
- import { setScopeStatus, isNotFound, resolveProductKey } from './status.js';
25
- /** @import { Product } from './fragments.js' */
26
- /** @import { Collection, PageInfo, ProductQuery } from './loader.js' */
27
-
28
- /** `tether-products="url:q"`: the search in the page's `?q=`, for a results page on a static site. */
29
- const URL_QUERY_SOURCE = 'url:';
30
-
31
- export const listSelector = () => `${sel('collection')},${sel('products')},${sel('recommendations')}`;
32
-
33
- /**
34
- * @param {EventTarget | null} target
35
- * @returns {target is Element}
36
- */
37
- const isElement = (target) => target instanceof window.Element;
38
-
39
- /**
40
- * @param {string | null} raw A `tether-limit`.
41
- * @returns {number | undefined} The limit; `undefined` for none, zero or not a number.
42
- */
43
- const toLimit = (raw) => parseInt(raw ?? '', 10) || undefined;
44
-
45
- /**
46
- * @param {string | null} raw A `tether-intent`.
47
- * @returns {'RELATED' | 'COMPLEMENTARY'} Shopify's recommendation intent, `RELATED` unless `COMPLEMENTARY` is written.
48
- */
49
- const toIntent = (raw) => (raw === 'COMPLEMENTARY' ? 'COMPLEMENTARY' : 'RELATED');
50
-
51
- export class ListScope {
52
- /** @param {HTMLElement} root */
53
- constructor(root) {
54
- this.root = root;
55
- /** @type {Product[]} */
56
- this.products = [];
57
- /** @type {PageInfo | null} */
58
- this.pageInfo = null;
59
- /** @type {Collection | null} */
60
- this.collection = null;
61
- /** Counts loads, so only the latest one renders. */
62
- this.loadToken = 0;
63
- setScopeStatus(root, 'loading');
64
- root.addEventListener('click', (event) => {
65
- if (!isElement(event.target)) return;
66
- const more = event.target.closest(sel('load-more'));
67
- if (more?.closest(listSelector()) !== root) return;
68
- event.preventDefault();
69
- void busy(more, () => this.load(true));
70
- });
71
- }
72
-
73
- /** @param {string} selector */
74
- $(selector) {
75
- return scoped(this.root, selector, listSelector());
76
- }
77
-
78
- /** @returns {ProductQuery} The collection or search query of the list's attributes. */
79
- options() {
80
- const { root } = this;
81
- /** @type {ProductQuery} */
82
- const query = { reverse: get(root, 'reverse') === 'true' };
83
- const first = toLimit(get(root, 'limit'));
84
- if (first) query.first = first;
85
- const sortKey = get(root, 'sort');
86
- if (sortKey) query.sortKey = sortKey;
87
- if (has(root, 'collection')) {
88
- query.collection = get(root, 'collection') ?? '';
89
- return query;
90
- }
91
- const raw = get(root, 'products') ?? '';
92
- query.query = raw.startsWith(URL_QUERY_SOURCE)
93
- ? new URL(window.location.href).searchParams.get(raw.slice(URL_QUERY_SOURCE.length)) || ''
94
- : raw;
95
- return query;
96
- }
97
-
98
- /**
99
- * The product of a recommendations list: a handle, `url:…` (one dynamic product page), or empty inside a product
100
- * scope for that product.
101
- *
102
- * @returns {Promise<Product[]>}
103
- * @throws {TetherError} `NOT_FOUND` when there is no product to recommend for.
104
- */
105
- async loadRecommendations() {
106
- let { key } = resolveProductKey(this.root, window.location, 'recommendations');
107
- const scope = this.root.closest(sel('product'));
108
- if (!key && scope) ({ key } = resolveProductKey(scope));
109
- if (!key) throw new TetherError('No product for recommendations', { code: 'NOT_FOUND' });
110
- return getRecommendations(key, toIntent(get(this.root, 'intent')));
111
- }
112
-
113
- /** @param {boolean} [more] Appends the next page instead of replacing the products. */
114
- async load(more = false) {
115
- this.loadToken += 1;
116
- const token = this.loadToken;
117
- const stale = () => token !== this.loadToken;
118
- try {
119
- if (has(this.root, 'recommendations')) {
120
- const recommendations = await this.loadRecommendations();
121
- if (stale()) return;
122
- const limit = toLimit(get(this.root, 'limit'));
123
- this.products = limit ? recommendations.slice(0, limit) : recommendations;
124
- } else {
125
- const page = await getProducts({ ...this.options(), after: more ? (this.pageInfo?.endCursor ?? null) : null });
126
- if (stale()) return;
127
- this.collection = page.collection;
128
- this.pageInfo = page.pageInfo;
129
- this.products = more ? [...this.products, ...page.products] : page.products;
130
- }
131
- this.render();
132
- setScopeStatus(this.root, 'ready');
133
- emit('list-ready', { products: this.products, collection: this.collection }, this.root);
134
- } catch (error) {
135
- if (stale()) return;
136
- // A failed "load more" keeps what is rendered; otherwise the list shows its error state.
137
- if (!more) setScopeStatus(this.root, isNotFound(error) ? 'not-found' : 'error', error);
138
- // A missing product for recommendations is a page detail, not an error to report.
139
- if (!(isNotFound(error) && has(this.root, 'recommendations'))) reportError(error, 'list load failed');
140
- emit('error', { error, context: 'list' }, this.root);
141
- }
142
- }
143
-
144
- render() {
145
- const { collection } = this;
146
- this.$(sel('collection-bind')).forEach((el) => {
147
- switch (get(el, 'collection-bind') ?? '') {
148
- case 'title':
149
- setText(el, collection?.title ?? '');
150
- break;
151
- case 'description':
152
- setStoreHtml(el, collection?.descriptionHtml);
153
- break;
154
- case 'image':
155
- setImage(el, collection?.image?.url, collection?.image?.altText || collection?.title);
156
- break;
157
- case 'count':
158
- setText(el, this.products.length);
159
- break;
160
- }
161
- });
162
-
163
- this.$(sel('list')).forEach((container) => {
164
- repeat(container, this.products, (node, product) => {
165
- node.setAttribute(attr('product'), product.handle);
166
- node.removeAttribute(attr('state'));
167
- });
168
- // Cards are new elements: mounting them uses the product data already cached.
169
- emit('list-rendered', { container }, this.root);
170
- });
171
-
172
- const empty = this.products.length === 0;
173
- this.$(sel('show')).forEach((el) => {
174
- const key = get(el, 'show');
175
- if (key === 'list-empty') show(el, empty);
176
- if (key === 'list-filled') show(el, !empty);
177
- });
178
- this.$(sel('load-more')).forEach((el) => {
179
- show(el, Boolean(this.pageInfo?.hasNextPage));
180
- });
181
- notifyLayoutChanged('list');
182
- }
183
- }
package/src/config.js DELETED
@@ -1,349 +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
- * Options that end up in query text or attribute names are validated here, once, where they arrive.
5
- */
6
- /** @import { Money } from './shopify-values.js' */
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>} */
101
- export const DEFAULTS = {
102
- domain: '',
103
- token: '',
104
- apiVersion: '2026-07',
105
- country: undefined,
106
- language: undefined,
107
- locale: undefined,
108
- markets: null,
109
- prefix: 'tether',
110
- productPath: '/product',
111
- collectionPath: '/collection',
112
- metafields: [],
113
- imagesFirst: 25,
114
- variantsFirst: 100,
115
- sellingPlanGroupsFirst: 10,
116
- sellingPlansFirst: 25,
117
- collectionFirst: 24,
118
- retries: 2,
119
- revalidate: true,
120
- storageKey: '',
121
- cartDrawerOpenClass: 'is-open',
122
- openCartOnAdd: true,
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,
145
- debug: false,
146
- };
147
-
148
- /** @type {TetherConfig} */
149
- export const cfg = { ...DEFAULTS };
150
-
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]);
214
- }
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. */
322
- export const isConfigured = () => Boolean(cfg.domain && cfg.token);
323
-
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
- }
333
-
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);
348
- }
349
- }
@@ -1,155 +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.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
- }