@occluze/tether 0.6.7 → 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 +773 -442
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +648 -318
- 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 +331 -242
- 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 +3176 -2015
- 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 +69 -298
- package/src/account.js +280 -323
- 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 +320 -488
- 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 -287
- 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-Q6E2TFEV.js +0 -1
- package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
- package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
- package/dist/esm/chunks/chunk-4QJKLPYR.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-KIDH76B7.js +0 -1
- package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
- package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
- package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/loader.js
CHANGED
|
@@ -1,162 +1,257 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* the
|
|
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.
|
|
4
5
|
*/
|
|
5
6
|
import { cfg, log } from './config.js';
|
|
6
7
|
import { gql, TetherError } from './client.js';
|
|
7
8
|
import { productFragment, collectionFields } from './fragments.js';
|
|
9
|
+
import { productKey } from './shopify-values.js';
|
|
10
|
+
/** @import { Product, Image } from './fragments.js' */
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
*/
|
|
15
35
|
let generation = 0;
|
|
16
36
|
|
|
17
|
-
|
|
37
|
+
/** @param {string} key */
|
|
38
|
+
const isGid = (key) => key.startsWith('gid://');
|
|
18
39
|
|
|
19
40
|
/** Accepts a handle, numeric id, gid or a full product URL. */
|
|
20
|
-
export
|
|
21
|
-
const v = String(raw ?? '').trim();
|
|
22
|
-
if (!v) return null;
|
|
23
|
-
if (isGid(v)) return v;
|
|
24
|
-
if (/^\d+$/.test(v)) return `gid://shopify/Product/${v}`;
|
|
25
|
-
const m = v.match(/\/products\/([^/?#]+)/);
|
|
26
|
-
return m ? m[1] : v;
|
|
27
|
-
}
|
|
41
|
+
export const normalizeProductKey = productKey;
|
|
28
42
|
|
|
29
|
-
/**
|
|
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
|
+
*/
|
|
30
48
|
export function primeProduct(product) {
|
|
31
49
|
if (!product) return;
|
|
32
|
-
const
|
|
33
|
-
productCache.set(product.handle,
|
|
34
|
-
productCache.set(product.id,
|
|
50
|
+
const loaded = Promise.resolve(product);
|
|
51
|
+
productCache.set(product.handle, loaded);
|
|
52
|
+
productCache.set(product.id, loaded);
|
|
35
53
|
}
|
|
36
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
|
+
*/
|
|
37
60
|
export function getProduct(rawKey) {
|
|
38
61
|
const key = normalizeProductKey(rawKey);
|
|
39
62
|
if (!key) return Promise.reject(new TetherError('empty product key', { code: 'NOT_FOUND' }));
|
|
40
|
-
|
|
63
|
+
const cached = productCache.get(key);
|
|
64
|
+
if (cached) return cached;
|
|
41
65
|
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
/** @type {Promise<Product>} */
|
|
67
|
+
const loading = new Promise((resolve, reject) => {
|
|
68
|
+
waiting.set(key, { resolve, reject });
|
|
44
69
|
pending.push(key);
|
|
45
|
-
if (!
|
|
46
|
-
|
|
47
|
-
setTimeout(flush, 0);
|
|
70
|
+
if (!flushScheduled) {
|
|
71
|
+
flushScheduled = true;
|
|
72
|
+
setTimeout(() => void flush(), 0);
|
|
48
73
|
}
|
|
49
74
|
});
|
|
50
|
-
productCache.set(key,
|
|
51
|
-
return
|
|
75
|
+
productCache.set(key, loading);
|
|
76
|
+
return loading;
|
|
52
77
|
}
|
|
53
78
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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) {
|
|
60
85
|
const ids = keys.filter(isGid);
|
|
61
|
-
const handles = keys.filter((
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
const handles = keys.filter((key) => !isGid(key));
|
|
87
|
+
const definitions = [];
|
|
88
|
+
const selections = [];
|
|
89
|
+
/** @type {Record<string, unknown>} */
|
|
90
|
+
const variables = {};
|
|
66
91
|
if (ids.length) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
92
|
+
definitions.push('$ids: [ID!]!');
|
|
93
|
+
variables['ids'] = ids;
|
|
94
|
+
selections.push('byId: nodes(ids: $ids) { ... on Product { ...TetherProduct } }');
|
|
70
95
|
}
|
|
71
|
-
handles.forEach((
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 }`);
|
|
75
100
|
});
|
|
101
|
+
const query = `query TetherProducts(${definitions.join(',')}) { ${selections.join(' ')} } ${productFragment()}`;
|
|
102
|
+
return { query, variables, handles };
|
|
103
|
+
}
|
|
76
104
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
}
|
|
79
126
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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);
|
|
85
134
|
|
|
135
|
+
/** @type {Record<string, Product | null> & { byId?: (Product | null)[] }} */
|
|
136
|
+
let data;
|
|
86
137
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
r.resolve(p);
|
|
97
|
-
} else {
|
|
98
|
-
// A miss is remembered for a few seconds (one page load asking twice makes one request),
|
|
99
|
-
// not longer: a product published in Shopify shows up on the next visit.
|
|
100
|
-
const miss = productCache.get(k);
|
|
101
|
-
if (gen === generation) setTimeout(() => { if (productCache.get(k) === miss) productCache.delete(k); }, 10000);
|
|
102
|
-
r.reject(new TetherError(`Product not found: ${k}`, { code: 'NOT_FOUND' }));
|
|
103
|
-
}
|
|
104
|
-
}));
|
|
105
|
-
} catch (err) {
|
|
106
|
-
keys.forEach((k) => settle(k, (r) => { if (gen === generation) productCache.delete(k); r.reject(err); }));
|
|
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;
|
|
107
147
|
}
|
|
108
|
-
}
|
|
109
148
|
|
|
110
|
-
|
|
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
|
+
});
|
|
111
156
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|
|
116
175
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
}
|
|
120
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
|
+
*/
|
|
121
190
|
export function hydrateProducts(products) {
|
|
122
|
-
(products
|
|
123
|
-
if (!
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
primeProduct(
|
|
127
|
-
stale.add(
|
|
128
|
-
stale.add(
|
|
129
|
-
}
|
|
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
|
+
}
|
|
130
199
|
}
|
|
131
200
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
});
|
|
140
214
|
return true;
|
|
141
215
|
}
|
|
142
216
|
|
|
143
|
-
/**
|
|
217
|
+
/**
|
|
218
|
+
* Loads these products again, past the cache.
|
|
219
|
+
*
|
|
220
|
+
* @param {readonly (string | number)[]} keys
|
|
221
|
+
* @returns {Promise<Product>[]}
|
|
222
|
+
*/
|
|
144
223
|
export function refreshProducts(keys) {
|
|
145
|
-
|
|
146
|
-
|
|
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));
|
|
147
229
|
}
|
|
148
230
|
|
|
231
|
+
/** @typedef {{ hasNextPage: boolean, endCursor: string | null }} PageInfo */
|
|
149
232
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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.
|
|
152
252
|
*/
|
|
153
|
-
export async function getProducts({
|
|
154
|
-
collection, query, first = cfg.collectionFirst, after = null, sortKey, reverse = false, filters,
|
|
155
|
-
} = {}) {
|
|
156
|
-
const fragment = productFragment();
|
|
157
253
|
|
|
158
|
-
|
|
159
|
-
const data = await gql(`query TetherCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
|
|
254
|
+
const COLLECTION_QUERY = `query TetherCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
|
|
160
255
|
collection(handle: $handle) {
|
|
161
256
|
${collectionFields()}
|
|
162
257
|
products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
|
|
@@ -164,9 +259,41 @@ export async function getProducts({
|
|
|
164
259
|
pageInfo { hasNextPage endCursor }
|
|
165
260
|
}
|
|
166
261
|
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
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,
|
|
170
297
|
});
|
|
171
298
|
if (!data.collection) throw new TetherError(`Collection not found: ${collection}`, { code: 'NOT_FOUND' });
|
|
172
299
|
const { products, ...rest } = data.collection;
|
|
@@ -174,25 +301,37 @@ ${fragment}`, {
|
|
|
174
301
|
return { collection: rest, products: products.nodes, pageInfo: products.pageInfo };
|
|
175
302
|
}
|
|
176
303
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
});
|
|
184
312
|
data.products.nodes.forEach(primeProduct);
|
|
185
313
|
return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
|
|
186
314
|
}
|
|
187
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
|
+
*/
|
|
188
323
|
export async function getRecommendations(product, intent = 'RELATED') {
|
|
189
324
|
const key = normalizeProductKey(typeof product === 'object' ? product?.id : product);
|
|
190
|
-
const byId = key?.startsWith('gid://');
|
|
191
|
-
|
|
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) {
|
|
192
329
|
productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
|
|
193
330
|
}
|
|
194
|
-
${productFragment()}`,
|
|
195
|
-
|
|
331
|
+
${productFragment()}`,
|
|
332
|
+
byId ? { id: key, intent } : { handle: key, intent },
|
|
333
|
+
);
|
|
334
|
+
const list = data.productRecommendations ?? [];
|
|
196
335
|
list.forEach(primeProduct);
|
|
197
336
|
return list;
|
|
198
337
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
}
|