@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/cart.js DELETED
@@ -1,391 +0,0 @@
1
- /**
2
- * The cart, as one object: `Tether.cart`. Every change goes through one queue (fast clicks never race) and resolves
3
- * with the cart Shopify sent back. The state lives in `cart/store.js`, the Cart API calls in `cart/mutations.js`, the
4
- * drawer in `cart/drawer.js`, the document rendering in `cart/render.js` and the checkout in `cart/checkout.js`.
5
- */
6
- import { cfg, reportError } from './config.js';
7
- import { TetherError } from './client.js';
8
- import { emit, toAttributes } from './dom.js';
9
- import {
10
- fetchCart,
11
- isCartGone,
12
- mutateCart,
13
- removeLines,
14
- setLineQuantity,
15
- toLineInput,
16
- toLineUpdate,
17
- updateLines,
18
- } from './cart/mutations.js';
19
- import { checkout } from './cart/checkout.js';
20
- import { drawers, isOpen, openCart, closeCart, toggleCart, trapFocus } from './cart/drawer.js';
21
- import {
22
- announce,
23
- cartStatus,
24
- commit,
25
- currentCart,
26
- currentWarnings,
27
- customer,
28
- enqueue,
29
- forgetSavedCart,
30
- keepQuietly,
31
- pendingQuantities,
32
- pendingQuantityOf,
33
- render,
34
- savedCartId,
35
- setFailure,
36
- settleLoad,
37
- showLastKnownCount,
38
- subscribe,
39
- } from './cart/store.js';
40
- /** @import { CartWarning } from './config.js' */
41
- /** @import { Attributes, LineSpec, CartLineInput, LinePatch } from './cart/mutations.js' */
42
- /** @import { ShopifyCart, ShopifyCartLine, CustomerLink, CartStatus } from './cart/store.js' */
43
-
44
- export { trapFocus };
45
-
46
- /** @returns {readonly ShopifyCartLine[]} */
47
- const currentLines = () => currentCart()?.lines.nodes ?? [];
48
-
49
- /**
50
- * +1 / -1 for a line. Quick clicks add up: each builds on the quantity the click before asked for, which shows at once
51
- * (`tether-line="quantity"`, `quantityOf`) until Shopify has answered. Only the latest target is sent; the ones it
52
- * replaced are skipped.
53
- *
54
- * @param {string} lineId
55
- * @param {number} delta
56
- * @returns {Promise<ShopifyCart | null>}
57
- */
58
- function changeQuantity(lineId, delta) {
59
- const line = currentLines().find((candidate) => candidate.id === lineId);
60
- if (!line) return Promise.resolve(currentCart());
61
- const target = Math.max(0, pendingQuantityOf(line) + delta);
62
- const job = { quantity: target };
63
- pendingQuantities.set(lineId, job);
64
- announce('pending');
65
- const isLatest = () => pendingQuantities.get(lineId) === job;
66
- const sent = enqueue(async () => {
67
- if (!isLatest()) return currentCart();
68
- const now = currentLines().find((candidate) => candidate.id === lineId);
69
- // A + then a -: Shopify already has the quantity.
70
- if (now?.quantity === target) return currentCart();
71
- const cart = await setLineQuantity(lineId, target);
72
- // A newer click is on its way: keep this answer, but show totals and count once that one is in, instead of
73
- // stepping through every reply.
74
- if (!isLatest()) {
75
- keepQuietly(cart);
76
- return cart;
77
- }
78
- return commit(cart, target > 0 ? 'update' : 'remove');
79
- });
80
- return sent.finally(() => {
81
- if (!isLatest()) return;
82
- pendingQuantities.delete(lineId);
83
- announce('settled');
84
- });
85
- }
86
-
87
- /**
88
- * The codes the cart has once a change is its turn in the queue.
89
- *
90
- * @param {string[] | string | ((current: string[]) => string[])} codes A function gets the codes as they are by then.
91
- * @returns {string[]}
92
- */
93
- function discountCodesFrom(codes) {
94
- const current = (currentCart()?.discountCodes ?? []).map((discount) => discount.code);
95
- const list = typeof codes === 'function' ? codes(current) : codes;
96
- return (Array.isArray(list) ? list : [list]).map((code) => code.trim()).filter(Boolean);
97
- }
98
-
99
- /**
100
- * @param {Attributes} attributes
101
- * @returns {{ key: string, value: string }[]} The cart's attributes with `attributes` merged in; `''` removes a key.
102
- */
103
- function mergedAttributes(attributes) {
104
- const merged = new Map((currentCart()?.attributes ?? []).map((attribute) => [attribute.key, attribute.value]));
105
- toAttributes(attributes).forEach((attribute) => merged.set(attribute.key, attribute.value));
106
- return [...merged].filter(([, value]) => value !== '').map(([key, value]) => ({ key, value }));
107
- }
108
-
109
- /**
110
- * Loads the saved cart. A cart Shopify no longer has is forgotten; one it can't be asked about (the network, the
111
- * token) is kept, because it is probably fine.
112
- *
113
- * @returns {Promise<ShopifyCart | null>}
114
- */
115
- async function init() {
116
- const id = savedCartId();
117
- if (!id) {
118
- settleLoad({ kind: 'loaded', cart: null });
119
- return null;
120
- }
121
- showLastKnownCount();
122
- try {
123
- const cart = await fetchCart(id);
124
- if (!cart) forgetSavedCart();
125
- settleLoad({ kind: 'loaded', cart });
126
- } catch (error) {
127
- reportError(error, 'cart load failed');
128
- if (isCartGone(error)) forgetSavedCart();
129
- settleLoad({ kind: 'failed', error });
130
- }
131
- return currentCart();
132
- }
133
-
134
- export const Cart = {
135
- init,
136
- /** @returns {string | null} The saved cart's id. */
137
- id: savedCartId,
138
-
139
- /** @returns {CartStatus} */
140
- get status() {
141
- return cartStatus();
142
- },
143
- /** @returns {ShopifyCart | null} */
144
- get state() {
145
- return currentCart();
146
- },
147
- /** @returns {readonly ShopifyCartLine[]} */
148
- get lines() {
149
- return currentLines();
150
- },
151
- /** @returns {number} */
152
- get count() {
153
- return currentCart()?.totalQuantity ?? 0;
154
- },
155
- /** @returns {boolean} True once the saved cart, or that there is none, is known. */
156
- get loaded() {
157
- return cartStatus().kind !== 'unknown';
158
- },
159
- /** @returns {unknown} The failure of the last load or change, until a change succeeds. */
160
- get lastError() {
161
- const current = cartStatus();
162
- return current.kind === 'error' ? current.error : null;
163
- },
164
- set lastError(error) {
165
- setFailure(error);
166
- },
167
- /** @returns {readonly CartWarning[]} Shopify's notes on the last change. */
168
- get warnings() {
169
- return currentWarnings();
170
- },
171
- /** @returns {ReadonlyMap<string, { quantity: number }>} Line id → the quantity quick clicks asked for. */
172
- get pendingQty() {
173
- return pendingQuantities;
174
- },
175
-
176
- /** @returns {string | null} The logged-in customer's Customer Account API token (set by the account part). */
177
- get customerAccessToken() {
178
- return customer.accessToken;
179
- },
180
- set customerAccessToken(token) {
181
- customer.accessToken = token || null;
182
- },
183
- /** @returns {(() => Promise<string | null>) | null} The account's own checkout URL (set by the account part). */
184
- get beforeCheckout() {
185
- return customer.beforeCheckout;
186
- },
187
- set beforeCheckout(fetchUrl) {
188
- customer.beforeCheckout = typeof fetchUrl === 'function' ? fetchUrl : null;
189
- },
190
- /**
191
- * Links the cart to a logged-in customer, or unlinks it with `null`s. Carts created from now on belong to the
192
- * customer; `beforeCheckout` gives the checkout URL (`null` from it uses the cart's).
193
- *
194
- * @param {Partial<CustomerLink>} link Fields left out keep their value.
195
- */
196
- setCustomer(link) {
197
- if (link.accessToken !== undefined) customer.accessToken = link.accessToken;
198
- if (link.beforeCheckout !== undefined) customer.beforeCheckout = link.beforeCheckout;
199
- },
200
-
201
- /** Keeps a cart and shows it, as a change does; tests seed the cart with it. */
202
- _commit: commit,
203
- render,
204
- subscribe,
205
-
206
- /**
207
- * `add(variantId, 2)`, `add({ variantId, quantity, sellingPlanId, attributes })`, or `add([line, line])` for
208
- * bundles in one request. Creates the cart when there is none.
209
- *
210
- * @param {string | number | LineSpec | (string | number | LineSpec)[]} input
211
- * @param {number} [quantity]
212
- * @param {Omit<LineSpec, 'variantId' | 'quantity'>} [options]
213
- * @returns {Promise<ShopifyCart | null>}
214
- */
215
- add(input, quantity = 1, options = {}) {
216
- /** @type {CartLineInput[]} */
217
- const lines = (Array.isArray(input) ? input : [input]).map((line) => toLineInput(line, quantity, options));
218
- return enqueue(async () => {
219
- const cart = await mutateCart({
220
- name: 'cartLinesAdd',
221
- args: '$lines: [CartLineInput!]!',
222
- inner: 'lines: $lines',
223
- variables: { lines },
224
- createInput: { lines },
225
- });
226
- commit(cart, 'add');
227
- emit('added', { cart, lines });
228
- if (cfg.openCartOnAdd) openCart();
229
- return cart;
230
- });
231
- },
232
-
233
- /**
234
- * `updateLine(lineId, 3)` or `updateLine(lineId, { quantity, sellingPlanId, attributes })`.
235
- *
236
- * @param {string} lineId
237
- * @param {number | LinePatch} patch A quantity of 0 or less removes the line.
238
- * @returns {Promise<ShopifyCart | null>}
239
- */
240
- updateLine(lineId, patch) {
241
- const quantity = typeof patch === 'number' ? patch : patch.quantity;
242
- if (quantity !== undefined && quantity <= 0) return Cart.removeLine(lineId);
243
- const line = toLineUpdate(lineId, patch);
244
- return enqueue(async () => commit(await updateLines([line]), 'update'));
245
- },
246
-
247
- changeQuantity,
248
-
249
- /**
250
- * @param {ShopifyCartLine} line
251
- * @returns {number} The quantity to show: the one asked for while an update is on its way.
252
- */
253
- quantityOf: pendingQuantityOf,
254
-
255
- /**
256
- * @param {string | string[]} lineIds
257
- * @returns {Promise<ShopifyCart | null>}
258
- */
259
- removeLine(lineIds) {
260
- const ids = Array.isArray(lineIds) ? lineIds : [lineIds];
261
- return enqueue(async () => commit(await removeLines(ids), 'remove'));
262
- },
263
-
264
- /** @returns {Promise<ShopifyCart | null>} */
265
- clear() {
266
- const ids = currentLines().map((line) => line.id);
267
- if (!ids.length) return Promise.resolve(currentCart());
268
- return Cart.removeLine(ids);
269
- },
270
-
271
- /**
272
- * @param {string | null | undefined} note
273
- * @returns {Promise<ShopifyCart | null>}
274
- */
275
- setNote(note) {
276
- const variables = { note: note ?? '' };
277
- return enqueue(async () =>
278
- commit(
279
- await mutateCart({
280
- name: 'cartNoteUpdate',
281
- args: '$note: String!',
282
- inner: 'note: $note',
283
- variables,
284
- createInput: variables,
285
- }),
286
- 'note',
287
- ),
288
- );
289
- },
290
-
291
- /**
292
- * Cart attributes, merged with the cart's (as it is when the change runs, so quick calls don't drop each other).
293
- *
294
- * @param {Attributes} attributes The value `''` removes a key.
295
- * @returns {Promise<ShopifyCart | null>}
296
- */
297
- setAttributes(attributes) {
298
- return enqueue(async () => {
299
- const variables = { attributes: mergedAttributes(attributes) };
300
- const cart = await mutateCart({
301
- name: 'cartAttributesUpdate',
302
- args: '$attributes: [AttributeInput!]!',
303
- inner: 'attributes: $attributes',
304
- variables,
305
- createInput: variables,
306
- });
307
- return commit(cart, 'attributes');
308
- });
309
- },
310
-
311
- /**
312
- * Replaces the discount codes; `[]` removes them all.
313
- *
314
- * @param {string[] | string | ((current: string[]) => string[])} codes
315
- * @returns {Promise<ShopifyCart | null>}
316
- */
317
- setDiscountCodes(codes) {
318
- return enqueue(async () => {
319
- const variables = { discountCodes: discountCodesFrom(codes) };
320
- const cart = await mutateCart({
321
- name: 'cartDiscountCodesUpdate',
322
- args: '$discountCodes: [String!]!',
323
- inner: 'discountCodes: $discountCodes',
324
- variables,
325
- createInput: variables,
326
- });
327
- return commit(cart, 'discount');
328
- });
329
- },
330
-
331
- /**
332
- * Adds a code. A code Shopify doesn't accept is removed again, so it doesn't warn on every later change.
333
- *
334
- * @param {string} code
335
- * @returns {Promise<ShopifyCart | null>}
336
- * @throws {TetherError} `INVALID_DISCOUNT` (rejected) when Shopify doesn't accept the code.
337
- */
338
- async applyDiscount(code) {
339
- const cart = await Cart.setDiscountCodes((current) => [...new Set([...current, code])]);
340
- const wanted = code.toLowerCase();
341
- const applied = (cart?.discountCodes ?? []).find((discount) => discount.code.toLowerCase() === wanted);
342
- if (applied?.applicable) return cart;
343
- try {
344
- await Cart.removeDiscount(code);
345
- } catch (error) {
346
- reportError(error, 'removing a discount code Shopify refused');
347
- }
348
- throw new TetherError(`Discount code not valid: ${code}`, { code: 'INVALID_DISCOUNT' });
349
- },
350
-
351
- /**
352
- * @param {string} code
353
- * @returns {Promise<ShopifyCart | null>}
354
- */
355
- removeDiscount(code) {
356
- const unwanted = code.toLowerCase();
357
- return Cart.setDiscountCodes((current) => current.filter((existing) => existing.toLowerCase() !== unwanted));
358
- },
359
-
360
- /**
361
- * Sets who is buying; a country changes the cart's currency and prices to that market's.
362
- *
363
- * @param {{ countryCode?: string, email?: string, phone?: string, customerAccessToken?: string | null }} buyerIdentity
364
- * @param {{ create?: boolean }} [options] `create: false` forgets an expired cart instead of creating an empty one.
365
- * @returns {Promise<ShopifyCart | null>}
366
- */
367
- setBuyerIdentity(buyerIdentity, { create = true } = {}) {
368
- return enqueue(async () => {
369
- const cart = await mutateCart({
370
- name: 'cartBuyerIdentityUpdate',
371
- args: '$buyerIdentity: CartBuyerIdentityInput!',
372
- inner: 'buyerIdentity: $buyerIdentity',
373
- variables: { buyerIdentity },
374
- createInput: create ? { buyerIdentity } : null,
375
- }).catch((/** @type {unknown} */ error) => {
376
- // mutateCart has forgotten the cart Shopify no longer has; without `create` that leaves no cart.
377
- if (create || !isCartGone(error)) throw error;
378
- return null;
379
- });
380
- return commit(cart, 'buyer-identity');
381
- });
382
- },
383
-
384
- checkout,
385
-
386
- drawers,
387
- isOpen,
388
- open: openCart,
389
- close: closeCart,
390
- toggle: toggleCart,
391
- };
package/src/client.js DELETED
@@ -1,267 +0,0 @@
1
- /**
2
- * The one Storefront API client: a fetch wrapper that adds the market (`@inContext`), retries what Shopify asks to be
3
- * retried, and turns every failure into a `TetherError`. The browser (`gql`, also `Tether.query()`) and the server
4
- * helpers (`request` with their own config) share it.
5
- */
6
- import { cfg, log } from './config.js';
7
- import { toCountry, toLanguage } from './market-codes.js';
8
- /** @import { TetherConfig } from './config.js' */
9
-
10
- /**
11
- * @typedef {'NOT_FOUND' | 'NETWORK' | 'NOT_CONFIGURED' | 'NOT_LOGGED_IN' | 'NOT_ALLOWED' | 'INVALID'
12
- * | 'INVALID_COUNTRY' | 'INVALID_DISCOUNT' | 'SESSION_ENDED' | 'TOKEN_FAILED' | 'ERROR' | `HTTP_${number}`}
13
- * TetherErrorCode
14
- */
15
- /** @typedef {{ message: string, extensions?: { code?: string } }} GraphQLError */
16
- /** @typedef {{ data?: unknown, errors?: GraphQLError[] }} GraphQLResponse The response shape the GraphQL spec sets. */
17
- /** @typedef {{ message: string, field?: string[] | null, code?: string | null }} UserError */
18
- /**
19
- * @typedef {object} TetherErrorDetails
20
- * @property {TetherErrorCode} [code] Default `HTTP_<status>` with a status, else `ERROR`.
21
- * @property {number} [status] The HTTP status of the response that failed.
22
- * @property {GraphQLError[]} [errors] The GraphQL errors of that response.
23
- * @property {readonly UserError[]} [userErrors] A mutation's user errors.
24
- */
25
-
26
- export class TetherError extends Error {
27
- /**
28
- * @param {string} message
29
- * @param {TetherErrorDetails} [details]
30
- */
31
- constructor(message, { status, errors, userErrors, code } = {}) {
32
- super(message);
33
- this.name = 'TetherError';
34
- /** @type {TetherErrorCode} */
35
- this.code = code ?? (status ? `HTTP_${status}` : 'ERROR');
36
- /** @type {number | undefined} */
37
- this.status = status;
38
- /** @type {GraphQLError[] | undefined} */
39
- this.errors = errors;
40
- /** @type {readonly UserError[] | undefined} */
41
- this.userErrors = userErrors;
42
- }
43
- }
44
-
45
- /**
46
- * What a request needs from a config: the browser's `cfg`, or a config the server helpers build.
47
- *
48
- * @typedef {Pick<TetherConfig, 'domain' | 'token' | 'apiVersion' | 'country' | 'language' | 'maxAge' | 'retries'>
49
- * & { fetch?: typeof fetch }} StorefrontConfig
50
- */
51
- /** @typedef {{ context?: boolean }} RequestOptions `context: false` sends the query without `@inContext`. */
52
-
53
- /**
54
- * @param {Pick<TetherConfig, 'domain' | 'apiVersion'>} [conf]
55
- * @returns {string} The Storefront GraphQL endpoint of `conf`'s store.
56
- */
57
- export function endpoint(conf = cfg) {
58
- return `https://${conf.domain}/api/${conf.apiVersion}/graphql.json`;
59
- }
60
-
61
- /**
62
- * Adds @inContext(country, language) to the first operation in the document, so prices, currency and translations
63
- * follow the configured market. Operations are always written first, fragments after, so the first match is the
64
- * operation. A country or language that isn't a valid code is left out.
65
- *
66
- * @param {string} query
67
- * @param {{ country?: string | undefined, language?: string | undefined }} [ctx]
68
- * @returns {string}
69
- */
70
- export function withContext(query, ctx = cfg) {
71
- const args = [];
72
- const country = toCountry(ctx.country);
73
- const language = toLanguage(ctx.language);
74
- if (country) args.push(`country: ${country}`);
75
- if (language) args.push(`language: ${language}`);
76
- if (!args.length || query.includes('@inContext')) return query;
77
-
78
- const tag = `@inContext(${args.join(', ')})`;
79
- // Named (query Name($x: T)), anonymous (query { … }) and shorthand ({ … }) queries.
80
- if (/^\s*\{/.test(query)) return `query ${tag} ${query.trimStart()}`;
81
- return query.replace(/^(\s*(?:query|mutation)(?:\s+\w+)?\s*(?:\([^)]*\))?)/, `$1 ${tag}`);
82
- }
83
-
84
- /** Backoff after a network error or a 429/5xx: this times 2^attempt. */
85
- const RETRY_BACKOFF_MS = 300;
86
- /** Backoff after Shopify's `THROTTLED` error, which asks for a longer wait: this times 2^attempt. */
87
- const THROTTLE_BACKOFF_MS = 500;
88
- /** Shopify's status for a request its bot protection rejected; retrying doesn't help. */
89
- const SECURITY_REJECTION = 430;
90
-
91
- /**
92
- * `quantityAvailable` needs the token's `unauthenticated_read_product_inventory` scope; without it Shopify refuses the
93
- * whole query with an error naming the field. That refusal is learned once per page and store: every later query to
94
- * the store goes out without the field, so stock levels are unknown and everything else works for one extra request.
95
- */
96
- const INVENTORY_FIELD = 'quantityAvailable';
97
-
98
- /** @type {Set<string>} The stores (domain, version and token) whose token can't read inventory. */
99
- const storesWithoutInventory = new Set();
100
-
101
- /** @param {StorefrontConfig} conf */
102
- const storeOf = (conf) => `${conf.domain} ${conf.apiVersion} ${conf.token}`;
103
-
104
- /** @param {number} ms */
105
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
106
-
107
- /**
108
- * What a status means, for errors Shopify sends without a message (401/403 have none).
109
- *
110
- * @param {number} status
111
- * @param {StorefrontConfig} conf
112
- */
113
- function statusText(status, conf) {
114
- if (status === 401 || status === 403) {
115
- const adminHint = conf.token.startsWith('shpat_') ? ', not an Admin API token (shpat_…)' : '';
116
- return `Shopify rejected the Storefront token for ${conf.domain} (${status}): use the public Storefront API token (Headless channel)${adminHint}`;
117
- }
118
- if (status === 404) {
119
- return `Store or API version not found (404): check domain ${conf.domain} and apiVersion ${conf.apiVersion}`;
120
- }
121
- return `HTTP ${status} from ${conf.domain}`;
122
- }
123
-
124
- /**
125
- * @param {Response} res
126
- * @param {GraphQLError[]} errors
127
- * @param {StorefrontConfig} conf
128
- */
129
- function graphQLFailure(res, errors, conf) {
130
- const message = errors
131
- .map((error) => error.message)
132
- .filter(Boolean)
133
- .join(' | ');
134
- let text = message || 'Storefront API error';
135
- if (!res.ok) text = res.status === 401 || res.status === 403 || !message ? statusText(res.status, conf) : message;
136
- return new TetherError(text, { status: res.status, errors });
137
- }
138
-
139
- /**
140
- * @typedef {{ kind: 'data', data: unknown }
141
- * | { kind: 'retry', backoffMs: number }
142
- * | { kind: 'inventory-refused' }} Attempt
143
- */
144
-
145
- /**
146
- * One request. Failures that retrying can't fix throw.
147
- *
148
- * @param {StorefrontConfig} conf
149
- * @param {string} query
150
- * @param {Record<string, unknown>} variables
151
- * @param {boolean} canRetry False on the last attempt: a retryable failure then throws too.
152
- * @returns {Promise<Attempt>}
153
- */
154
- async function attempt(conf, query, variables, canRetry) {
155
- let res;
156
- try {
157
- res = await (conf.fetch ?? fetch)(endpoint(conf), {
158
- method: 'POST',
159
- headers: { 'Content-Type': 'application/json', 'X-Shopify-Storefront-Access-Token': conf.token },
160
- body: JSON.stringify({ query, variables }),
161
- });
162
- } catch (error) {
163
- if (canRetry) return { kind: 'retry', backoffMs: RETRY_BACKOFF_MS };
164
- throw new TetherError(`Network error: ${error instanceof Error ? error.message : String(error)}`, {
165
- code: 'NETWORK',
166
- });
167
- }
168
- if (res.status === SECURITY_REJECTION) {
169
- throw new TetherError('Shopify security rejection (430)', { status: SECURITY_REJECTION });
170
- }
171
- if (canRetry && (res.status === 429 || res.status >= 500)) {
172
- return { kind: 'retry', backoffMs: RETRY_BACKOFF_MS };
173
- }
174
-
175
- /** @type {unknown} */
176
- let json;
177
- try {
178
- json = await res.json();
179
- } catch {
180
- const text = res.ok ? `Invalid response (${res.status})` : statusText(res.status, conf);
181
- throw new TetherError(text, { status: res.status });
182
- }
183
- // The Storefront API answers in the shape the GraphQL spec sets, or with an error page that isn't JSON.
184
- const { data, errors = [] } = /** @type {GraphQLResponse | null} */ (json) ?? {};
185
- if (errors.length) {
186
- if (query.includes(INVENTORY_FIELD) && errors.some((error) => error.message.includes(INVENTORY_FIELD))) {
187
- return { kind: 'inventory-refused' };
188
- }
189
- if (canRetry && errors.some((error) => error.extensions?.code === 'THROTTLED')) {
190
- return { kind: 'retry', backoffMs: THROTTLE_BACKOFF_MS };
191
- }
192
- throw graphQLFailure(res, errors, conf);
193
- }
194
- if (!res.ok) throw new TetherError(statusText(res.status, conf), { status: res.status });
195
- return { kind: 'data', data };
196
- }
197
-
198
- /**
199
- * One Storefront API request, for any config: the browser's shared one (`gql`) or one the server helpers build.
200
- * Queries carry a time bucket so Shopify's cache of an identical query text is at most `maxAge` seconds old, and
201
- * leave out inventory for a store whose token can't read it (`INVENTORY_FIELD`).
202
- *
203
- * @template T The shape of the query's `data`, which the caller's query text decides.
204
- * @param {StorefrontConfig} conf
205
- * @param {string} query
206
- * @param {Record<string, unknown>} [variables]
207
- * @param {RequestOptions} [options]
208
- * @returns {Promise<T>}
209
- * @throws {TetherError} `NOT_CONFIGURED` without a domain or token, `NETWORK` when the store can't be reached,
210
- * `HTTP_<status>` for an HTTP or GraphQL failure.
211
- */
212
- export async function request(conf, query, variables = {}, { context = true } = {}) {
213
- if (!conf.domain || !conf.token) {
214
- throw new TetherError('Tether is not configured: domain and token are required', { code: 'NOT_CONFIGURED' });
215
- }
216
- let text = context ? withContext(query, conf) : query;
217
- if (conf.maxAge > 0 && !/^\s*mutation\b/.test(text)) {
218
- text += ` #${Math.floor(Date.now() / (conf.maxAge * 1000))}`;
219
- }
220
- const store = storeOf(conf);
221
- let retries = 0;
222
- for (;;) {
223
- const sent = storesWithoutInventory.has(store) ? text.replaceAll(INVENTORY_FIELD, '') : text;
224
- const outcome = await attempt(conf, sent, variables, retries < conf.retries);
225
- switch (outcome.kind) {
226
- case 'data':
227
- // The query text decides the shape of `data`; the caller's `T` states it.
228
- return /** @type {T} */ (outcome.data);
229
- case 'inventory-refused':
230
- log(`the token can't read inventory: ${INVENTORY_FIELD} is left out from now on`);
231
- storesWithoutInventory.add(store);
232
- break;
233
- case 'retry':
234
- retries += 1;
235
- log('retry', retries);
236
- await sleep(outcome.backoffMs * 2 ** retries);
237
- break;
238
- }
239
- }
240
- }
241
-
242
- /**
243
- * `request` with the browser's shared config.
244
- *
245
- * @template T
246
- * @param {string} query
247
- * @param {Record<string, unknown>} [variables]
248
- * @param {RequestOptions} [options]
249
- * @returns {Promise<T>}
250
- */
251
- export const gql = (query, variables, options) => request(cfg, query, variables, options);
252
-
253
- /**
254
- * @template {{ userErrors?: readonly UserError[] | null }} Payload
255
- * @param {Payload | null | undefined} payload A mutation's payload.
256
- * @param {string} name The mutation, for the error message.
257
- * @returns {Payload}
258
- * @throws {TetherError} `ERROR` on an empty payload or on user errors (in `userErrors`).
259
- */
260
- export function unwrap(payload, name) {
261
- if (!payload) throw new TetherError(`${name}: empty response`);
262
- const userErrors = payload.userErrors ?? [];
263
- if (userErrors.length) {
264
- throw new TetherError(userErrors.map((error) => error.message).join(' | '), { userErrors });
265
- }
266
- return payload;
267
- }