@occluze/tether 0.6.6 → 0.6.8

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 (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +776 -432
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +655 -321
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +334 -243
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3171 -1998
  36. package/dist/tether.markets.js +231 -126
  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 +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +68 -284
  58. package/src/account.js +281 -317
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +322 -481
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -285
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
  120. package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
  121. package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
  122. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  123. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  124. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  125. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  126. package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
  127. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  128. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  129. package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
  130. package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
  131. package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
  132. package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
  133. package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
  134. package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/client.js CHANGED
@@ -1,35 +1,79 @@
1
1
  /**
2
- * Storefront API client: one fetch wrapper with @inContext, retries and
3
- * readable errors. Exposed as Tether.query() for anything Tether doesn't cover.
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.
4
5
  */
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
+ */
6
25
 
7
26
  export class TetherError extends Error {
27
+ /**
28
+ * @param {string} message
29
+ * @param {TetherErrorDetails} [details]
30
+ */
8
31
  constructor(message, { status, errors, userErrors, code } = {}) {
9
32
  super(message);
10
33
  this.name = 'TetherError';
11
- this.code = code || (status ? `HTTP_${status}` : 'ERROR');
34
+ /** @type {TetherErrorCode} */
35
+ this.code = code ?? (status ? `HTTP_${status}` : 'ERROR');
36
+ /** @type {number | undefined} */
12
37
  this.status = status;
38
+ /** @type {GraphQLError[] | undefined} */
13
39
  this.errors = errors;
40
+ /** @type {readonly UserError[] | undefined} */
14
41
  this.userErrors = userErrors;
15
42
  }
16
43
  }
17
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
+ */
18
57
  export function endpoint(conf = cfg) {
19
58
  return `https://${conf.domain}/api/${conf.apiVersion}/graphql.json`;
20
59
  }
21
60
 
22
61
  /**
23
- * Adds @inContext(country, language) to the first operation in the document,
24
- * so prices, currency and translations follow the configured market.
25
- * Operations are always written first, fragments after, so the first match is
26
- * the operation.
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}
27
69
  */
28
70
  export function withContext(query, ctx = cfg) {
29
71
  const args = [];
30
- if (ctx.country) args.push(`country: ${String(ctx.country).toUpperCase()}`);
31
- if (ctx.language) args.push(`language: ${String(ctx.language).toUpperCase().replace('-', '_')}`);
32
- if (!args.length || /@inContext/.test(query)) return query;
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;
33
77
 
34
78
  const tag = `@inContext(${args.join(', ')})`;
35
79
  // Named (query Name($x: T)), anonymous (query { … }) and shorthand ({ … }) queries.
@@ -37,95 +81,187 @@ export function withContext(query, ctx = cfg) {
37
81
  return query.replace(/^(\s*(?:query|mutation)(?:\s+\w+)?\s*(?:\([^)]*\))?)/, `$1 ${tag}`);
38
82
  }
39
83
 
40
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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();
41
100
 
42
- /** What a status means, for errors Shopify sends without a message (401/403 have none). */
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
+ */
43
113
  function statusText(status, conf) {
44
114
  if (status === 401 || status === 403) {
45
- return `Shopify rejected the Storefront token for ${conf.domain} (${status}): use the public Storefront API token (Headless channel)${/^shpat_/.test(conf.token) ? ', not an Admin API token (shpat_…)' : ''}`;
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}`;
46
117
  }
47
- return status === 404 ? `Store or API version not found (404): check domain ${conf.domain} and apiVersion ${conf.apiVersion}` : `HTTP ${status} from ${conf.domain}`;
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}`;
48
122
  }
49
123
 
50
124
  /**
51
- * One Storefront API request with retries, for any config object (the browser's shared config,
52
- * or the server module's own). gql() below is the browser version.
125
+ * @param {Response} res
126
+ * @param {GraphQLError[]} errors
127
+ * @param {StorefrontConfig} conf
53
128
  */
54
- export async function request(conf, query, variables = {}, { context = true } = {}) {
55
- if (!conf.domain || !conf.token) {
56
- throw new TetherError('Tether is not configured: domain and token are required', { code: 'NOT_CONFIGURED' });
57
- }
58
-
59
- // Shopify caches the response of an identical query text (prices included, for hours after a
60
- // change). A time bucket in a comment keeps answers at most maxAge seconds old (default 60).
61
- let text = context ? withContext(query, conf) : query;
62
- const age = conf.maxAge ?? 60;
63
- if (age > 0 && !/^\s*mutation\b/.test(text)) text += ` #${Math.floor(Date.now() / (age * 1000))}`;
64
- const body = JSON.stringify({ query: text, variables });
65
- let attempt = 0;
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
+ }
66
138
 
67
- for (;;) {
68
- let res;
69
- try {
70
- res = await (conf.fetch || fetch)(endpoint(conf), {
71
- method: 'POST',
72
- headers: {
73
- 'Content-Type': 'application/json',
74
- 'X-Shopify-Storefront-Access-Token': conf.token,
75
- },
76
- body,
77
- });
78
- } catch (err) {
79
- if (attempt++ < conf.retries) { await sleep(300 * 2 ** attempt); continue; }
80
- throw new TetherError(`Network error: ${err.message}`, { code: 'NETWORK' });
81
- }
139
+ /**
140
+ * @typedef {{ kind: 'data', data: unknown }
141
+ * | { kind: 'retry', backoffMs: number }
142
+ * | { kind: 'inventory-refused' }} Attempt
143
+ */
82
144
 
83
- if (res.status === 430) throw new TetherError('Shopify security rejection (430)', { status: 430 });
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
+ }
84
174
 
85
- const retryable = res.status === 429 || res.status >= 500;
86
- if (retryable && attempt++ < conf.retries) {
87
- log('retry', res.status, attempt);
88
- await sleep(300 * 2 ** attempt);
89
- continue;
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' };
90
188
  }
91
-
92
- let json;
93
- try { json = await res.json(); } catch {
94
- throw new TetherError(res.ok ? `Invalid response (${res.status})` : statusText(res.status, conf), { status: res.status });
189
+ if (canRetry && errors.some((error) => error.extensions?.code === 'THROTTLED')) {
190
+ return { kind: 'retry', backoffMs: THROTTLE_BACKOFF_MS };
95
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
+ }
96
197
 
97
- if (json.errors?.length) {
98
- // Token without unauthenticated_read_product_inventory: drop the field and retry.
99
- const inventoryDenied = json.errors.some((e) => /quantityAvailable/.test(e.message || ''));
100
- if (inventoryDenied && /\bquantityAvailable\b/.test(body)) {
101
- log('no inventory permission, retrying without quantityAvailable');
102
- return request(conf, query.replace(/\bquantityAvailable\b/g, ''), variables, { context });
103
- }
104
- // An older apiVersion without cart warnings (before 2024-07): retry without them.
105
- if (json.errors.some((e) => /'warnings'/.test(e.message || '')) && /warnings \{ code message target \}/.test(query)) {
106
- return request(conf, query.replace(/ warnings \{ code message target \}/g, ''), variables, { context });
107
- }
108
- const throttled = json.errors.some((e) => e.extensions?.code === 'THROTTLED');
109
- if (throttled && attempt++ < conf.retries) { await sleep(500 * 2 ** attempt); continue; }
110
- const message = json.errors.map((e) => e.message).filter(Boolean).join(' | ');
111
- throw new TetherError(res.ok ? message || 'Storefront API error' : (res.status === 401 || res.status === 403 || !message ? statusText(res.status, conf) : message), {
112
- status: res.status, errors: json.errors,
113
- });
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;
114
238
  }
115
- if (!res.ok) throw new TetherError(statusText(res.status, conf), { status: res.status });
116
-
117
- return json.data;
118
239
  }
119
240
  }
120
241
 
121
- export const gql = (query, variables, opts) => request(cfg, query, variables, opts);
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);
122
252
 
123
- /** Throws on mutation userErrors, returns the payload otherwise. */
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
+ */
124
260
  export function unwrap(payload, name) {
125
261
  if (!payload) throw new TetherError(`${name}: empty response`);
126
- const errs = payload.userErrors || [];
127
- if (errs.length) {
128
- throw new TetherError(errs.map((e) => e.message).join(' | '), { userErrors: errs });
262
+ const userErrors = payload.userErrors ?? [];
263
+ if (userErrors.length) {
264
+ throw new TetherError(userErrors.map((error) => error.message).join(' | '), { userErrors });
129
265
  }
130
266
  return payload;
131
267
  }
package/src/collection.js CHANGED
@@ -1,114 +1,183 @@
1
1
  /**
2
- * Product lists: [tether-collection="handle"] or [tether-products="search query"].
3
- * The first child of [tether-list] is the card template; every card becomes a
4
- * product scope, so all product attributes work inside cards (including
5
- * quick-add with a subscription toggle).
2
+ * Product lists: [tether-collection="handle"], [tether-products="search query"] and [tether-recommendations]. The
3
+ * first child of [tether-list] is the card template; every card becomes a product scope, so all product attributes
4
+ * work inside cards (including quick-add with a subscription toggle).
6
5
  */
7
6
  import { reportError } from './config.js';
8
7
  import { TetherError } from './client.js';
9
- import { attr, sel, get, has, scoped, emit, setText, setImage, repeat, notifyLayoutChanged, busy, show } from './dom.js';
8
+ import {
9
+ attr,
10
+ sel,
11
+ get,
12
+ has,
13
+ scoped,
14
+ emit,
15
+ setText,
16
+ setStoreHtml,
17
+ setImage,
18
+ repeat,
19
+ notifyLayoutChanged,
20
+ busy,
21
+ show,
22
+ } from './dom.js';
10
23
  import { getProducts, getRecommendations } from './loader.js';
11
24
  import { setScopeStatus, isNotFound, resolveProductKey } from './status.js';
25
+ /** @import { Product } from './fragments.js' */
26
+ /** @import { Collection, PageInfo, ProductQuery } from './loader.js' */
27
+
28
+ /** `tether-products="url:q"`: the search in the page's `?q=`, for a results page on a static site. */
29
+ const URL_QUERY_SOURCE = 'url:';
12
30
 
13
31
  export const listSelector = () => `${sel('collection')},${sel('products')},${sel('recommendations')}`;
14
32
 
33
+ /**
34
+ * @param {EventTarget | null} target
35
+ * @returns {target is Element}
36
+ */
37
+ const isElement = (target) => target instanceof window.Element;
38
+
39
+ /**
40
+ * @param {string | null} raw A `tether-limit`.
41
+ * @returns {number | undefined} The limit; `undefined` for none, zero or not a number.
42
+ */
43
+ const toLimit = (raw) => parseInt(raw ?? '', 10) || undefined;
44
+
45
+ /**
46
+ * @param {string | null} raw A `tether-intent`.
47
+ * @returns {'RELATED' | 'COMPLEMENTARY'} Shopify's recommendation intent, `RELATED` unless `COMPLEMENTARY` is written.
48
+ */
49
+ const toIntent = (raw) => (raw === 'COMPLEMENTARY' ? 'COMPLEMENTARY' : 'RELATED');
50
+
15
51
  export class ListScope {
52
+ /** @param {HTMLElement} root */
16
53
  constructor(root) {
17
54
  this.root = root;
55
+ /** @type {Product[]} */
18
56
  this.products = [];
57
+ /** @type {PageInfo | null} */
19
58
  this.pageInfo = null;
59
+ /** @type {Collection | null} */
20
60
  this.collection = null;
61
+ /** Counts loads, so only the latest one renders. */
62
+ this.loadToken = 0;
21
63
  setScopeStatus(root, 'loading');
22
- root.addEventListener('click', (e) => {
23
- const more = e.target.closest?.(sel('load-more'));
24
- if (more && more.closest(listSelector()) === root) {
25
- e.preventDefault();
26
- busy(more, () => this.load(true));
27
- }
64
+ root.addEventListener('click', (event) => {
65
+ if (!isElement(event.target)) return;
66
+ const more = event.target.closest(sel('load-more'));
67
+ if (more?.closest(listSelector()) !== root) return;
68
+ event.preventDefault();
69
+ void busy(more, () => this.load(true));
28
70
  });
29
71
  }
30
72
 
31
- $(s) { return scoped(this.root, s, listSelector()); }
73
+ /** @param {string} selector */
74
+ $(selector) {
75
+ return scoped(this.root, selector, listSelector());
76
+ }
32
77
 
78
+ /** @returns {ProductQuery} The collection or search query of the list's attributes. */
33
79
  options() {
34
- const r = this.root;
35
- const opts = {
36
- first: parseInt(get(r, 'limit') || '', 10) || undefined,
37
- sortKey: get(r, 'sort') || undefined,
38
- reverse: get(r, 'reverse') === 'true',
39
- };
40
- if (has(r, 'collection')) opts.collection = get(r, 'collection');
41
- else {
42
- // url:q = the search in the page's ?q= (a results page on a static site).
43
- const raw = get(r, 'products') || '';
44
- opts.query = raw.startsWith('url:') ? new URL(window.location.href).searchParams.get(raw.slice(4)) || '' : raw;
80
+ const { root } = this;
81
+ /** @type {ProductQuery} */
82
+ const query = { reverse: get(root, 'reverse') === 'true' };
83
+ const first = toLimit(get(root, 'limit'));
84
+ if (first) query.first = first;
85
+ const sortKey = get(root, 'sort');
86
+ if (sortKey) query.sortKey = sortKey;
87
+ if (has(root, 'collection')) {
88
+ query.collection = get(root, 'collection') ?? '';
89
+ return query;
45
90
  }
46
- return opts;
91
+ const raw = get(root, 'products') ?? '';
92
+ query.query = raw.startsWith(URL_QUERY_SOURCE)
93
+ ? new URL(window.location.href).searchParams.get(raw.slice(URL_QUERY_SOURCE.length)) || ''
94
+ : raw;
95
+ return query;
47
96
  }
48
97
 
98
+ /**
99
+ * The product of a recommendations list: a handle, `url:…` (one dynamic product page), or empty inside a product
100
+ * scope for that product.
101
+ *
102
+ * @returns {Promise<Product[]>}
103
+ * @throws {TetherError} `NOT_FOUND` when there is no product to recommend for.
104
+ */
105
+ async loadRecommendations() {
106
+ let { key } = resolveProductKey(this.root, window.location, 'recommendations');
107
+ const scope = this.root.closest(sel('product'));
108
+ if (!key && scope) ({ key } = resolveProductKey(scope));
109
+ if (!key) throw new TetherError('No product for recommendations', { code: 'NOT_FOUND' });
110
+ return getRecommendations(key, toIntent(get(this.root, 'intent')));
111
+ }
112
+
113
+ /** @param {boolean} [more] Appends the next page instead of replacing the products. */
49
114
  async load(more = false) {
50
- const token = (this.loadToken = (this.loadToken || 0) + 1);
115
+ this.loadToken += 1;
116
+ const token = this.loadToken;
51
117
  const stale = () => token !== this.loadToken;
52
118
  try {
53
119
  if (has(this.root, 'recommendations')) {
54
- // A handle, url:handle (one dynamic product page), or empty inside a product: that product.
55
- let { key } = resolveProductKey(this.root, window.location, 'recommendations');
56
- const scope = this.root.closest(sel('product'));
57
- if (!key && scope) ({ key } = resolveProductKey(scope));
58
- if (!key) throw new TetherError('No product for recommendations', { code: 'NOT_FOUND' });
59
- const recs = await getRecommendations(key, get(this.root, 'intent') || 'RELATED');
120
+ const recommendations = await this.loadRecommendations();
60
121
  if (stale()) return;
61
- const limit = parseInt(get(this.root, 'limit') || '', 10);
62
- this.products = limit ? recs.slice(0, limit) : recs;
122
+ const limit = toLimit(get(this.root, 'limit'));
123
+ this.products = limit ? recommendations.slice(0, limit) : recommendations;
63
124
  } else {
64
- const res = await getProducts({ ...this.options(), after: more ? this.pageInfo?.endCursor : null });
125
+ const page = await getProducts({ ...this.options(), after: more ? (this.pageInfo?.endCursor ?? null) : null });
65
126
  if (stale()) return;
66
- this.collection = res.collection;
67
- this.pageInfo = res.pageInfo;
68
- this.products = more ? [...this.products, ...res.products] : res.products;
127
+ this.collection = page.collection;
128
+ this.pageInfo = page.pageInfo;
129
+ this.products = more ? [...this.products, ...page.products] : page.products;
69
130
  }
70
131
  this.render();
71
132
  setScopeStatus(this.root, 'ready');
72
133
  emit('list-ready', { products: this.products, collection: this.collection }, this.root);
73
- } catch (err) {
134
+ } catch (error) {
74
135
  if (stale()) return;
75
- // Keep what's already rendered on a failed "load more"; otherwise show the error state.
76
- if (!more) setScopeStatus(this.root, isNotFound(err) ? 'not-found' : 'error', err);
136
+ // A failed "load more" keeps what is rendered; otherwise the list shows its error state.
137
+ if (!more) setScopeStatus(this.root, isNotFound(error) ? 'not-found' : 'error', error);
77
138
  // A missing product for recommendations is a page detail, not an error to report.
78
- if (!(isNotFound(err) && has(this.root, 'recommendations'))) reportError(err, 'list load failed');
79
- emit('error', { error: err, context: 'list' }, this.root);
139
+ if (!(isNotFound(error) && has(this.root, 'recommendations'))) reportError(error, 'list load failed');
140
+ emit('error', { error, context: 'list' }, this.root);
80
141
  }
81
142
  }
82
143
 
83
144
  render() {
84
- const c = this.collection;
145
+ const { collection } = this;
85
146
  this.$(sel('collection-bind')).forEach((el) => {
86
- switch (get(el, 'collection-bind')) {
87
- case 'title': return setText(el, c?.title || '');
88
- case 'description': el.innerHTML = c?.descriptionHtml || ''; return undefined;
89
- case 'image': return setImage(el, c?.image?.url, c?.image?.altText || c?.title);
90
- case 'count': return setText(el, this.products.length);
91
- default: return undefined;
147
+ switch (get(el, 'collection-bind') ?? '') {
148
+ case 'title':
149
+ setText(el, collection?.title ?? '');
150
+ break;
151
+ case 'description':
152
+ setStoreHtml(el, collection?.descriptionHtml);
153
+ break;
154
+ case 'image':
155
+ setImage(el, collection?.image?.url, collection?.image?.altText || collection?.title);
156
+ break;
157
+ case 'count':
158
+ setText(el, this.products.length);
159
+ break;
92
160
  }
93
161
  });
94
162
 
95
163
  this.$(sel('list')).forEach((container) => {
96
- repeat(container, this.products, (node, p) => {
97
- node.setAttribute(attr('product'), p.handle);
164
+ repeat(container, this.products, (node, product) => {
165
+ node.setAttribute(attr('product'), product.handle);
98
166
  node.removeAttribute(attr('state'));
99
167
  });
100
- // Cards are new elements: mount them (product data is already cached).
168
+ // Cards are new elements: mounting them uses the product data already cached.
101
169
  emit('list-rendered', { container }, this.root);
102
170
  });
103
171
 
104
172
  const empty = this.products.length === 0;
105
173
  this.$(sel('show')).forEach((el) => {
106
- const k = get(el, 'show');
107
- if (k === 'list-empty') show(el, empty);
108
- if (k === 'list-filled') show(el, !empty);
174
+ const key = get(el, 'show');
175
+ if (key === 'list-empty') show(el, empty);
176
+ if (key === 'list-filled') show(el, !empty);
177
+ });
178
+ this.$(sel('load-more')).forEach((el) => {
179
+ show(el, Boolean(this.pageInfo?.hasNextPage));
109
180
  });
110
- this.$(sel('load-more')).forEach((el) => show(el, Boolean(this.pageInfo?.hasNextPage)));
111
181
  notifyLayoutChanged('list');
112
182
  }
113
183
  }
114
-