@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/loader.js DELETED
@@ -1,337 +0,0 @@
1
- /**
2
- * Product data for the browser script. Products asked for in the same tick go out as one request, and every product
3
- * is cached for the life of the page, which survives Barba/Swup page swaps. Products the server rendered
4
- * (`tether/server` `hydrate()`) are used at once and refreshed once in the background.
5
- */
6
- import { cfg, log } from './config.js';
7
- import { gql, TetherError } from './client.js';
8
- import { productFragment, collectionFields } from './fragments.js';
9
- import { productKey } from './shopify-values.js';
10
- /** @import { Product, Image } from './fragments.js' */
11
-
12
- /**
13
- * A product request that missed is remembered this long, so one page asking twice sends one request, and a product
14
- * published in Shopify meanwhile shows up on the next visit.
15
- */
16
- const MISS_CACHE_MS = 10_000;
17
- /** A product loaded live this recently is newer than a server-rendered copy of it, which is then ignored. */
18
- const FRESH_MS = 60_000;
19
-
20
- /** @type {Map<string, Promise<Product>>} Handle or gid to the product, loaded or loading. */
21
- const productCache = new Map();
22
- /** @type {Map<string, { resolve: (product: Product) => void, reject: (error: unknown) => void }>} */
23
- const waiting = new Map();
24
- /** @type {Set<string>} Keys of products that came from the server and weren't refreshed yet. */
25
- const stale = new Set();
26
- /** @type {Map<string, number>} Handle or gid to when it last loaded live, in ms since the epoch. */
27
- const freshAt = new Map();
28
- /** @type {string[]} Keys for the next batch. */
29
- const pending = [];
30
- let flushScheduled = false;
31
- /**
32
- * Bumped by `clearProductCache()`: responses to requests sent before that (in the old market, after a country
33
- * switch) still reach their callers but never refill the cache.
34
- */
35
- let generation = 0;
36
-
37
- /** @param {string} key */
38
- const isGid = (key) => key.startsWith('gid://');
39
-
40
- /** Accepts a handle, numeric id, gid or a full product URL. */
41
- export const normalizeProductKey = productKey;
42
-
43
- /**
44
- * Puts a product the page already has (from a collection query) in the cache, under its handle and its id.
45
- *
46
- * @param {Product | null | undefined} product
47
- */
48
- export function primeProduct(product) {
49
- if (!product) return;
50
- const loaded = Promise.resolve(product);
51
- productCache.set(product.handle, loaded);
52
- productCache.set(product.id, loaded);
53
- }
54
-
55
- /**
56
- * @param {string | number | null | undefined} rawKey A handle, numeric id, gid or product URL.
57
- * @returns {Promise<Product>}
58
- * @throws {TetherError} `NOT_FOUND` (rejected) for an empty key or a product the store doesn't have.
59
- */
60
- export function getProduct(rawKey) {
61
- const key = normalizeProductKey(rawKey);
62
- if (!key) return Promise.reject(new TetherError('empty product key', { code: 'NOT_FOUND' }));
63
- const cached = productCache.get(key);
64
- if (cached) return cached;
65
-
66
- /** @type {Promise<Product>} */
67
- const loading = new Promise((resolve, reject) => {
68
- waiting.set(key, { resolve, reject });
69
- pending.push(key);
70
- if (!flushScheduled) {
71
- flushScheduled = true;
72
- setTimeout(() => void flush(), 0);
73
- }
74
- });
75
- productCache.set(key, loading);
76
- return loading;
77
- }
78
-
79
- /**
80
- * @param {string[]} keys Handles and gids.
81
- * @returns {{ query: string, variables: Record<string, unknown>, handles: string[] }} One query for all of them: gids
82
- * through `nodes(ids:)` as `byId`, each handle as its own alias `h<index>`.
83
- */
84
- function batchQuery(keys) {
85
- const ids = keys.filter(isGid);
86
- const handles = keys.filter((key) => !isGid(key));
87
- const definitions = [];
88
- const selections = [];
89
- /** @type {Record<string, unknown>} */
90
- const variables = {};
91
- if (ids.length) {
92
- definitions.push('$ids: [ID!]!');
93
- variables['ids'] = ids;
94
- selections.push('byId: nodes(ids: $ids) { ... on Product { ...TetherProduct } }');
95
- }
96
- handles.forEach((handle, index) => {
97
- definitions.push(`$h${index}: String!`);
98
- variables[`h${index}`] = handle;
99
- selections.push(`h${index}: product(handle: $h${index}) { ...TetherProduct }`);
100
- });
101
- const query = `query TetherProducts(${definitions.join(',')}) { ${selections.join(' ')} } ${productFragment()}`;
102
- return { query, variables, handles };
103
- }
104
-
105
- /**
106
- * @param {string} key
107
- * @param {(waiter: { resolve: (product: Product) => void, reject: (error: unknown) => void }) => void} settle
108
- */
109
- function settleWaiting(key, settle) {
110
- const waiter = waiting.get(key);
111
- waiting.delete(key);
112
- if (waiter) settle(waiter);
113
- }
114
-
115
- /**
116
- * @param {string} key
117
- * @param {number} batchGeneration
118
- */
119
- function forgetMissLater(key, batchGeneration) {
120
- if (batchGeneration !== generation) return;
121
- const miss = productCache.get(key);
122
- setTimeout(() => {
123
- if (productCache.get(key) === miss) productCache.delete(key);
124
- }, MISS_CACHE_MS);
125
- }
126
-
127
- async function flush() {
128
- flushScheduled = false;
129
- const batchGeneration = generation;
130
- const keys = pending.splice(0);
131
- if (!keys.length) return;
132
- const { query, variables, handles } = batchQuery(keys);
133
- log('fetch products', keys);
134
-
135
- /** @type {Record<string, Product | null> & { byId?: (Product | null)[] }} */
136
- let data;
137
- try {
138
- data = await gql(query, variables);
139
- } catch (error) {
140
- keys.forEach((key) => {
141
- settleWaiting(key, (waiter) => {
142
- if (batchGeneration === generation) productCache.delete(key);
143
- waiter.reject(error);
144
- });
145
- });
146
- return;
147
- }
148
-
149
- /** @type {Map<string, Product>} */
150
- const found = new Map();
151
- for (const product of data.byId ?? []) if (product) found.set(product.id, product);
152
- handles.forEach((handle, index) => {
153
- const product = data[`h${index}`];
154
- if (product) found.set(handle, product);
155
- });
156
-
157
- const now = Date.now();
158
- keys.forEach((key) => {
159
- settleWaiting(key, (waiter) => {
160
- const product = found.get(key);
161
- if (!product) {
162
- forgetMissLater(key, batchGeneration);
163
- waiter.reject(new TetherError(`Product not found: ${key}`, { code: 'NOT_FOUND' }));
164
- return;
165
- }
166
- if (batchGeneration === generation) {
167
- primeProduct(product);
168
- freshAt.set(product.handle, now);
169
- freshAt.set(product.id, now);
170
- }
171
- waiter.resolve(product);
172
- });
173
- });
174
- }
175
-
176
- /** Forgets every product, for a new market: requests already sent no longer fill the cache. */
177
- export function clearProductCache() {
178
- productCache.clear();
179
- generation += 1;
180
- stale.clear();
181
- freshAt.clear();
182
- }
183
-
184
- /**
185
- * Uses products the server rendered, unless the page loaded the same product live in the last `FRESH_MS` (an earlier
186
- * page of a View Transition visit), whose copy is newer.
187
- *
188
- * @param {readonly Product[] | null | undefined} products
189
- */
190
- export function hydrateProducts(products) {
191
- for (const product of products ?? []) {
192
- if (!product.id || !product.handle) continue;
193
- const loadedAgo = Date.now() - (freshAt.get(product.handle) ?? 0);
194
- if (loadedAgo < FRESH_MS && productCache.has(product.handle)) continue;
195
- primeProduct(product);
196
- stale.add(product.handle);
197
- stale.add(product.id);
198
- }
199
- }
200
-
201
- /**
202
- * @param {string | number | null | undefined} rawKey
203
- * @returns {boolean} True once per server-rendered product: the caller then refreshes it.
204
- */
205
- export function takeStale(rawKey) {
206
- const key = normalizeProductKey(rawKey);
207
- if (!key || !stale.has(key)) return false;
208
- stale.delete(key);
209
- // The handle and the id point to the same product: one refresh covers both.
210
- void productCache.get(key)?.then((product) => {
211
- stale.delete(product.handle);
212
- stale.delete(product.id);
213
- });
214
- return true;
215
- }
216
-
217
- /**
218
- * Loads these products again, past the cache.
219
- *
220
- * @param {readonly (string | number)[]} keys
221
- * @returns {Promise<Product>[]}
222
- */
223
- export function refreshProducts(keys) {
224
- for (const key of keys) {
225
- const normalized = normalizeProductKey(key);
226
- if (normalized) productCache.delete(normalized);
227
- }
228
- return keys.map((key) => getProduct(key));
229
- }
230
-
231
- /** @typedef {{ hasNextPage: boolean, endCursor: string | null }} PageInfo */
232
- /**
233
- * @typedef {object} Collection
234
- * @property {string} id
235
- * @property {string} handle
236
- * @property {string} title
237
- * @property {string} description
238
- * @property {string} descriptionHtml
239
- * @property {Image | null} image
240
- * @property {{ title: string | null, description: string | null }} seo
241
- */
242
- /** @typedef {{ collection: Collection | null, products: Product[], pageInfo: PageInfo }} ProductPage */
243
- /**
244
- * @typedef {object} ProductQuery
245
- * @property {string} [collection] A collection handle; without one, a search.
246
- * @property {string} [query] Storefront search syntax, for a search.
247
- * @property {number} [first]
248
- * @property {string | null} [after] The cursor of the page before.
249
- * @property {string} [sortKey] `ProductCollectionSortKeys` for a collection, `ProductSortKeys` for a search.
250
- * @property {boolean} [reverse]
251
- * @property {unknown[]} [filters] `ProductFilter` values, for a collection.
252
- */
253
-
254
- const COLLECTION_QUERY = `query TetherCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
255
- collection(handle: $handle) {
256
- ${collectionFields()}
257
- products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
258
- nodes { ...TetherProduct }
259
- pageInfo { hasNextPage endCursor }
260
- }
261
- }
262
- }`;
263
-
264
- const SEARCH_QUERY = `query TetherSearch($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
265
- products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
266
- nodes { ...TetherProduct }
267
- pageInfo { hasNextPage endCursor }
268
- }
269
- }`;
270
-
271
- /**
272
- * A page of products from a collection (by handle) or from a search. Every product on it is cached.
273
- *
274
- * @param {ProductQuery} [options]
275
- * @returns {Promise<ProductPage>}
276
- * @throws {TetherError} `NOT_FOUND` for a collection the store doesn't have.
277
- */
278
- export async function getProducts({
279
- collection,
280
- query,
281
- first = cfg.collectionFirst,
282
- after = null,
283
- sortKey,
284
- reverse = false,
285
- filters,
286
- } = {}) {
287
- const fragment = productFragment();
288
- if (collection) {
289
- /** @type {{ collection: (Collection & { products: { nodes: Product[], pageInfo: PageInfo } }) | null }} */
290
- const data = await gql(`${COLLECTION_QUERY}\n${fragment}`, {
291
- handle: collection,
292
- first,
293
- after,
294
- sortKey: sortKey || undefined,
295
- reverse,
296
- filters: filters ?? undefined,
297
- });
298
- if (!data.collection) throw new TetherError(`Collection not found: ${collection}`, { code: 'NOT_FOUND' });
299
- const { products, ...rest } = data.collection;
300
- products.nodes.forEach(primeProduct);
301
- return { collection: rest, products: products.nodes, pageInfo: products.pageInfo };
302
- }
303
-
304
- /** @type {{ products: { nodes: Product[], pageInfo: PageInfo } }} */
305
- const data = await gql(`${SEARCH_QUERY}\n${fragment}`, {
306
- first,
307
- after,
308
- query: query || undefined,
309
- sortKey: sortKey || undefined,
310
- reverse,
311
- });
312
- data.products.nodes.forEach(primeProduct);
313
- return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
314
- }
315
-
316
- /**
317
- * Shopify's recommendations for a product. Every product in them is cached.
318
- *
319
- * @param {string | number | { id: string } | null | undefined} product A product, or its handle, id or gid.
320
- * @param {'RELATED' | 'COMPLEMENTARY'} [intent]
321
- * @returns {Promise<Product[]>}
322
- */
323
- export async function getRecommendations(product, intent = 'RELATED') {
324
- const key = normalizeProductKey(typeof product === 'object' ? product?.id : product);
325
- const byId = key?.startsWith('gid://') ?? false;
326
- /** @type {{ productRecommendations: Product[] | null }} */
327
- const data = await gql(
328
- `query TetherRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
329
- productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
330
- }
331
- ${productFragment()}`,
332
- byId ? { id: key, intent } : { handle: key, intent },
333
- );
334
- const list = data.productRecommendations ?? [];
335
- list.forEach(primeProduct);
336
- return list;
337
- }
@@ -1,35 +0,0 @@
1
- /**
2
- * Country and language codes as Shopify's `@inContext` takes them. Every market value that reaches a query passes
3
- * through here: they are written into the query text, so anything else is dropped rather than sent.
4
- */
5
-
6
- /** @typedef {string & { readonly __brand: 'CountryCode' }} CountryCode An ISO 3166-1 alpha-2 code, upper case. */
7
- /** @typedef {string & { readonly __brand: 'LanguageCode' }} LanguageCode A Shopify `LanguageCode` value. */
8
-
9
- const COUNTRY = /^[A-Z]{2}$/;
10
- /** Cloudflare's codes for an unknown country (XX) and Tor (T1), which a `cf-ipcountry` cookie can carry. */
11
- const NOT_A_COUNTRY = new Set(['XX', 'T1']);
12
- /** Shopify `LanguageCode` values with a region. Every other language is its plain ISO 639 code. */
13
- const REGIONAL_LANGUAGES = new Set(['PT_BR', 'PT_PT', 'ZH_CN', 'ZH_TW']);
14
- const LANGUAGE = /^[A-Z]{2,3}$/;
15
-
16
- /**
17
- * @param {unknown} value
18
- * @returns {CountryCode | null} `'de'` → `'DE'`; `null` for anything that is not a country code.
19
- */
20
- export function toCountry(value) {
21
- const code = (typeof value === 'string' ? value : '').trim().toUpperCase();
22
- if (!COUNTRY.test(code) || NOT_A_COUNTRY.has(code)) return null;
23
- return /** @type {CountryCode} */ (code);
24
- }
25
-
26
- /**
27
- * @param {unknown} value
28
- * @returns {LanguageCode | null} `'de-DE'` → `'DE'`, `'pt-BR'` → `'PT_BR'`, `'en'` → `'EN'`; `null` otherwise.
29
- */
30
- export function toLanguage(value) {
31
- const raw = (typeof value === 'string' ? value : '').trim().replace('-', '_').toUpperCase();
32
- if (REGIONAL_LANGUAGES.has(raw)) return /** @type {LanguageCode} */ (raw);
33
- const primary = raw.split('_')[0] ?? '';
34
- return LANGUAGE.test(primary) ? /** @type {LanguageCode} */ (primary) : null;
35
- }
package/src/markets-ui.js DELETED
@@ -1,204 +0,0 @@
1
- /**
2
- * Optional part, loaded on first use: market detection, `select[tether-country-select]`, `tether-country-bind` and
3
- * `Tether.setCountry()`. See markets.js for the detection rules.
4
- */
5
- import { cfg, reportError } from './config.js';
6
- import { TetherError } from './client.js';
7
- import { Cart } from './cart.js';
8
- import { listSelector } from './collection.js';
9
- import { clearProductCache } from './loader.js';
10
- import { attr, sel, get, emit } from './dom.js';
11
- import { within, pruneLive, liveLists, liveProducts, mountedProducts, mountProduct } from './mounting.js';
12
- import { rememberMarket, getLocalization, clearLocalization } from './markets.js';
13
- import { toCountry, toLanguage } from './market-codes.js';
14
- /** @import { LocalizedCountry, Localization } from './markets.js' */
15
- /** @import { CountryCode } from './market-codes.js' */
16
-
17
- export { detectMarket, getLocalization } from './markets.js';
18
-
19
- const DEFAULT_LABEL = '{name} ({currency})';
20
- const DEFAULT_BIND = 'name';
21
-
22
- /**
23
- * @typedef {object} SetCountryOptions
24
- * @property {string | null | undefined} [language] A Shopify language; `null` clears it. Default: the
25
- * `markets.locales` entry for the country, else unchanged.
26
- * @property {string | undefined} [locale] The money format. Default: the `markets.locales` entry, else unchanged.
27
- * @property {boolean | undefined} [remember] Save the choice for the next pages (default true).
28
- */
29
-
30
- /**
31
- * @param {LocalizedCountry} country
32
- * @param {string} field A `tether-country-bind` value or a label placeholder: `name`, `code`, `currency`, `symbol`.
33
- * @returns {string} `''` for any other field.
34
- */
35
- function countryField(country, field) {
36
- switch (field) {
37
- case 'name':
38
- return country.name;
39
- case 'code':
40
- return country.isoCode;
41
- case 'currency':
42
- return country.currency.isoCode;
43
- case 'symbol':
44
- return country.currency.symbol;
45
- default:
46
- return '';
47
- }
48
- }
49
-
50
- /**
51
- * @param {string} template `tether-country-label`, e.g. `{name} ({currency})`.
52
- * @param {LocalizedCountry} country
53
- */
54
- const fillLabel = (template, country) =>
55
- template.replace(/\{(name|code|currency|symbol)\}/g, (_match, /** @type {string} */ field) =>
56
- countryField(country, field),
57
- );
58
-
59
- /**
60
- * @param {HTMLSelectElement} select
61
- * @param {LocalizedCountry[]} countries The store's countries, sorted by name.
62
- * @param {string} current The country in use.
63
- */
64
- function fillCountrySelect(select, countries, current) {
65
- const template = select.getAttribute(attr('country-label')) || DEFAULT_LABEL;
66
- // tether-countries="NL,DE,BE": only these, in that order; otherwise every country of the store.
67
- const only = (select.getAttribute(attr('countries')) || '')
68
- .toUpperCase()
69
- .split(/[\s,]+/)
70
- .filter(Boolean);
71
- const listed = only.length
72
- ? only
73
- .map((code) => countries.find((country) => country.isoCode === code))
74
- .filter((country) => country !== undefined)
75
- : countries;
76
- select.replaceChildren(...listed.map((country) => new Option(fillLabel(template, country), country.isoCode)));
77
- select.value = current;
78
- // A country the store doesn't sell to selects nothing.
79
- if (select.value !== current) select.selectedIndex = -1;
80
- select.disabled = listed.length < 2 && !select.hasAttribute(attr('always-enabled'));
81
- }
82
-
83
- /**
84
- * @param {HTMLSelectElement[]} selects
85
- * @param {HTMLElement[]} binds
86
- * @param {Localization} localization
87
- */
88
- function fillCountries(selects, binds, localization) {
89
- const current = (cfg.country || localization.country.isoCode).toUpperCase();
90
- const countries = [...localization.availableCountries].sort((a, b) =>
91
- a.name.localeCompare(b.name, cfg.locale || undefined),
92
- );
93
- const inUse = countries.find((country) => country.isoCode === current) ?? localization.country;
94
- selects.forEach((select) => {
95
- fillCountrySelect(select, countries, current);
96
- });
97
- binds.forEach((el) => {
98
- el.textContent = countryField(inUse, get(el, 'country-bind') || DEFAULT_BIND);
99
- });
100
- }
101
-
102
- /**
103
- * Fills `select[tether-country-select]` with the countries the store sells to (Shopify Markets), and
104
- * `tether-country-bind="name|code|currency|symbol"` with the current country. `tether-country-label` sets the option
105
- * text from `{name}`, `{code}`, `{currency}` and `{symbol}`. A failure is reported, never thrown.
106
- *
107
- * @param {ParentNode} [root]
108
- * @returns {Promise<void>}
109
- */
110
- export function renderCountrySelects(root = document) {
111
- const selects = /** @type {HTMLSelectElement[]} */ (within(root, `select${sel('country-select')}`));
112
- const binds = within(root, sel('country-bind'));
113
- if (!selects.length && !binds.length) return Promise.resolve();
114
- return getLocalization()
115
- .then((localization) => {
116
- fillCountries(selects, binds, localization);
117
- })
118
- .catch((/** @type {unknown} */ error) => {
119
- reportError(error, 'country select');
120
- });
121
- }
122
-
123
- /**
124
- * @param {CountryCode} country
125
- * @returns {{ country?: string, language?: string, locale?: string } | undefined} The `markets.locales` entry for it.
126
- */
127
- const localeEntryFor = (country) =>
128
- Object.values(cfg.markets?.locales ?? {}).find((entry) => toCountry(entry.country) === country);
129
-
130
- /**
131
- * @param {string | null | undefined} language The `setCountry()` option.
132
- * @param {{ language?: string } | undefined} entry The `markets.locales` entry for the country.
133
- * @returns {string | null | undefined} The option, else the entry's language (which may have none), else unchanged.
134
- */
135
- function languageFor(language, entry) {
136
- if (language !== undefined) return toLanguage(language);
137
- if (!entry) return cfg.language;
138
- return entry.language ? toLanguage(entry.language) : undefined;
139
- }
140
-
141
- /**
142
- * Loads every live product and list again in the market `cfg` now holds, and moves an existing cart to `country`.
143
- *
144
- * @param {CountryCode} country
145
- * @returns {Promise<unknown>[]} The cart update and the list loads.
146
- */
147
- function rerenderInMarket(country) {
148
- clearProductCache();
149
- /** @type {Promise<unknown>[]} */
150
- const jobs = [];
151
- // Only an existing cart moves; switching country never creates one.
152
- if (Cart.id()) {
153
- jobs.push(
154
- Cart.setBuyerIdentity({ countryCode: country }, { create: false }).catch((/** @type {unknown} */ error) => {
155
- reportError(error, 'cart country');
156
- }),
157
- );
158
- }
159
- pruneLive();
160
- liveLists.forEach((list) => jobs.push(list.load()));
161
- liveProducts.forEach((el) => {
162
- // A product inside a list is re-rendered by its list.
163
- if (el.closest(listSelector())) return;
164
- mountedProducts.delete(el);
165
- mountProduct(el);
166
- });
167
- return jobs;
168
- }
169
-
170
- /**
171
- * Switches the market: prices, currency and translations follow (`@inContext`), the cart's
172
- * `buyerIdentity.countryCode` is updated, and every product and list on the page re-renders.
173
- *
174
- * Tether.setCountry('DE') Tether.setCountry('BE', { language: 'FR' })
175
- *
176
- * @param {string} code
177
- * @param {SetCountryOptions} [options]
178
- * @returns {Promise<typeof Cart.state>} The cart once everything is in the new market.
179
- * @throws {TetherError} `INVALID_COUNTRY` for anything that isn't a country code.
180
- */
181
- export async function setCountry(code, { language, locale, remember = true } = {}) {
182
- const country = toCountry(code);
183
- if (!country) throw new TetherError(`Not a country code: ${code}`, { code: 'INVALID_COUNTRY' });
184
- const entry = localeEntryFor(country);
185
- const ownLocale = locale || entry?.locale;
186
- const nextLocale = ownLocale || cfg.locale;
187
- const nextLanguage = languageFor(language, entry);
188
- const changed = country !== cfg.country?.toUpperCase() || nextLanguage !== cfg.language || nextLocale !== cfg.locale;
189
- cfg.country = country;
190
- cfg.language = nextLanguage || undefined;
191
- cfg.locale = nextLocale;
192
- if (remember) rememberMarket({ country, language: nextLanguage, locale: ownLocale });
193
- if (!changed) {
194
- void renderCountrySelects();
195
- return Cart.state;
196
- }
197
- // Country names follow the language.
198
- if (language !== undefined) clearLocalization();
199
- const jobs = rerenderInMarket(country);
200
- void renderCountrySelects();
201
- emit('country-changed', { country, language: cfg.language });
202
- await Promise.all(jobs);
203
- return Cart.state;
204
- }