@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.
- package/README.md +3 -3
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
- package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
- package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
- package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
- package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
- package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
- package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
- package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
- package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
- package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
- package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
- package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
- package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
- package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
- package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
- package/dist/esm/chunks/search-OU75RNQL.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +56 -36
- package/dist/tether.account-pages.js +776 -432
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +655 -321
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +318 -191
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +334 -243
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +78 -33
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3171 -1998
- package/dist/tether.markets.js +231 -126
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +221 -126
- package/dist/tether.search.min.js +1 -1
- package/package.json +23 -4
- package/src/account/addresses-view.js +92 -0
- package/src/account/browser-session.js +359 -0
- package/src/account/markup.js +48 -0
- package/src/account/operations.js +180 -0
- package/src/account/order-lines.js +96 -0
- package/src/account/order-list.js +91 -0
- package/src/account/order-page.js +192 -0
- package/src/account/page-events.js +288 -0
- package/src/account/server-client.js +172 -0
- package/src/account/shared.js +111 -0
- package/src/account/shopify-auth.js +150 -0
- package/src/account/subscriptions-view.js +158 -0
- package/src/account-api.js +118 -0
- package/src/account-links.js +35 -0
- package/src/account-pages.js +68 -284
- package/src/account.js +281 -317
- package/src/amounts.js +36 -0
- package/src/analytics-shopify.js +241 -0
- package/src/analytics.js +304 -184
- package/src/api-version.js +75 -25
- package/src/astro.js +233 -68
- package/src/cart/checkout.js +97 -0
- package/src/cart/drawer.js +138 -0
- package/src/cart/mutations.js +234 -0
- package/src/cart/render.js +294 -0
- package/src/cart/store.js +326 -0
- package/src/cart.js +322 -481
- package/src/client.js +214 -78
- package/src/collection.js +126 -57
- package/src/config.js +323 -59
- package/src/controls/cart-controls.js +155 -0
- package/src/controls/discount.js +96 -0
- package/src/controls/extras.js +63 -0
- package/src/controls/navigation.js +83 -0
- package/src/diagnose-markup.js +333 -0
- package/src/diagnose.js +251 -202
- package/src/dom.js +219 -220
- package/src/extras.js +119 -37
- package/src/fragments.js +115 -22
- package/src/images.js +104 -0
- package/src/index.js +245 -285
- package/src/initial.js +78 -66
- package/src/lazy-iife.js +92 -22
- package/src/lazy.js +25 -4
- package/src/loader.js +263 -124
- package/src/market-codes.js +35 -0
- package/src/markets-ui.js +169 -55
- package/src/markets.js +286 -85
- package/src/markup.js +64 -0
- package/src/mounting.js +214 -91
- package/src/page-market.js +116 -0
- package/src/parts/account-pages.js +4 -1
- package/src/parts/account.js +4 -1
- package/src/parts/analytics.js +4 -1
- package/src/parts/diagnose.js +4 -1
- package/src/parts/extras.js +4 -1
- package/src/parts/markets.js +4 -1
- package/src/parts/search.js +4 -1
- package/src/presets/barba.js +120 -30
- package/src/product-events.js +130 -0
- package/src/product-render.js +257 -0
- package/src/product-state.js +318 -0
- package/src/product.js +233 -459
- package/src/repeaters.js +173 -0
- package/src/search.js +277 -107
- package/src/selling-plans.js +107 -47
- package/src/server.js +168 -205
- package/src/shopify-values.js +89 -0
- package/src/status.js +99 -39
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +199 -0
- package/src/storefront/json-ld.js +167 -0
- package/src/variant-render.js +165 -0
- package/types/astro.d.ts +13 -2
- package/types/index.d.ts +178 -27
- package/types/server.d.ts +89 -17
- package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
- package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
- package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
- package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
- package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
- package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
- package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
- package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/client.js
CHANGED
|
@@ -1,35 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storefront API client:
|
|
3
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
52
|
-
*
|
|
125
|
+
* @param {Response} res
|
|
126
|
+
* @param {GraphQLError[]} errors
|
|
127
|
+
* @param {StorefrontConfig} conf
|
|
53
128
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
127
|
-
if (
|
|
128
|
-
throw new TetherError(
|
|
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"]
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
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', (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
62
|
-
this.products = limit ?
|
|
122
|
+
const limit = toLimit(get(this.root, 'limit'));
|
|
123
|
+
this.products = limit ? recommendations.slice(0, limit) : recommendations;
|
|
63
124
|
} else {
|
|
64
|
-
const
|
|
125
|
+
const page = await getProducts({ ...this.options(), after: more ? (this.pageInfo?.endCursor ?? null) : null });
|
|
65
126
|
if (stale()) return;
|
|
66
|
-
this.collection =
|
|
67
|
-
this.pageInfo =
|
|
68
|
-
this.products = more ? [...this.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 (
|
|
134
|
+
} catch (error) {
|
|
74
135
|
if (stale()) return;
|
|
75
|
-
//
|
|
76
|
-
if (!more) setScopeStatus(this.root, isNotFound(
|
|
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(
|
|
79
|
-
emit('error', { error
|
|
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
|
|
145
|
+
const { collection } = this;
|
|
85
146
|
this.$(sel('collection-bind')).forEach((el) => {
|
|
86
|
-
switch (get(el, 'collection-bind')) {
|
|
87
|
-
case 'title':
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
case '
|
|
91
|
-
|
|
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,
|
|
97
|
-
node.setAttribute(attr('product'),
|
|
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:
|
|
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
|
|
107
|
-
if (
|
|
108
|
-
if (
|
|
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
|
-
|