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