@occluze/tether 0.6.8 → 0.7.0

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 (259) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -2
  24. package/dist/presets/barba.esm.js +19 -23
  25. package/dist/tether.account-pages.js +320 -365
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +242 -491
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +66 -88
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +36 -33
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +28 -36
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +1270 -894
  36. package/dist/tether.markets.js +73 -90
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +44 -68
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +24 -23
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/{order-lines.js → order-lines.ts} +20 -41
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/{page-events.js → page-events.ts} +55 -90
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/{account-pages.js → account-pages.ts} +29 -37
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
  135. package/src/analytics.ts +315 -0
  136. package/src/{api-version.js → api-version.ts} +25 -31
  137. package/src/{astro.js → astro.ts} +110 -104
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/{drawer.js → drawer.ts} +18 -31
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/{render.js → render.ts} +68 -96
  143. package/src/cart/store.ts +279 -0
  144. package/src/{cart.js → cart.ts} +82 -128
  145. package/src/client.ts +391 -0
  146. package/src/{collection.js → collection.ts} +30 -38
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
  150. package/src/controls/{discount.js → discount.ts} +13 -34
  151. package/src/controls/{extras.js → extras.ts} +15 -24
  152. package/src/controls/{navigation.js → navigation.ts} +24 -30
  153. package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
  154. package/src/{diagnose.js → diagnose.ts} +95 -96
  155. package/src/dom.ts +268 -0
  156. package/src/{extras.js → extras.ts} +43 -52
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/{images.js → images.ts} +30 -43
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/{loader.js → loader.ts} +149 -123
  165. package/src/market-codes.ts +6 -0
  166. package/src/{markets-ui.js → markets-ui.ts} +91 -68
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/{mounting.js → mounting.ts} +88 -101
  170. package/src/{page-market.js → page-market.ts} +32 -38
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/{product-events.js → product-events.ts} +33 -48
  181. package/src/{product-render.js → product-render.ts} +38 -77
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/{product.js → product.ts} +90 -126
  185. package/src/{repeaters.js → repeaters.ts} +40 -52
  186. package/src/{search.js → search.ts} +85 -103
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/{shopify-values.js → shopify-values.ts} +24 -22
  190. package/src/{status.js → status.ts} +34 -54
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
  195. package/src/{variant-render.js → variant-render.ts} +24 -50
  196. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  197. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  198. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  199. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  200. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  201. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  202. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  203. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  204. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  205. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  206. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  207. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  208. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  209. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  210. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  211. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  212. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  213. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  214. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  215. package/src/account/addresses-view.js +0 -92
  216. package/src/account/browser-session.js +0 -359
  217. package/src/account/markup.js +0 -48
  218. package/src/account/operations.js +0 -180
  219. package/src/account/order-list.js +0 -91
  220. package/src/account/order-page.js +0 -192
  221. package/src/account/server-client.js +0 -172
  222. package/src/account/shared.js +0 -111
  223. package/src/account/shopify-auth.js +0 -150
  224. package/src/account-api.js +0 -118
  225. package/src/account-links.js +0 -35
  226. package/src/account.js +0 -342
  227. package/src/amounts.js +0 -36
  228. package/src/analytics.js +0 -352
  229. package/src/cart/checkout.js +0 -97
  230. package/src/cart/mutations.js +0 -234
  231. package/src/cart/store.js +0 -326
  232. package/src/client.js +0 -267
  233. package/src/config.js +0 -349
  234. package/src/dom.js +0 -287
  235. package/src/fragments.js +0 -237
  236. package/src/index.js +0 -353
  237. package/src/initial.js +0 -87
  238. package/src/lazy-iife.js +0 -116
  239. package/src/lazy.js +0 -39
  240. package/src/market-codes.js +0 -35
  241. package/src/markets.js +0 -334
  242. package/src/markup.js +0 -64
  243. package/src/parts/account-pages.js +0 -6
  244. package/src/parts/account.js +0 -6
  245. package/src/parts/analytics.js +0 -6
  246. package/src/parts/diagnose.js +0 -6
  247. package/src/parts/extras.js +0 -6
  248. package/src/parts/markets.js +0 -6
  249. package/src/parts/search.js +0 -6
  250. package/src/presets/barba.js +0 -190
  251. package/src/product-state.js +0 -318
  252. package/src/selling-plans.js +0 -154
  253. package/src/server.js +0 -215
  254. package/src/store-html.js +0 -0
  255. package/src/storefront/catalog.js +0 -199
  256. package/types/astro.d.ts +0 -28
  257. package/types/barba.d.ts +0 -16
  258. package/types/index.d.ts +0 -577
  259. package/types/server.d.ts +0 -182
package/src/cart/store.js DELETED
@@ -1,326 +0,0 @@
1
- /**
2
- * The cart on this page: what Shopify last said about it (`CartStatus`), the quantities quick clicks asked for, the
3
- * customer it belongs to, and the one queue every change goes through. A change of what the page shows goes through
4
- * here, so the document (`renderCart`) and the listeners (`tether:cart-updated`) always see the store's cart.
5
- */
6
- import { cfg } from '../config.js';
7
- import { emit } from '../dom.js';
8
- import { isOpen } from './drawer.js';
9
- import { renderCart, quantityOf, showSavedCount } from './render.js';
10
- /** @import { CartWarning } from '../config.js' */
11
- /** @import { Image } from '../fragments.js' */
12
- /** @import { Money } from '../shopify-values.js' */
13
-
14
- /** @typedef {{ key: string, value: string }} Attribute */
15
- /**
16
- * @typedef {object} CartVariant A line's `merchandise`: Shopify's `Merchandise` union has only `ProductVariant`.
17
- * @property {string} id
18
- * @property {string} title
19
- * @property {string | null} sku
20
- * @property {boolean} availableForSale
21
- * @property {boolean} requiresShipping
22
- * @property {number | null} [quantityAvailable] Absent when the store's token can't read inventory.
23
- * @property {Image | null} image
24
- * @property {Money} price
25
- * @property {Money | null} compareAtPrice
26
- * @property {{ name: string, value: string }[]} selectedOptions
27
- * @property {{ id: string, title: string, handle: string, vendor: string, featuredImage: Image | null }} product
28
- */
29
- /**
30
- * @typedef {object} LinePlanAllocation
31
- * @property {{ id: string, name: string, description: string | null, recurringDeliveries: boolean,
32
- * options: { name: string, value: string }[] }} sellingPlan
33
- * @property {Money} checkoutChargeAmount
34
- * @property {{ price: Money, compareAtPrice: Money, perDeliveryPrice: Money }[]} priceAdjustments
35
- */
36
- /**
37
- * @typedef {object} ShopifyCartLine
38
- * @property {string} id
39
- * @property {number} quantity
40
- * @property {Attribute[]} attributes
41
- * @property {{ totalAmount: Money, subtotalAmount: Money, amountPerQuantity: Money,
42
- * compareAtAmountPerQuantity: Money | null }} cost
43
- * @property {{ discountedAmount: Money }[]} discountAllocations
44
- * @property {LinePlanAllocation | null} sellingPlanAllocation
45
- * @property {CartVariant} merchandise
46
- * @property {{ id: string, quantity: number, merchandise: { id: string, title: string,
47
- * product: { title: string, handle: string } } }[]} [lineComponents] Only on a bundle (`ComponentizableCartLine`).
48
- */
49
- /**
50
- * @typedef {object} ShopifyCart The fields of `fragment TetherCart`.
51
- * @property {string} id
52
- * @property {string} checkoutUrl
53
- * @property {number} totalQuantity
54
- * @property {string | null} note
55
- * @property {string} createdAt
56
- * @property {string} updatedAt
57
- * @property {Attribute[]} attributes
58
- * @property {{ countryCode: string | null, email: string | null }} buyerIdentity
59
- * @property {{ code: string, applicable: boolean }[]} discountCodes
60
- * @property {{ subtotalAmount: Money, totalAmount: Money, checkoutChargeAmount: Money }} cost
61
- * @property {{ nodes: ShopifyCartLine[] }} lines
62
- */
63
-
64
- /**
65
- * @typedef {{ kind: 'unknown' }
66
- * | { kind: 'empty' }
67
- * | { kind: 'ready', cart: ShopifyCart }
68
- * | { kind: 'error', error: unknown, cart: ShopifyCart | null }} CartStatus
69
- * `unknown` until the saved cart has loaded (the page's own markup shows); `empty` without a cart (none saved, or
70
- * Shopify no longer has it); `error` after a failed load or change, with the cart as Shopify last sent it.
71
- */
72
-
73
- /** @typedef {{ quantity: number }} PendingQuantity What one quick +/- click asked for; a newer click replaces it. */
74
-
75
- /**
76
- * @typedef {object} CustomerLink What the account part sets while a customer is logged in.
77
- * @property {string | null} accessToken The Customer Account API token: carts created now belong to the customer.
78
- * @property {(() => Promise<string | null>) | null} beforeCheckout The account's own checkout URL; `null` from it, or
79
- * no function, uses the cart's.
80
- */
81
-
82
- /**
83
- * @typedef {object} CartMeta The second argument of a `subscribe` listener.
84
- * @property {boolean} open
85
- * @property {number} count
86
- * @property {boolean} loaded
87
- * @property {readonly CartWarning[]} warnings
88
- */
89
-
90
- /** @type {CartStatus} */
91
- let status = { kind: 'unknown' };
92
-
93
- /** @type {readonly CartWarning[]} Shopify's notes on the last change. */
94
- let warnings = [];
95
-
96
- /** @type {Map<string, PendingQuantity>} Line id → what quick +/- clicks asked for, until Shopify has answered. */
97
- export const pendingQuantities = new Map();
98
-
99
- /** @type {CustomerLink} */
100
- export const customer = { accessToken: null, beforeCheckout: null };
101
-
102
- /** @returns {CartStatus} */
103
- export const cartStatus = () => status;
104
-
105
- /** @returns {ShopifyCart | null} The cart as Shopify last sent it. */
106
- export function currentCart() {
107
- switch (status.kind) {
108
- case 'ready':
109
- case 'error':
110
- return status.cart;
111
- case 'unknown':
112
- case 'empty':
113
- return null;
114
- }
115
- }
116
-
117
- /** @returns {readonly CartWarning[]} */
118
- export const currentWarnings = () => warnings;
119
-
120
- /**
121
- * @param {ShopifyCart | null} cart
122
- * @returns {CartStatus}
123
- */
124
- const statusOf = (cart) => (cart ? { kind: 'ready', cart } : { kind: 'empty' });
125
-
126
- const seenKey = () => `${cfg.storageKey}:seen`;
127
-
128
- /**
129
- * localStorage throws in private mode and where the visitor blocks site data: the cart then lasts for this page.
130
- *
131
- * @param {string} key
132
- * @returns {string | null}
133
- */
134
- function readStorageIfAllowed(key) {
135
- try {
136
- return localStorage.getItem(key);
137
- } catch {
138
- return null;
139
- }
140
- }
141
-
142
- /**
143
- * Like `readStorageIfAllowed`: without storage, nothing is remembered.
144
- *
145
- * @param {string} key
146
- * @param {string | null} value `null` removes the key.
147
- */
148
- function writeStorageIfAllowed(key, value) {
149
- try {
150
- if (value === null) localStorage.removeItem(key);
151
- else localStorage.setItem(key, value);
152
- } catch {
153
- // Private mode or blocked storage: see readStorageIfAllowed.
154
- }
155
- }
156
-
157
- /** @returns {string | null} The id of the visitor's cart, kept across pages. */
158
- export const savedCartId = () => readStorageIfAllowed(cfg.storageKey) || null;
159
-
160
- /** Forgets the saved cart, which Shopify no longer has. */
161
- export function forgetSavedCart() {
162
- writeStorageIfAllowed(cfg.storageKey, null);
163
- }
164
-
165
- /**
166
- * Keeps a cart Shopify sent without showing it, for an answer a newer change will replace.
167
- *
168
- * @param {ShopifyCart | null} cart
169
- */
170
- export function keepQuietly(cart) {
171
- status = statusOf(cart);
172
- if (cart) writeStorageIfAllowed(cfg.storageKey, cart.id);
173
- }
174
-
175
- /** Renders the document with the store's cart. */
176
- export function render() {
177
- renderCart({
178
- cart: currentCart(),
179
- warnings,
180
- pending: pendingQuantities,
181
- failed: status.kind === 'error',
182
- });
183
- }
184
-
185
- /**
186
- * @param {string} reason The `reason` of `tether:cart-updated`.
187
- */
188
- export function announce(reason) {
189
- render();
190
- emit('cart-updated', { cart: currentCart(), reason });
191
- }
192
-
193
- /**
194
- * Keeps a cart Shopify sent after a change, shows it, and tells this page and the visitor's other tabs.
195
- *
196
- * @param {ShopifyCart | null} cart
197
- * @param {string} reason
198
- * @returns {ShopifyCart | null} `cart`.
199
- */
200
- export function commit(cart, reason) {
201
- keepQuietly(cart);
202
- // Other tabs reload the cart on this storage event; the next page shows the count before its cart has loaded.
203
- writeStorageIfAllowed(seenKey(), `${Date.now()}|${cart?.totalQuantity ?? 0}`);
204
- announce(reason);
205
- return cart;
206
- }
207
-
208
- /**
209
- * The outcome of loading the saved cart: the cart, `null` when there is none, or the failure.
210
- *
211
- * @param {{ kind: 'loaded', cart: ShopifyCart | null } | { kind: 'failed', error: unknown }} outcome
212
- */
213
- export function settleLoad(outcome) {
214
- status = outcome.kind === 'loaded' ? statusOf(outcome.cart) : { kind: 'error', error: outcome.error, cart: null };
215
- announce('load');
216
- }
217
-
218
- /**
219
- * Sets or clears the failure without rendering, for `Tether.cart.lastError`, which the public types declare writable.
220
- *
221
- * @param {unknown} error `null` or `undefined` clears it.
222
- */
223
- export function setFailure(error) {
224
- const cart = currentCart();
225
- status = error == null ? statusOf(cart) : { kind: 'error', error, cart };
226
- }
227
-
228
- /** Until the saved cart has loaded, the counts show the count it had on the last page instead of the page's 0. */
229
- export function showLastKnownCount() {
230
- if (status.kind !== 'unknown') return;
231
- const count = readStorageIfAllowed(seenKey())?.split('|')[1];
232
- if (count) showSavedCount(count);
233
- }
234
-
235
- /**
236
- * Sets Shopify's notes on the last change: each message once, in the site's words (`warningText`).
237
- *
238
- * @param {readonly CartWarning[]} notes
239
- */
240
- export function setWarnings(notes) {
241
- warnings = notes
242
- .filter((note, index) => notes.findIndex((other) => other.message === note.message) === index)
243
- .map((note) => ({ ...note, message: cfg.warningText?.(note) || note.message }));
244
- if (warnings.length) emit('cart-warning', { warnings });
245
- }
246
-
247
- /** The queue only waits for a task: its result, or failure, reaches the caller through the promise `run` returns. */
248
- const waitOnly = () => undefined;
249
-
250
- /**
251
- * @returns {<T>(task: () => Promise<T>) => Promise<T>} A runner that starts each task once the one before has
252
- * settled, whether it succeeded or failed.
253
- */
254
- export function serial() {
255
- /** @type {Promise<unknown>} */
256
- let previous = Promise.resolve();
257
- return (task) => {
258
- const run = previous.then(task);
259
- previous = run.then(waitOnly, waitOnly);
260
- return run;
261
- };
262
- }
263
-
264
- const runInOrder = serial();
265
-
266
- /**
267
- * Runs a cart change after the ones before it, so quick clicks never race. A failure sets the `error` status (shown by
268
- * `tether-show="cart-error"` until the next change succeeds), emits `tether:cart-error` and rejects.
269
- *
270
- * @template T
271
- * @param {() => Promise<T>} task
272
- * @returns {Promise<T>}
273
- */
274
- export function enqueue(task) {
275
- return runInOrder(task).then(
276
- (value) => {
277
- if (status.kind === 'error') {
278
- status = statusOf(status.cart);
279
- render();
280
- }
281
- return value;
282
- },
283
- (/** @type {unknown} */ error) => {
284
- status = { kind: 'error', error, cart: currentCart() };
285
- render();
286
- emit('cart-error', { error });
287
- throw error;
288
- },
289
- );
290
- }
291
-
292
- /**
293
- * @param {ShopifyCartLine} line
294
- * @returns {number} The quantity to show for `line`.
295
- */
296
- export const pendingQuantityOf = (line) => quantityOf(line, pendingQuantities);
297
-
298
- /** @returns {CartMeta} */
299
- const meta = () => ({
300
- open: isOpen(),
301
- count: currentCart()?.totalQuantity ?? 0,
302
- loaded: status.kind !== 'unknown',
303
- warnings,
304
- });
305
-
306
- /**
307
- * Calls `listener` now and after every change of the cart or the drawer.
308
- *
309
- * @param {(cart: ShopifyCart | null, meta: CartMeta) => void} listener
310
- * @returns {() => void} Stops listening.
311
- */
312
- export function subscribe(listener) {
313
- const events = ['cart-updated', 'cart-toggled'].map((name) => `${cfg.prefix}:${name}`);
314
- const call = () => {
315
- listener(currentCart(), meta());
316
- };
317
- events.forEach((name) => {
318
- document.addEventListener(name, call);
319
- });
320
- call();
321
- return () => {
322
- events.forEach((name) => {
323
- document.removeEventListener(name, call);
324
- });
325
- };
326
- }
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
- }