@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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversions of Shopify values that the browser script and the server helpers (`src/server.js`, `src/astro.js`)
|
|
3
|
+
* share, so a server-rendered page and the script that takes it over agree. Pure: no config, no DOM.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ amount: string, currencyCode: string }} Money A Storefront API `MoneyV2`. */
|
|
7
|
+
|
|
8
|
+
/** The `.env` names a store's domain and public Storefront token are read from, first match wins. */
|
|
9
|
+
export const STORE_ENV_NAMES = {
|
|
10
|
+
domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
|
|
11
|
+
token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True for an absolute https URL. Links and redirects that come from Shopify (checkout, status pages,
|
|
16
|
+
* tracking) are only used when this holds.
|
|
17
|
+
*
|
|
18
|
+
* @param {unknown} url
|
|
19
|
+
*/
|
|
20
|
+
export function isHttpsUrl(url) {
|
|
21
|
+
if (typeof url !== 'string') return false;
|
|
22
|
+
try {
|
|
23
|
+
return new URL(url).protocol === 'https:';
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True for a token Shopify issues as a secret (`shpat_` Admin, `shpss_` shared secret, `shpca_` client credentials,
|
|
31
|
+
* `shppa_` private app, `shpua_` user). The public Storefront token has no such prefix. A secret token must never
|
|
32
|
+
* reach a page.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} token
|
|
35
|
+
*/
|
|
36
|
+
export function isSecretShopifyToken(token) {
|
|
37
|
+
return /^shp[a-z]{2}_/.test(token.trim());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Money as text in `locale`, e.g. `{ amount: '30.0', currencyCode: 'EUR' }` in `nl-NL` → `€ 30,00`.
|
|
42
|
+
*
|
|
43
|
+
* @param {Money | null | undefined} money
|
|
44
|
+
* @param {string | undefined} locale An `Intl` locale; the runtime's own when absent.
|
|
45
|
+
* @returns {string} `''` without money or with an amount that isn't a number.
|
|
46
|
+
*/
|
|
47
|
+
export function formatMoney(money, locale) {
|
|
48
|
+
if (!money) return '';
|
|
49
|
+
const amount = parseFloat(money.amount);
|
|
50
|
+
if (Number.isNaN(amount)) return '';
|
|
51
|
+
try {
|
|
52
|
+
return new Intl.NumberFormat(locale || undefined, { style: 'currency', currency: money.currencyCode }).format(
|
|
53
|
+
amount,
|
|
54
|
+
);
|
|
55
|
+
} catch {
|
|
56
|
+
// A currency code Intl doesn't know.
|
|
57
|
+
return `${money.currencyCode} ${amount.toFixed(2)}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A product page's path. `productPath` is a folder (`/product` → `/product/<handle>`) or a template with `{handle}`
|
|
63
|
+
* and `{id}` (the numeric product id), e.g. `/product?handle={handle}`.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} productPath
|
|
66
|
+
* @param {string | null | undefined} handle
|
|
67
|
+
* @param {string | null | undefined} [id] A product gid or numeric id.
|
|
68
|
+
*/
|
|
69
|
+
export function productUrl(productPath, handle, id) {
|
|
70
|
+
if (!/\{(handle|id)\}/.test(productPath)) return `${productPath.replace(/\/$/, '')}/${handle ?? String(handle)}`;
|
|
71
|
+
const numericId = id ? (id.split('/').pop() ?? '') : '';
|
|
72
|
+
return productPath
|
|
73
|
+
.replace(/\{handle\}/g, encodeURIComponent(handle ?? ''))
|
|
74
|
+
.replace(/\{id\}/g, encodeURIComponent(numericId));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The key Tether loads a product by: a handle, or a product gid.
|
|
79
|
+
*
|
|
80
|
+
* @param {string | number | null | undefined} raw A handle, a numeric id, a gid or a product URL (`…/products/<handle>`).
|
|
81
|
+
* @returns {string | null}
|
|
82
|
+
*/
|
|
83
|
+
export function productKey(raw) {
|
|
84
|
+
const value = String(raw ?? '').trim();
|
|
85
|
+
if (!value) return null;
|
|
86
|
+
if (value.startsWith('gid://')) return value;
|
|
87
|
+
if (/^\d+$/.test(value)) return `gid://shopify/Product/${value}`;
|
|
88
|
+
return /\/products\/([^/?#]+)/.exec(value)?.[1] ?? value;
|
|
89
|
+
}
|
package/src/status.js
CHANGED
|
@@ -1,66 +1,126 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where a product scope gets its key from, and the loading
|
|
3
|
-
*
|
|
2
|
+
* Where a product scope gets its key from, and the state of a scope (loading, ready, error, not found), so a failing
|
|
3
|
+
* API never leaves a broken page.
|
|
4
4
|
*/
|
|
5
5
|
import { attr, sel, get, scoped, show } from './dom.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
8
|
+
* @typedef {object} ProductKeySource
|
|
9
|
+
* @property {string | null} key The product to load; `null` when the attribute or the page URL has none.
|
|
10
|
+
* @property {boolean} fromUrl The key comes from the page URL (`url:…`).
|
|
11
|
+
* @property {string} raw The attribute's value as written.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @typedef {'loading' | 'ready' | 'error' | 'not-found'} ScopeStatus */
|
|
15
|
+
|
|
16
|
+
const URL_SOURCE = 'url:';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A path segment as text. A malformed escape (`%E0%A4%A`) is kept as written, which is how the browser shows it.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} segment
|
|
22
|
+
*/
|
|
23
|
+
function decodePathSegment(segment) {
|
|
24
|
+
try {
|
|
25
|
+
return decodeURIComponent(segment);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof URIError) return segment;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reads a `tether-product` value:
|
|
34
|
+
* - `coffee-box`: a handle, numeric id, gid or product URL;
|
|
35
|
+
* - `url:handle`: `?handle=` from the page URL (any parameter: `url:p` reads `?p=`);
|
|
36
|
+
* - `url:path`: the last segment of the page path (`/shop/coffee-box` → `coffee-box`).
|
|
37
|
+
*
|
|
38
|
+
* @param {Element} el
|
|
39
|
+
* @param {Pick<Location, 'pathname' | 'search'>} [location]
|
|
40
|
+
* @param {string} [name] The attribute, without prefix.
|
|
41
|
+
* @returns {ProductKeySource}
|
|
13
42
|
*/
|
|
14
43
|
export function resolveProductKey(el, location = window.location, name = 'product') {
|
|
15
|
-
const raw = (get(el, name)
|
|
16
|
-
if (!raw.toLowerCase().startsWith(
|
|
17
|
-
const
|
|
18
|
-
let key
|
|
19
|
-
if (
|
|
20
|
-
const
|
|
21
|
-
|
|
44
|
+
const raw = (get(el, name) ?? '').trim();
|
|
45
|
+
if (!raw.toLowerCase().startsWith(URL_SOURCE)) return { key: raw || null, fromUrl: false, raw };
|
|
46
|
+
const source = raw.slice(URL_SOURCE.length).trim();
|
|
47
|
+
let key;
|
|
48
|
+
if (source.toLowerCase() === 'path') {
|
|
49
|
+
const segments = location.pathname.split('/').filter(Boolean);
|
|
50
|
+
const segment = segments[segments.length - 1];
|
|
51
|
+
key = segment === undefined ? null : decodePathSegment(segment);
|
|
22
52
|
} else {
|
|
23
|
-
key = new URLSearchParams(location.search).get(
|
|
53
|
+
key = new URLSearchParams(location.search).get(source || 'handle');
|
|
24
54
|
}
|
|
25
|
-
|
|
26
|
-
return { key, fromUrl: true, raw };
|
|
55
|
+
return { key: key?.trim() || null, fromUrl: true, raw };
|
|
27
56
|
}
|
|
28
57
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
/** Values of tether-show that follow the scope state. */
|
|
58
|
+
/** @type {readonly ScopeStatus[]} The values of `tether-show` that follow the scope's state. */
|
|
32
59
|
export const STATUS_KEYS = ['loading', 'ready', 'error', 'not-found'];
|
|
33
60
|
|
|
34
|
-
|
|
61
|
+
/**
|
|
62
|
+
* @param {unknown} value
|
|
63
|
+
* @returns {value is ScopeStatus}
|
|
64
|
+
*/
|
|
65
|
+
const isScopeStatus = (value) => STATUS_KEYS.some((status) => status === value);
|
|
35
66
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
67
|
+
/**
|
|
68
|
+
* @param {unknown} error
|
|
69
|
+
* @returns {unknown} The error's `code` (a `TetherError` has one).
|
|
70
|
+
*/
|
|
71
|
+
const codeOf = (error) => (typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {unknown} error
|
|
75
|
+
* @returns {boolean} True for a `TetherError` with code `NOT_FOUND`.
|
|
76
|
+
*/
|
|
77
|
+
export const isNotFound = (error) => codeOf(error) === 'NOT_FOUND';
|
|
38
78
|
|
|
39
|
-
/**
|
|
79
|
+
/** @returns {string} The selector of every scope that owns its state elements; the nearest one wins. */
|
|
80
|
+
export const boundary = () =>
|
|
81
|
+
['product', 'collection', 'products', 'recommendations'].map((name) => sel(name)).join(',');
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {Element} el
|
|
85
|
+
* @param {Element} root
|
|
86
|
+
* @returns {boolean} True when `el` belongs to the scope `root`, not to a scope nested in it.
|
|
87
|
+
*/
|
|
40
88
|
export const ownedBy = (el, root) => el === root || el.parentElement?.closest(boundary()) === root;
|
|
41
89
|
|
|
42
90
|
/**
|
|
43
|
-
*
|
|
44
|
-
* error code
|
|
45
|
-
* inside it, and disables add-to-cart buttons when there's no product.
|
|
91
|
+
* @param {unknown} error
|
|
92
|
+
* @returns {string} The error's code for `tether-error`, `error` without one.
|
|
46
93
|
*/
|
|
47
|
-
|
|
94
|
+
function errorCode(error) {
|
|
95
|
+
const code = codeOf(error);
|
|
96
|
+
return typeof code === 'string' && code ? code : 'error';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sets `tether-state` (loading | ready | error) and `tether-error` (not-found or an error code) on `root`, toggles
|
|
101
|
+
* `tether-show="loading|ready|error|not-found"` inside it, and disables its add-to-cart buttons when there's no
|
|
102
|
+
* product.
|
|
103
|
+
*
|
|
104
|
+
* @param {Element} root
|
|
105
|
+
* @param {ScopeStatus} status
|
|
106
|
+
* @param {unknown} [error]
|
|
107
|
+
*/
|
|
108
|
+
export function setScopeStatus(root, status, error) {
|
|
48
109
|
const notFound = status === 'not-found';
|
|
110
|
+
const failed = status === 'error' || notFound;
|
|
49
111
|
root.setAttribute(attr('state'), notFound ? 'error' : status);
|
|
50
|
-
if (
|
|
112
|
+
if (failed) root.setAttribute(attr('error'), notFound ? 'not-found' : errorCode(error));
|
|
51
113
|
else root.removeAttribute(attr('error'));
|
|
52
114
|
|
|
53
115
|
scoped(root, sel('show'), boundary()).forEach((el) => {
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
show(el, k === status);
|
|
116
|
+
const shows = get(el, 'show');
|
|
117
|
+
if (isScopeStatus(shows)) show(el, shows === status);
|
|
57
118
|
});
|
|
58
119
|
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
120
|
+
if (!failed) return;
|
|
121
|
+
scoped(root, sel('add-to-cart'), boundary()).forEach((button) => {
|
|
122
|
+
button.classList.add('is-disabled');
|
|
123
|
+
button.setAttribute('aria-disabled', 'true');
|
|
124
|
+
if ('disabled' in button) button.disabled = true;
|
|
125
|
+
});
|
|
66
126
|
}
|
|
Binary file
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog reads of `createStorefront()`: products, collections and recommendations, with the browser script's own
|
|
3
|
+
* product fragment, so what the server renders and hands over has the shape the script expects. Runs in Node and
|
|
4
|
+
* Workers: no DOM, no shared config.
|
|
5
|
+
*/
|
|
6
|
+
import { request } from '../client.js';
|
|
7
|
+
import { productFragment, collectionFields } from '../fragments.js';
|
|
8
|
+
import { productKey } from '../shopify-values.js';
|
|
9
|
+
/** @import { StorefrontConfig } from '../client.js' */
|
|
10
|
+
/** @import { ProductFragmentConfig, Product } from '../fragments.js' */
|
|
11
|
+
/** @import { Collection, PageInfo, ProductPage } from '../loader.js' */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {StorefrontConfig & ProductFragmentConfig} CatalogConfig What the catalog needs of a storefront's config.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} CatalogQuery A page of a collection's products, or of a search.
|
|
19
|
+
* @property {string} [collection] A collection handle; without one, a search.
|
|
20
|
+
* @property {string | null} [query] Storefront search syntax; `null` or `''` is every product.
|
|
21
|
+
* @property {number} [first] Default the storefront's `pageSize`.
|
|
22
|
+
* @property {string | null} [after] The cursor of the page before.
|
|
23
|
+
* @property {string} [sortKey] `ProductCollectionSortKeys` for a collection, `ProductSortKeys` for a search.
|
|
24
|
+
* @property {boolean} [reverse]
|
|
25
|
+
* @property {unknown[]} [filters] `ProductFilter` values, for a collection.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @typedef {Collection & { products: Product[], pageInfo: PageInfo }} CollectionWithProducts */
|
|
29
|
+
/** @typedef {'RELATED' | 'COMPLEMENTARY'} RecommendationIntent */
|
|
30
|
+
|
|
31
|
+
/** Stops a paging loop on a store that keeps answering `hasNextPage` (500 pages of 25 is 12,500 products). */
|
|
32
|
+
const MAX_PRODUCT_PAGES = 500;
|
|
33
|
+
const COLLECTIONS_PER_PAGE = 100;
|
|
34
|
+
const MAX_COLLECTION_PAGES = 100;
|
|
35
|
+
|
|
36
|
+
const PRODUCT_BY_ID = `query TetherServerProductById($id: ID!) { node(id: $id) { ... on Product { ...TetherProduct } } }`;
|
|
37
|
+
const PRODUCT_BY_HANDLE = `query TetherServerProduct($handle: String!) { product(handle: $handle) { ...TetherProduct } }`;
|
|
38
|
+
|
|
39
|
+
const collectionQuery =
|
|
40
|
+
() => `query TetherServerCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
|
|
41
|
+
collection(handle: $handle) {
|
|
42
|
+
${collectionFields()}
|
|
43
|
+
products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
|
|
44
|
+
nodes { ...TetherProduct }
|
|
45
|
+
pageInfo { hasNextPage endCursor }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}`;
|
|
49
|
+
|
|
50
|
+
const SEARCH_QUERY = `query TetherServerProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
|
|
51
|
+
products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
|
|
52
|
+
nodes { ...TetherProduct }
|
|
53
|
+
pageInfo { hasNextPage endCursor }
|
|
54
|
+
}
|
|
55
|
+
}`;
|
|
56
|
+
|
|
57
|
+
const collectionsQuery = () => `query TetherServerCollections($first: Int!, $after: String) {
|
|
58
|
+
collections(first: $first, after: $after) { nodes { ${collectionFields()} } pageInfo { hasNextPage endCursor } }
|
|
59
|
+
}`;
|
|
60
|
+
|
|
61
|
+
/** @param {boolean} byId */
|
|
62
|
+
const recommendationsQuery = (
|
|
63
|
+
byId,
|
|
64
|
+
) => `query TetherServerRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
|
|
65
|
+
productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
|
|
66
|
+
}`;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {CatalogConfig} conf
|
|
70
|
+
* @param {number} pageSize Products per page when a query doesn't say.
|
|
71
|
+
*/
|
|
72
|
+
export function createCatalog(conf, pageSize) {
|
|
73
|
+
/**
|
|
74
|
+
* @template T The shape of the query's `data`.
|
|
75
|
+
* @param {string} operation The query, without the product fragment.
|
|
76
|
+
* @param {Record<string, unknown>} variables
|
|
77
|
+
* @returns {Promise<T>}
|
|
78
|
+
*/
|
|
79
|
+
const send = (operation, variables) => request(conf, `${operation}\n${productFragment(conf)}`, variables);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string | number | null | undefined} raw A handle, numeric id, gid or product URL.
|
|
83
|
+
* @returns {Promise<Product | null>} `null` when the store has no such product.
|
|
84
|
+
*/
|
|
85
|
+
async function getProduct(raw) {
|
|
86
|
+
const key = productKey(raw);
|
|
87
|
+
if (!key) return null;
|
|
88
|
+
if (key.startsWith('gid://')) {
|
|
89
|
+
/** @type {{ node: Partial<Product> | null }} */
|
|
90
|
+
const data = await send(PRODUCT_BY_ID, { id: key });
|
|
91
|
+
// A gid of another type answers with a node that isn't a product: no fields of the fragment.
|
|
92
|
+
return data.node?.id ? /** @type {Product} */ (data.node) : null;
|
|
93
|
+
}
|
|
94
|
+
/** @type {{ product: Product | null }} */
|
|
95
|
+
const data = await send(PRODUCT_BY_HANDLE, { handle: key });
|
|
96
|
+
return data.product;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} handle
|
|
101
|
+
* @param {Omit<CatalogQuery, 'collection' | 'query'>} options
|
|
102
|
+
* @returns {Promise<ProductPage>} No collection and no products for a handle the store doesn't have.
|
|
103
|
+
*/
|
|
104
|
+
async function getCollectionPage(handle, { first = pageSize, after = null, sortKey, reverse = false, filters }) {
|
|
105
|
+
/** @type {{ collection: (Collection & { products: { nodes: Product[], pageInfo: PageInfo } }) | null }} */
|
|
106
|
+
const data = await send(collectionQuery(), {
|
|
107
|
+
handle,
|
|
108
|
+
first,
|
|
109
|
+
after,
|
|
110
|
+
sortKey: sortKey || undefined,
|
|
111
|
+
reverse,
|
|
112
|
+
filters: filters ?? undefined,
|
|
113
|
+
});
|
|
114
|
+
if (!data.collection) return { collection: null, products: [], pageInfo: { hasNextPage: false, endCursor: null } };
|
|
115
|
+
const { products, ...collection } = data.collection;
|
|
116
|
+
return { collection, products: products.nodes, pageInfo: products.pageInfo };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {CatalogQuery} [options]
|
|
121
|
+
* @returns {Promise<ProductPage>}
|
|
122
|
+
*/
|
|
123
|
+
async function getProducts(options = {}) {
|
|
124
|
+
const { collection, query, first = pageSize, after = null, sortKey, reverse = false } = options;
|
|
125
|
+
if (collection) return getCollectionPage(collection, options);
|
|
126
|
+
/** @type {{ products: { nodes: Product[], pageInfo: PageInfo } }} */
|
|
127
|
+
const data = await send(SEARCH_QUERY, {
|
|
128
|
+
first,
|
|
129
|
+
after,
|
|
130
|
+
query: query || undefined,
|
|
131
|
+
sortKey: sortKey || undefined,
|
|
132
|
+
reverse,
|
|
133
|
+
});
|
|
134
|
+
return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every product of a collection or a search, page by page: for `getStaticPaths()`.
|
|
139
|
+
*
|
|
140
|
+
* @param {Omit<CatalogQuery, 'first' | 'after'>} [options]
|
|
141
|
+
* @returns {Promise<Product[]>}
|
|
142
|
+
*/
|
|
143
|
+
async function allProducts(options = {}) {
|
|
144
|
+
/** @type {Product[]} */
|
|
145
|
+
const products = [];
|
|
146
|
+
/** @type {string | null} */
|
|
147
|
+
let after = null;
|
|
148
|
+
for (let page = 0; page < MAX_PRODUCT_PAGES; page++) {
|
|
149
|
+
const result = await getProducts({ ...options, first: pageSize, after });
|
|
150
|
+
products.push(...result.products);
|
|
151
|
+
if (!result.pageInfo.hasNextPage) break;
|
|
152
|
+
after = result.pageInfo.endCursor;
|
|
153
|
+
}
|
|
154
|
+
return products;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {string} handle
|
|
159
|
+
* @param {Omit<CatalogQuery, 'collection'>} [options]
|
|
160
|
+
* @returns {Promise<CollectionWithProducts | null>} `null` for a handle the store doesn't have.
|
|
161
|
+
*/
|
|
162
|
+
async function getCollection(handle, options = {}) {
|
|
163
|
+
const page = await getProducts({ ...options, collection: handle });
|
|
164
|
+
return page.collection ? { ...page.collection, products: page.products, pageInfo: page.pageInfo } : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @returns {Promise<Collection[]>} */
|
|
168
|
+
async function allCollections() {
|
|
169
|
+
/** @type {Collection[]} */
|
|
170
|
+
const collections = [];
|
|
171
|
+
/** @type {string | null} */
|
|
172
|
+
let after = null;
|
|
173
|
+
for (let page = 0; page < MAX_COLLECTION_PAGES; page++) {
|
|
174
|
+
/** @type {{ collections: { nodes: Collection[], pageInfo: PageInfo } }} */
|
|
175
|
+
const data = await request(conf, collectionsQuery(), { first: COLLECTIONS_PER_PAGE, after });
|
|
176
|
+
collections.push(...data.collections.nodes);
|
|
177
|
+
if (!data.collections.pageInfo.hasNextPage) break;
|
|
178
|
+
after = data.collections.pageInfo.endCursor;
|
|
179
|
+
}
|
|
180
|
+
return collections;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* By product id (gid or numeric) or handle, so it can run next to `getProduct(handle)`.
|
|
185
|
+
*
|
|
186
|
+
* @param {string | number | { id: string } | null | undefined} product
|
|
187
|
+
* @param {RecommendationIntent} [intent]
|
|
188
|
+
* @returns {Promise<Product[]>}
|
|
189
|
+
*/
|
|
190
|
+
async function getRecommendations(product, intent = 'RELATED') {
|
|
191
|
+
const key = productKey(typeof product === 'object' ? product?.id : product);
|
|
192
|
+
const byId = key?.startsWith('gid://') ?? false;
|
|
193
|
+
/** @type {{ productRecommendations: Product[] | null }} */
|
|
194
|
+
const data = await send(recommendationsQuery(byId), byId ? { id: key, intent } : { handle: key, intent });
|
|
195
|
+
return data.productRecommendations ?? [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { getProduct, getProducts, allProducts, getCollection, allCollections, getRecommendations };
|
|
199
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema.org data for search engines (Google's product rich results): a `Product`, or a `ProductGroup` with one
|
|
3
|
+
* `Product` per variant, each with its price, currency, stock, sku, image and `?variant=` link. Products that are only
|
|
4
|
+
* sold on a subscription are priced at their first plan.
|
|
5
|
+
*/
|
|
6
|
+
import { allPlans, allocationFor, planPrice } from '../selling-plans.js';
|
|
7
|
+
import { productUrl } from '../shopify-values.js';
|
|
8
|
+
/** @import { Product, ProductVariant } from '../fragments.js' */
|
|
9
|
+
/** @import { Money } from '../shopify-values.js' */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} JsonLdOptions
|
|
13
|
+
* @property {string} [url] The product page's absolute URL; default from the storefront's `siteUrl`.
|
|
14
|
+
* @property {string} [brand] Default the product's vendor.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @typedef {{ productPath: string, siteUrl: string | undefined }} JsonLdSite Where the product pages are. */
|
|
18
|
+
|
|
19
|
+
/** The option names schema.org has a property for: a `ProductGroup` varies by them. */
|
|
20
|
+
/** @type {Readonly<Record<string, string>>} */
|
|
21
|
+
const VARIES_BY = {
|
|
22
|
+
size: 'https://schema.org/size',
|
|
23
|
+
color: 'https://schema.org/color',
|
|
24
|
+
colour: 'https://schema.org/color',
|
|
25
|
+
material: 'https://schema.org/material',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Text for a `<script>` tag: `<` is escaped so product text can never close the tag, and U+2028 / U+2029 so older
|
|
30
|
+
* JavaScript parsers read it.
|
|
31
|
+
*
|
|
32
|
+
* @param {unknown} value
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export const scriptJson = (value) =>
|
|
36
|
+
JSON.stringify(value)
|
|
37
|
+
.replace(/</g, '\\u003c')
|
|
38
|
+
.replace(/\u2028/g, '\\u2028')
|
|
39
|
+
.replace(/\u2029/g, '\\u2029');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {Record<string, unknown>} value
|
|
43
|
+
* @returns {Record<string, unknown>} `value` without its `undefined` fields, nested ones too.
|
|
44
|
+
*/
|
|
45
|
+
function withoutUndefined(value) {
|
|
46
|
+
/** @type {unknown} */
|
|
47
|
+
const copy = JSON.parse(JSON.stringify(value));
|
|
48
|
+
// The JSON of an object is an object.
|
|
49
|
+
return /** @type {Record<string, unknown>} */ (copy);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {string} gid */
|
|
53
|
+
const numericId = (gid) => gid.split('/').pop() ?? gid;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {ProductVariant} variant
|
|
57
|
+
* @returns {string} The schema.org availability.
|
|
58
|
+
*/
|
|
59
|
+
function availability(variant) {
|
|
60
|
+
if (!variant.availableForSale) return 'https://schema.org/OutOfStock';
|
|
61
|
+
return variant.currentlyNotInStock ? 'https://schema.org/BackOrder' : 'https://schema.org/InStock';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Product} product
|
|
66
|
+
* @param {ProductVariant} variant
|
|
67
|
+
* @returns {Money} The variant's price, or its first plan's for a product sold only on a subscription.
|
|
68
|
+
*/
|
|
69
|
+
function offeredPrice(product, variant) {
|
|
70
|
+
if (!product.requiresSellingPlan) return variant.price;
|
|
71
|
+
const plan = allPlans(product).find((candidate) => allocationFor(variant, candidate.id));
|
|
72
|
+
return (plan && planPrice(variant, plan.id)) ?? variant.price;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Product} product
|
|
77
|
+
* @param {ProductVariant} variant
|
|
78
|
+
* @param {string | undefined} pageUrl
|
|
79
|
+
*/
|
|
80
|
+
function offer(product, variant, pageUrl) {
|
|
81
|
+
const price = offeredPrice(product, variant);
|
|
82
|
+
const separator = pageUrl?.includes('?') ? '&' : '?';
|
|
83
|
+
return {
|
|
84
|
+
'@type': 'Offer',
|
|
85
|
+
price: price.amount,
|
|
86
|
+
priceCurrency: price.currencyCode,
|
|
87
|
+
availability: availability(variant),
|
|
88
|
+
url: pageUrl ? `${pageUrl}${separator}variant=${numericId(variant.id)}` : undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {Product} product
|
|
94
|
+
* @param {ProductVariant} variant
|
|
95
|
+
* @param {string | undefined} pageUrl
|
|
96
|
+
* @param {readonly string[]} images
|
|
97
|
+
*/
|
|
98
|
+
function variantProduct(product, variant, pageUrl, images) {
|
|
99
|
+
/** @type {[string, string][]} schema.org property name → the variant's value. */
|
|
100
|
+
const properties = variant.selectedOptions.flatMap((option) => {
|
|
101
|
+
const property = VARIES_BY[option.name.toLowerCase()];
|
|
102
|
+
return property ? [[property.split('/').pop() ?? property, option.value]] : [];
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
'@type': 'Product',
|
|
106
|
+
name: `${product.title} - ${variant.title}`,
|
|
107
|
+
sku: variant.sku || undefined,
|
|
108
|
+
image: variant.image?.url || images[0],
|
|
109
|
+
...Object.fromEntries(properties),
|
|
110
|
+
offers: offer(product, variant, pageUrl),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {Product} product
|
|
116
|
+
* @param {JsonLdOptions} options
|
|
117
|
+
* @param {JsonLdSite} site
|
|
118
|
+
* @returns {string | undefined}
|
|
119
|
+
*/
|
|
120
|
+
function pageUrlOf(product, { url }, site) {
|
|
121
|
+
if (url) return url;
|
|
122
|
+
if (!site.siteUrl) return undefined;
|
|
123
|
+
return new URL(productUrl(site.productPath, product.handle, product.id), site.siteUrl).toString();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {Product | null | undefined} product
|
|
128
|
+
* @param {JsonLdOptions} options
|
|
129
|
+
* @param {JsonLdSite} site
|
|
130
|
+
* @returns {Record<string, unknown> | null} The schema.org object, `null` without a product.
|
|
131
|
+
*/
|
|
132
|
+
export function jsonLdData(product, options, site) {
|
|
133
|
+
if (!product) return null;
|
|
134
|
+
const pageUrl = pageUrlOf(product, options, site);
|
|
135
|
+
const images = product.images.nodes.map((image) => image.url).filter(Boolean);
|
|
136
|
+
const brandName = options.brand || product.vendor;
|
|
137
|
+
const brand = brandName ? { '@type': 'Brand', name: brandName } : undefined;
|
|
138
|
+
const description = product.description.trim() || undefined;
|
|
139
|
+
const variants = product.variants.nodes;
|
|
140
|
+
if (variants.length <= 1) {
|
|
141
|
+
const [variant] = variants;
|
|
142
|
+
return withoutUndefined({
|
|
143
|
+
'@context': 'https://schema.org',
|
|
144
|
+
'@type': 'Product',
|
|
145
|
+
name: product.title,
|
|
146
|
+
description,
|
|
147
|
+
url: pageUrl,
|
|
148
|
+
image: images,
|
|
149
|
+
sku: variant?.sku || undefined,
|
|
150
|
+
brand,
|
|
151
|
+
offers: variant ? offer(product, variant, pageUrl) : undefined,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const variesBy = product.options.map((option) => VARIES_BY[option.name.toLowerCase()]).filter(Boolean);
|
|
155
|
+
return withoutUndefined({
|
|
156
|
+
'@context': 'https://schema.org',
|
|
157
|
+
'@type': 'ProductGroup',
|
|
158
|
+
name: product.title,
|
|
159
|
+
description,
|
|
160
|
+
url: pageUrl,
|
|
161
|
+
productGroupID: numericId(product.id),
|
|
162
|
+
image: images,
|
|
163
|
+
brand,
|
|
164
|
+
variesBy: [...new Set(variesBy)],
|
|
165
|
+
hasVariant: variants.map((variant) => variantProduct(product, variant, pageUrl, images)),
|
|
166
|
+
});
|
|
167
|
+
}
|