@occluze/tether 0.6.8 → 0.7.0
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 +11 -11
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
- package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
- package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
- package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
- package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
- package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
- package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
- package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
- package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
- package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
- package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
- package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
- package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
- package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
- package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
- package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
- package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
- package/dist/esm/chunks/search-UJ73YBQ5.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 +320 -365
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +242 -491
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +66 -88
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +36 -33
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +28 -36
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +1270 -894
- package/dist/tether.markets.js +73 -90
- 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 +44 -68
- 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/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 +79 -0
- package/dist/types/src/cart/render.d.ts +17 -0
- package/dist/types/src/cart/store.d.ts +182 -0
- package/dist/types/src/cart.d.ts +106 -0
- package/dist/types/src/client.d.ts +100 -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 +185 -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 +88 -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 -23
- package/src/account/addresses-view.ts +58 -0
- package/src/account/answers.ts +79 -0
- package/src/account/markup.ts +36 -0
- package/src/account/operations.ts +145 -0
- package/src/account/{order-lines.js → order-lines.ts} +20 -41
- package/src/account/order-list.ts +107 -0
- package/src/account/order-page.ts +137 -0
- package/src/account/page-answers.ts +317 -0
- package/src/account/{page-events.js → page-events.ts} +55 -90
- package/src/account/server-client.ts +183 -0
- package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/{account-pages.js → account-pages.ts} +29 -37
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
- package/src/analytics.ts +315 -0
- package/src/{api-version.js → api-version.ts} +25 -31
- package/src/{astro.js → astro.ts} +110 -104
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/{drawer.js → drawer.ts} +18 -31
- package/src/cart/mutations.ts +237 -0
- package/src/cart/{render.js → render.ts} +68 -96
- package/src/cart/store.ts +279 -0
- package/src/{cart.js → cart.ts} +82 -128
- package/src/client.ts +391 -0
- package/src/{collection.js → collection.ts} +30 -38
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
- package/src/controls/{discount.js → discount.ts} +13 -34
- package/src/controls/{extras.js → extras.ts} +15 -24
- package/src/controls/{navigation.js → navigation.ts} +24 -30
- package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
- package/src/{diagnose.js → diagnose.ts} +95 -96
- package/src/dom.ts +268 -0
- package/src/{extras.js → extras.ts} +43 -52
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/{images.js → images.ts} +30 -43
- package/src/index.ts +415 -0
- package/src/initial.ts +101 -0
- package/src/lazy-iife.ts +197 -0
- package/src/lazy.ts +42 -0
- package/src/{loader.js → loader.ts} +149 -123
- package/src/market-codes.ts +6 -0
- package/src/{markets-ui.js → markets-ui.ts} +91 -68
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/{mounting.js → mounting.ts} +88 -101
- package/src/{page-market.js → page-market.ts} +32 -38
- package/src/part-integrity.ts +7 -0
- package/src/parts/account-pages.ts +7 -0
- package/src/parts/account.ts +7 -0
- package/src/parts/analytics.ts +7 -0
- package/src/parts/diagnose.ts +7 -0
- package/src/parts/extras.ts +7 -0
- package/src/parts/markets.ts +7 -0
- package/src/parts/search.ts +7 -0
- package/src/presets/barba.ts +194 -0
- package/src/{product-events.js → product-events.ts} +33 -48
- package/src/{product-render.js → product-render.ts} +38 -77
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/{product.js → product.ts} +90 -126
- package/src/{repeaters.js → repeaters.ts} +40 -52
- package/src/{search.js → search.ts} +85 -103
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/{shopify-values.js → shopify-values.ts} +24 -22
- package/src/{status.js → status.ts} +34 -54
- package/src/storage.ts +34 -0
- package/src/store-html.ts +359 -0
- package/src/storefront/catalog.ts +196 -0
- package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
- package/src/{variant-render.js → variant-render.ts} +24 -50
- 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-list.js +0 -91
- package/src/account/order-page.js +0 -192
- 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-api.js +0 -118
- package/src/account-links.js +0 -35
- package/src/account.js +0 -342
- package/src/amounts.js +0 -36
- package/src/analytics.js +0 -352
- package/src/cart/checkout.js +0 -97
- package/src/cart/mutations.js +0 -234
- package/src/cart/store.js +0 -326
- package/src/client.js +0 -267
- package/src/config.js +0 -349
- package/src/dom.js +0 -287
- package/src/fragments.js +0 -237
- 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/market-codes.js +0 -35
- package/src/markets.js +0 -334
- package/src/markup.js +0 -64
- 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-state.js +0 -318
- package/src/selling-plans.js +0 -154
- package/src/server.js +0 -215
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +0 -199
- 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
|
@@ -15,27 +15,52 @@
|
|
|
15
15
|
* tether-search-state="idle|loading|ready|empty|error". Arrow keys move through the results, Escape closes.
|
|
16
16
|
* tether-search-limit (default 6), tether-search-min (characters, default 2).
|
|
17
17
|
*/
|
|
18
|
-
import { cfg, log, reportError } from './config.
|
|
19
|
-
import { gql } from './client.
|
|
20
|
-
import { attr, sel, get, qsa, setText, setImage, repeat, show, formatMoney, productUrl, emit } from './dom.
|
|
21
|
-
import { within } from './mounting.
|
|
22
|
-
|
|
18
|
+
import { cfg, log, reportError } from './config.ts';
|
|
19
|
+
import { gql, orNull, shaped } from './client.ts';
|
|
20
|
+
import { attr, sel, get, qsa, setText, setImage, repeat, show, formatMoney, productUrl, emit } from './dom.ts';
|
|
21
|
+
import { within } from './mounting.ts';
|
|
22
|
+
import type { Money } from './shopify-values.ts';
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
24
|
+
interface SearchImage {
|
|
25
|
+
url: string;
|
|
26
|
+
altText: string | null;
|
|
27
|
+
}
|
|
28
|
+
interface SearchProduct {
|
|
29
|
+
id: string;
|
|
30
|
+
handle: string;
|
|
31
|
+
title: string;
|
|
32
|
+
availableForSale: boolean;
|
|
33
|
+
featuredImage: SearchImage | null;
|
|
34
|
+
priceRange: { minVariantPrice: Money };
|
|
35
|
+
}
|
|
36
|
+
interface SearchCollection {
|
|
37
|
+
handle: string;
|
|
38
|
+
title: string;
|
|
39
|
+
image: SearchImage | null;
|
|
40
|
+
}
|
|
41
|
+
interface SearchQuery {
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
export interface SearchResults {
|
|
45
|
+
products: SearchProduct[];
|
|
46
|
+
collections: SearchCollection[];
|
|
47
|
+
queries: SearchQuery[];
|
|
48
|
+
}
|
|
49
|
+
/** No results, or lists whose items have what `render` reads without a fallback. */
|
|
50
|
+
const isSearchAnswer = shaped<{ predictiveSearch: SearchResults | null }>({
|
|
51
|
+
predictiveSearch: orNull({
|
|
52
|
+
products: [{ priceRange: 'object' }],
|
|
53
|
+
collections: ['object'],
|
|
54
|
+
queries: [{ text: 'string' }],
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
type SearchState = 'idle' | 'loading' | 'ready' | 'empty' | 'error';
|
|
58
|
+
interface BindValues {
|
|
59
|
+
title?: string;
|
|
60
|
+
price?: string;
|
|
61
|
+
text?: string;
|
|
62
|
+
image?: SearchImage | null;
|
|
63
|
+
}
|
|
39
64
|
|
|
40
65
|
const QUERY = `query TetherPredictive($q: String!, $limit: Int!) {
|
|
41
66
|
predictiveSearch(query: $q, limit: $limit, limitScope: EACH, types: [PRODUCT, COLLECTION, QUERY]) {
|
|
@@ -49,31 +74,18 @@ const DEFAULT_LIMIT = 6;
|
|
|
49
74
|
const DEFAULT_MIN_CHARACTERS = 2;
|
|
50
75
|
const DEFAULT_ACTION = '/search';
|
|
51
76
|
|
|
52
|
-
|
|
53
|
-
const mounted = new WeakSet();
|
|
77
|
+
const mounted = new WeakSet<Element>();
|
|
54
78
|
let searchBoxCount = 0;
|
|
55
79
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
* @param {string} name
|
|
59
|
-
* @param {number} fallback
|
|
60
|
-
* @returns {number} The attribute as an integer; `fallback` when it is missing, zero or not a number.
|
|
61
|
-
*/
|
|
62
|
-
function integerAttribute(el, name, fallback) {
|
|
80
|
+
/** @returns The attribute as an integer; `fallback` when it is missing, zero or not a number. */
|
|
81
|
+
function integerAttribute(el: Element, name: string, fallback: number): number {
|
|
63
82
|
return parseInt(get(el, name) || '', 10) || fallback;
|
|
64
83
|
}
|
|
65
84
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
* @returns {Element | null} `node` when it is a link, else its first link.
|
|
69
|
-
*/
|
|
70
|
-
const linkIn = (node) => (node.tagName === 'A' ? node : node.querySelector('a'));
|
|
85
|
+
/** @returns `node` when it is a link, else its first link. */
|
|
86
|
+
const linkIn = (node: Element): Element | null => (node.tagName === 'A' ? node : node.querySelector('a'));
|
|
71
87
|
|
|
72
|
-
|
|
73
|
-
* @param {HTMLElement} node
|
|
74
|
-
* @param {BindValues} values
|
|
75
|
-
*/
|
|
76
|
-
function bindValues(node, values) {
|
|
88
|
+
function bindValues(node: HTMLElement, values: BindValues) {
|
|
77
89
|
qsa(node, sel('search-bind')).forEach((el) => {
|
|
78
90
|
const field = get(el, 'search-bind');
|
|
79
91
|
if (field === 'image') setImage(el, values.image?.url, values.image?.altText || values.title);
|
|
@@ -83,25 +95,30 @@ function bindValues(node, values) {
|
|
|
83
95
|
});
|
|
84
96
|
}
|
|
85
97
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
* @param {string} href
|
|
89
|
-
*/
|
|
90
|
-
function linkOption(node, href) {
|
|
98
|
+
/** @param href `null` for a product without a page: the option shows, without a link. */
|
|
99
|
+
function linkOption(node: Element, href: string | null) {
|
|
91
100
|
const link = linkIn(node);
|
|
92
101
|
if (!link) return;
|
|
93
|
-
link.setAttribute('href', href);
|
|
102
|
+
if (href) link.setAttribute('href', href);
|
|
94
103
|
link.setAttribute('role', 'option');
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
/** One `[tether-search]` root: its input, its results and the search in flight. */
|
|
98
107
|
class SearchBox {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
readonly root: HTMLElement;
|
|
109
|
+
readonly input: HTMLInputElement;
|
|
110
|
+
readonly results: HTMLElement;
|
|
111
|
+
readonly limit: number;
|
|
112
|
+
readonly minCharacters: number;
|
|
113
|
+
readonly resultsId: string;
|
|
114
|
+
/** Counts searches, so only the latest one renders. */
|
|
115
|
+
searchNumber = 0;
|
|
116
|
+
timer: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
117
|
+
lastTerm = '';
|
|
118
|
+
/** Results per term, for typing back and forth. */
|
|
119
|
+
readonly cache = new Map<string, SearchResults | null>();
|
|
120
|
+
|
|
121
|
+
constructor(root: HTMLElement, input: HTMLInputElement, results: HTMLElement) {
|
|
105
122
|
this.root = root;
|
|
106
123
|
this.input = input;
|
|
107
124
|
this.results = results;
|
|
@@ -112,13 +129,6 @@ class SearchBox {
|
|
|
112
129
|
results.id = `${cfg.prefix}-search-${searchBoxCount}`;
|
|
113
130
|
}
|
|
114
131
|
this.resultsId = results.id;
|
|
115
|
-
/** Counts searches, so only the latest one renders. */
|
|
116
|
-
this.searchNumber = 0;
|
|
117
|
-
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
118
|
-
this.timer = undefined;
|
|
119
|
-
this.lastTerm = '';
|
|
120
|
-
/** @type {Map<string, SearchResults | null>} Results per term, for typing back and forth. */
|
|
121
|
-
this.cache = new Map();
|
|
122
132
|
this.setUpAria();
|
|
123
133
|
this.listen();
|
|
124
134
|
}
|
|
@@ -149,17 +159,15 @@ class SearchBox {
|
|
|
149
159
|
});
|
|
150
160
|
document.addEventListener('click', (event) => {
|
|
151
161
|
// A click event's target is always a node.
|
|
152
|
-
if (!this.root.contains(
|
|
162
|
+
if (!this.root.contains(event.target as Node | null)) this.open(false);
|
|
153
163
|
});
|
|
154
164
|
}
|
|
155
165
|
|
|
156
|
-
|
|
157
|
-
setState(state) {
|
|
166
|
+
setState(state: SearchState) {
|
|
158
167
|
this.root.setAttribute(attr('search-state'), state);
|
|
159
168
|
}
|
|
160
169
|
|
|
161
|
-
|
|
162
|
-
open(open) {
|
|
170
|
+
open(open: boolean) {
|
|
163
171
|
this.root.classList.toggle('is-open', open);
|
|
164
172
|
this.results.hidden = !open;
|
|
165
173
|
this.input.setAttribute('aria-expanded', String(open));
|
|
@@ -170,21 +178,15 @@ class SearchBox {
|
|
|
170
178
|
return qsa(this.results, '[role="option"]');
|
|
171
179
|
}
|
|
172
180
|
|
|
173
|
-
|
|
174
|
-
* @param {string} term
|
|
175
|
-
* @returns {Promise<SearchResults | null>}
|
|
176
|
-
*/
|
|
177
|
-
async fetchResults(term) {
|
|
181
|
+
async fetchResults(term: string): Promise<SearchResults | null> {
|
|
178
182
|
const cached = this.cache.get(term);
|
|
179
183
|
if (cached) return cached;
|
|
180
|
-
|
|
181
|
-
const data = await gql(QUERY, { q: term, limit: this.limit });
|
|
184
|
+
const data = await gql(QUERY, { q: term, limit: this.limit }, isSearchAnswer);
|
|
182
185
|
this.cache.set(term, data.predictiveSearch);
|
|
183
186
|
return data.predictiveSearch;
|
|
184
187
|
}
|
|
185
188
|
|
|
186
|
-
|
|
187
|
-
async search(term) {
|
|
189
|
+
async search(term: string) {
|
|
188
190
|
this.searchNumber += 1;
|
|
189
191
|
const searchNumber = this.searchNumber;
|
|
190
192
|
if (term.length < this.minCharacters) {
|
|
@@ -207,31 +209,21 @@ class SearchBox {
|
|
|
207
209
|
}
|
|
208
210
|
}
|
|
209
211
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
212
|
-
* @param {string} name The list attribute, e.g. `search-products`.
|
|
213
|
-
* @param {readonly Item[]} items
|
|
214
|
-
* @param {(node: HTMLElement, item: Item) => void} render
|
|
215
|
-
*/
|
|
216
|
-
fillList(name, items, render) {
|
|
212
|
+
/** @param name The list attribute, e.g. `search-products`. */
|
|
213
|
+
fillList<Item>(name: string, items: readonly Item[], render: (node: HTMLElement, item: Item) => void) {
|
|
217
214
|
qsa(this.results, sel(name)).forEach((list) => {
|
|
218
215
|
repeat(list, items, render);
|
|
219
216
|
show(list, items.length > 0);
|
|
220
217
|
});
|
|
221
218
|
}
|
|
222
219
|
|
|
223
|
-
|
|
224
|
-
queryUrl(text) {
|
|
220
|
+
queryUrl(text: string) {
|
|
225
221
|
const action = this.root.getAttribute('action') || DEFAULT_ACTION;
|
|
226
222
|
const separator = action.includes('?') ? '&' : '?';
|
|
227
223
|
return `${action}${separator}${this.input.name || 'q'}=${encodeURIComponent(text)}`;
|
|
228
224
|
}
|
|
229
225
|
|
|
230
|
-
|
|
231
|
-
* @param {SearchResults | null} results
|
|
232
|
-
* @param {string} term
|
|
233
|
-
*/
|
|
234
|
-
render(results, term) {
|
|
226
|
+
render(results: SearchResults | null, term: string) {
|
|
235
227
|
const products = results?.products ?? [];
|
|
236
228
|
const collections = results?.collections ?? [];
|
|
237
229
|
const queries = (results?.queries ?? []).filter((query) => query.text.toLowerCase() !== term.toLowerCase());
|
|
@@ -256,8 +248,7 @@ class SearchBox {
|
|
|
256
248
|
emit('search', { term, products, collections, queries }, this.root);
|
|
257
249
|
}
|
|
258
250
|
|
|
259
|
-
|
|
260
|
-
showCount(count) {
|
|
251
|
+
showCount(count: number) {
|
|
261
252
|
qsa(this.root, sel('show')).forEach((el) => {
|
|
262
253
|
const condition = get(el, 'show');
|
|
263
254
|
if (condition === 'search-empty') show(el, count === 0);
|
|
@@ -269,11 +260,7 @@ class SearchBox {
|
|
|
269
260
|
});
|
|
270
261
|
}
|
|
271
262
|
|
|
272
|
-
|
|
273
|
-
* @param {HTMLElement[]} options
|
|
274
|
-
* @param {number} index
|
|
275
|
-
*/
|
|
276
|
-
highlight(options, index) {
|
|
263
|
+
highlight(options: HTMLElement[], index: number) {
|
|
277
264
|
options.forEach((option, optionIndex) => {
|
|
278
265
|
option.setAttribute('aria-selected', String(optionIndex === index));
|
|
279
266
|
});
|
|
@@ -281,11 +268,10 @@ class SearchBox {
|
|
|
281
268
|
if (!option) return;
|
|
282
269
|
this.input.setAttribute('aria-activedescendant', option.id);
|
|
283
270
|
// jsdom has no scrollIntoView.
|
|
284
|
-
|
|
271
|
+
(option as Partial<Pick<HTMLElement, 'scrollIntoView'>>).scrollIntoView?.({ block: 'nearest' });
|
|
285
272
|
}
|
|
286
273
|
|
|
287
|
-
|
|
288
|
-
onKeydown(event) {
|
|
274
|
+
onKeydown(event: KeyboardEvent) {
|
|
289
275
|
const options = this.options();
|
|
290
276
|
const current = options.findIndex((option) => option.getAttribute('aria-selected') === 'true');
|
|
291
277
|
if (event.key === 'ArrowDown' && options.length) {
|
|
@@ -306,18 +292,14 @@ class SearchBox {
|
|
|
306
292
|
|
|
307
293
|
/**
|
|
308
294
|
* Sets up every `[tether-search]` in `root` (and `root` itself) once.
|
|
309
|
-
*
|
|
310
|
-
* @param {ParentNode & Partial<Pick<Element, 'matches'>>} [root]
|
|
311
295
|
*/
|
|
312
|
-
export function mountSearch(root = document) {
|
|
296
|
+
export function mountSearch(root: ParentNode & Partial<Pick<Element, 'matches'>> = document): void {
|
|
313
297
|
for (const el of within(root, sel('search'))) {
|
|
314
298
|
if (mounted.has(el)) continue;
|
|
315
299
|
mounted.add(el);
|
|
316
300
|
// The markup names an input element; a site without one gets the first input.
|
|
317
|
-
const input =
|
|
318
|
-
|
|
319
|
-
);
|
|
320
|
-
const results = /** @type {HTMLElement | null} */ (el.querySelector(sel('search-results')));
|
|
301
|
+
const input = el.querySelector<HTMLInputElement>(sel('search-input')) ?? el.querySelector('input');
|
|
302
|
+
const results = el.querySelector<HTMLElement>(sel('search-results'));
|
|
321
303
|
if (input && results) new SearchBox(el, input, results);
|
|
322
304
|
}
|
|
323
305
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopify selling plans (subscriptions): which plans a product and a variant have, and what each costs. Pure, no DOM,
|
|
3
|
+
* shared by the browser script and the server helpers.
|
|
4
|
+
*/
|
|
5
|
+
import { fromMinorUnits, toMinorUnits } from './amounts.ts';
|
|
6
|
+
import type { Money } from './shopify-values.ts';
|
|
7
|
+
import type { SellingPlan, SellingPlanGroup, SellingPlanAllocation } from './fragments.ts';
|
|
8
|
+
|
|
9
|
+
const PERCENT = 100;
|
|
10
|
+
|
|
11
|
+
/** A plan with the group it belongs to. */
|
|
12
|
+
export type PlanInGroup = SellingPlan & { group: SellingPlanGroup };
|
|
13
|
+
export interface ProductWithPlans {
|
|
14
|
+
sellingPlanGroups?: { nodes: SellingPlanGroup[] } | null;
|
|
15
|
+
}
|
|
16
|
+
/** A variant as far as plans go. A cart line's variant has no allocations. */
|
|
17
|
+
export interface VariantWithPlans {
|
|
18
|
+
price: Money;
|
|
19
|
+
sellingPlanAllocations?: { nodes: SellingPlanAllocation[] } | null;
|
|
20
|
+
}
|
|
21
|
+
export type PlanAdjustment =
|
|
22
|
+
{ type: 'percentage'; value: number } | { type: 'amount'; value: Money } | { type: 'price'; value: Money };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param value A gid, a numeric id, or anything else.
|
|
26
|
+
* @param type The Shopify type, e.g. `ProductVariant`.
|
|
27
|
+
* @returns The gid of a numeric id; a gid or other value as written; `null` when empty.
|
|
28
|
+
*/
|
|
29
|
+
export function toGid(value: unknown, type: string): string | null {
|
|
30
|
+
const text = typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
|
|
31
|
+
if (!text) return null;
|
|
32
|
+
if (text.startsWith('gid://')) return text;
|
|
33
|
+
if (/^\d+$/.test(text)) return `gid://shopify/${type}/${text}`;
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const GID = {
|
|
38
|
+
product: (value: unknown) => toGid(value, 'Product'),
|
|
39
|
+
variant: (value: unknown) => toGid(value, 'ProductVariant'),
|
|
40
|
+
sellingPlan: (value: unknown) => toGid(value, 'SellingPlan'),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** @returns All plans of the product, flattened. */
|
|
44
|
+
export function allPlans(product: ProductWithPlans | null | undefined): PlanInGroup[] {
|
|
45
|
+
const groups = product?.sellingPlanGroups?.nodes ?? [];
|
|
46
|
+
return groups.flatMap((group) => group.sellingPlans.nodes.map((plan) => ({ ...plan, group })));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function hasPlans(product: ProductWithPlans | null | undefined): boolean {
|
|
50
|
+
return allPlans(product).length > 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @returns The variant's allocation on the plan; `null` when the variant isn't in it. */
|
|
54
|
+
export function allocationFor(
|
|
55
|
+
variant: VariantWithPlans | null | undefined,
|
|
56
|
+
planId: string | null | undefined,
|
|
57
|
+
): SellingPlanAllocation | null {
|
|
58
|
+
if (!variant || !planId) return null;
|
|
59
|
+
const allocations = variant.sellingPlanAllocations?.nodes ?? [];
|
|
60
|
+
return allocations.find((allocation) => allocation.sellingPlan.id === planId) ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @returns The plans this variant can be bought on. */
|
|
64
|
+
export function plansForVariant(
|
|
65
|
+
product: ProductWithPlans | null | undefined,
|
|
66
|
+
variant: VariantWithPlans | null | undefined,
|
|
67
|
+
): PlanInGroup[] {
|
|
68
|
+
return allPlans(product).filter((plan) => allocationFor(variant, plan.id));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @returns The price per delivery on the plan; the one-time price when the variant isn't on it. */
|
|
72
|
+
export function planPrice(
|
|
73
|
+
variant: VariantWithPlans | null | undefined,
|
|
74
|
+
planId: string | null | undefined,
|
|
75
|
+
): Money | null {
|
|
76
|
+
const adjustment = allocationFor(variant, planId)?.priceAdjustments[0];
|
|
77
|
+
return adjustment?.perDeliveryPrice ?? adjustment?.price ?? variant?.price ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @returns What the customer pays at checkout on the plan (prepaid plans charge more up front). */
|
|
81
|
+
export function planCheckoutCharge(
|
|
82
|
+
variant: VariantWithPlans | null | undefined,
|
|
83
|
+
planId: string | null | undefined,
|
|
84
|
+
): Money | null {
|
|
85
|
+
const allocation = allocationFor(variant, planId);
|
|
86
|
+
return allocation?.checkoutChargeAmount ?? allocation?.priceAdjustments[0]?.price ?? variant?.price ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @returns What the plan saves on the one-time price; no amount and 0% when it saves nothing. */
|
|
90
|
+
export function planSavings(
|
|
91
|
+
variant: VariantWithPlans | null | undefined,
|
|
92
|
+
planId: string | null | undefined,
|
|
93
|
+
): { amount: Money | null; percentage: number } {
|
|
94
|
+
const onPlanPrice = planPrice(variant, planId);
|
|
95
|
+
if (!variant || !onPlanPrice) return { amount: null, percentage: 0 };
|
|
96
|
+
const oneTime = parseFloat(variant.price.amount);
|
|
97
|
+
const onPlan = parseFloat(onPlanPrice.amount);
|
|
98
|
+
if (!Number.isFinite(oneTime) || !Number.isFinite(onPlan) || oneTime <= 0 || onPlan >= oneTime) {
|
|
99
|
+
return { amount: null, percentage: 0 };
|
|
100
|
+
}
|
|
101
|
+
const saved = toMinorUnits(variant.price) - toMinorUnits(onPlanPrice);
|
|
102
|
+
return {
|
|
103
|
+
amount: fromMinorUnits(saved, variant.price.currencyCode),
|
|
104
|
+
percentage: Math.round((1 - onPlan / oneTime) * PERCENT),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @returns The plan's own discount, whatever the variant: a percentage, a fixed amount off or a fixed price.
|
|
110
|
+
*/
|
|
111
|
+
export function planAdjustment(plan: Pick<SellingPlan, 'priceAdjustments'> | null | undefined): PlanAdjustment | null {
|
|
112
|
+
const value = plan?.priceAdjustments[0]?.adjustmentValue;
|
|
113
|
+
if (!value) return null;
|
|
114
|
+
switch (value.__typename) {
|
|
115
|
+
case 'SellingPlanPercentagePriceAdjustment':
|
|
116
|
+
return { type: 'percentage', value: value.adjustmentPercentage };
|
|
117
|
+
case 'SellingPlanFixedAmountPriceAdjustment':
|
|
118
|
+
return { type: 'amount', value: value.adjustmentAmount };
|
|
119
|
+
case 'SellingPlanFixedPriceAdjustment':
|
|
120
|
+
return { type: 'price', value: value.price };
|
|
121
|
+
default:
|
|
122
|
+
// An adjustment type newer than the API version Tether was written for.
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Finds a plan by id, numeric id, name or option value (case-insensitive), so Webflow editors can write
|
|
129
|
+
* `tether-default-plan="Every month"`.
|
|
130
|
+
*/
|
|
131
|
+
export function findPlan(
|
|
132
|
+
product: ProductWithPlans | null | undefined,
|
|
133
|
+
ref: string | null | undefined,
|
|
134
|
+
): PlanInGroup | null {
|
|
135
|
+
if (!ref) return null;
|
|
136
|
+
const plans = allPlans(product);
|
|
137
|
+
const gid = GID.sellingPlan(ref);
|
|
138
|
+
const needle = ref.trim().toLowerCase();
|
|
139
|
+
return (
|
|
140
|
+
plans.find((plan) => plan.id === gid) ??
|
|
141
|
+
plans.find((plan) => plan.name.toLowerCase() === needle) ??
|
|
142
|
+
plans.find((plan) => plan.options.some((option) => option.value.toLowerCase() === needle)) ??
|
|
143
|
+
null
|
|
144
|
+
);
|
|
145
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@occluze/tether/server`: Shopify product data at build time or on the server (Astro, Eleventy, Node, Cloudflare
|
|
3
|
+
* Workers). No browser APIs. It sends the browser script's own queries, so products handed over with `hydrate()` are
|
|
4
|
+
* exactly what the script expects and need no request of their own.
|
|
5
|
+
*
|
|
6
|
+
* import { createStorefront } from '@occluze/tether/server';
|
|
7
|
+
* const shop = createStorefront({ domain, token, country: 'NL' });
|
|
8
|
+
* const product = await shop.getProduct('coffee-box');
|
|
9
|
+
* const all = await shop.allProducts();
|
|
10
|
+
*/
|
|
11
|
+
import { DEFAULTS, resolveConfig } from './config.ts';
|
|
12
|
+
import { request, hasData, withContext, TetherError } from './client.ts';
|
|
13
|
+
import * as plans from './selling-plans.ts';
|
|
14
|
+
import { imageUrl, formatPercent } from './dom.ts';
|
|
15
|
+
import { initialState } from './initial.ts';
|
|
16
|
+
import { STORE_ENV_NAMES, formatMoney, productKey, productUrl as productUrlFor } from './shopify-values.ts';
|
|
17
|
+
import { createCatalog } from './storefront/catalog.ts';
|
|
18
|
+
import { jsonLdData, scriptJson } from './storefront/json-ld.ts';
|
|
19
|
+
import type { TetherConfig, TetherOptions } from './config.ts';
|
|
20
|
+
import type { Product } from './fragments.ts';
|
|
21
|
+
import type { Money } from './shopify-values.ts';
|
|
22
|
+
import type { InitialOptions, InitialState } from './initial.ts';
|
|
23
|
+
import type { JsonLdOptions } from './storefront/json-ld.ts';
|
|
24
|
+
|
|
25
|
+
export { imageUrl, initialState, formatPercent, formatMoney, productKey };
|
|
26
|
+
|
|
27
|
+
export { TetherError, plans, withContext };
|
|
28
|
+
|
|
29
|
+
// The types a site uses by name, under the names the docs give them.
|
|
30
|
+
export type { StorefrontOptions, HydrateOptions };
|
|
31
|
+
export type { Money } from './shopify-values.ts';
|
|
32
|
+
export type { Product as ShopifyProduct, ProductVariant as ShopifyVariant } from './fragments.ts';
|
|
33
|
+
export type { ShopifyCart, ShopifyCartLine } from './cart/store.ts';
|
|
34
|
+
export type { Collection as ShopifyCollection, ProductQuery } from './loader.ts';
|
|
35
|
+
export type { InitialOptions, InitialState } from './initial.ts';
|
|
36
|
+
export type { ImageSize } from './images.ts';
|
|
37
|
+
export type { JsonLdOptions } from './storefront/json-ld.ts';
|
|
38
|
+
/** What `createStorefront()` returns. */
|
|
39
|
+
export type Storefront = ReturnType<typeof createStorefront>;
|
|
40
|
+
|
|
41
|
+
/** The options only a storefront has. */
|
|
42
|
+
interface ServerOptions {
|
|
43
|
+
/** Replaces the global `fetch` (tests, caching). */
|
|
44
|
+
fetch?: typeof fetch;
|
|
45
|
+
/** Products per request for `allProducts()` (default 25). */
|
|
46
|
+
pageSize?: number;
|
|
47
|
+
/** The site's address, for product URLs in `jsonLd()` without `url`. */
|
|
48
|
+
siteUrl?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The config options, or an `import.meta.env` spread into them: its `PUBLIC_SHOPIFY_*` names give the domain and
|
|
53
|
+
* token, its other keys are ignored.
|
|
54
|
+
*/
|
|
55
|
+
type StorefrontOptions = TetherOptions & ServerOptions & StoreEnv;
|
|
56
|
+
|
|
57
|
+
/** The environment names that give the domain and the token. */
|
|
58
|
+
type StoreEnv = Partial<Record<(typeof STORE_ENV_NAMES)['domain' | 'token'][number], string>>;
|
|
59
|
+
|
|
60
|
+
type ServerConfig = TetherConfig & ServerOptions;
|
|
61
|
+
|
|
62
|
+
interface HydrateOptions {
|
|
63
|
+
card?: boolean;
|
|
64
|
+
images?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
68
|
+
/** The images a card keeps: the main one and a hover image (`tether-bind="image:2"`). */
|
|
69
|
+
const DEFAULT_CARD_IMAGES = 2;
|
|
70
|
+
/** The version of the `data-tether-hydrate` payload, for the browser script reading it. */
|
|
71
|
+
const HYDRATE_VERSION = 1;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @returns `productPath` `/products` → `/products/<handle>`; templates with `{handle}` and `{id}` work too. `null` when
|
|
75
|
+
* the path needs a handle or an id that is empty.
|
|
76
|
+
*/
|
|
77
|
+
export function productUrl(
|
|
78
|
+
handle: string,
|
|
79
|
+
{ productPath = DEFAULTS.productPath, id }: { productPath?: string; id?: string } = {},
|
|
80
|
+
): string | null {
|
|
81
|
+
return productUrlFor(productPath, handle, id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @returns The first of the `names` set in `options`, `''` without one. */
|
|
85
|
+
function fromEnv(options: StoreEnv, names: readonly (keyof StoreEnv)[]): string {
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const value = options[name];
|
|
88
|
+
if (typeof value === 'string' && value) return value;
|
|
89
|
+
}
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @returns The config options of `options`, resolved and validated as the browser's are, with the domain and token
|
|
95
|
+
* from the environment names when they aren't set.
|
|
96
|
+
*/
|
|
97
|
+
function storefrontConfig(options: StorefrontOptions): ServerConfig {
|
|
98
|
+
const known: Record<string, unknown> = {};
|
|
99
|
+
for (const [key, value] of Object.entries(options)) {
|
|
100
|
+
if (key in DEFAULTS) known[key] = value;
|
|
101
|
+
}
|
|
102
|
+
const domain = typeof options.domain === 'string' && options.domain ? options.domain : '';
|
|
103
|
+
const token = typeof options.token === 'string' && options.token ? options.token : '';
|
|
104
|
+
const conf: ServerConfig = resolveConfig({
|
|
105
|
+
// Only config keys, each parsed by `resolveConfig`.
|
|
106
|
+
...known,
|
|
107
|
+
domain: domain || fromEnv(options, STORE_ENV_NAMES.domain),
|
|
108
|
+
token: token || fromEnv(options, STORE_ENV_NAMES.token),
|
|
109
|
+
});
|
|
110
|
+
if (options.fetch) conf.fetch = options.fetch;
|
|
111
|
+
if (options.pageSize !== undefined) conf.pageSize = options.pageSize;
|
|
112
|
+
if (options.siteUrl !== undefined) conf.siteUrl = options.siteUrl;
|
|
113
|
+
return conf;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @returns `pageSize` when it is a positive integer, else the default. */
|
|
117
|
+
const validPageSize = (pageSize: number | undefined): number =>
|
|
118
|
+
typeof pageSize === 'number' && Number.isInteger(pageSize) && pageSize > 0 ? pageSize : DEFAULT_PAGE_SIZE;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A product for a list (home, collections, recommendations): without descriptions and variant images, and only its
|
|
122
|
+
* first `images` images. Prices, stock and plans per variant stay.
|
|
123
|
+
*/
|
|
124
|
+
function cardOf(product: Product, images: number): Product {
|
|
125
|
+
return {
|
|
126
|
+
...product,
|
|
127
|
+
description: '',
|
|
128
|
+
descriptionHtml: '',
|
|
129
|
+
images: { nodes: product.images.nodes.slice(0, Math.max(1, images)) },
|
|
130
|
+
variants: { nodes: product.variants.nodes.map((variant) => ({ ...variant, image: null })) },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A storefront: the store's products and collections for the server, and the tags that hand them to the page.
|
|
136
|
+
*
|
|
137
|
+
* @throws {TetherError} `NOT_CONFIGURED` without a domain and a token.
|
|
138
|
+
*/
|
|
139
|
+
export function createStorefront(options: StorefrontOptions = {}) {
|
|
140
|
+
const conf = storefrontConfig(options);
|
|
141
|
+
if (!conf.domain || !conf.token) {
|
|
142
|
+
throw new TetherError(
|
|
143
|
+
'createStorefront needs domain and token: pass them, or import.meta.env with PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN',
|
|
144
|
+
{ code: 'NOT_CONFIGURED' },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const catalog = createCatalog(conf, validPageSize(conf.pageSize));
|
|
148
|
+
const site = { productPath: conf.productPath, siteUrl: conf.siteUrl };
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A `<script type="application/json" data-tether-hydrate>` tag that hands products to the browser script: it renders
|
|
152
|
+
* them at once, without a request of its own, then refreshes prices and stock once in the background. Astro:
|
|
153
|
+
* `<Fragment set:html={shop.hydrate(products)} />`.
|
|
154
|
+
*
|
|
155
|
+
* @param options `card: true` for lists: about 40% smaller (see `cardOf`). The full product comes with the
|
|
156
|
+
* background refresh.
|
|
157
|
+
*/
|
|
158
|
+
function hydrate(
|
|
159
|
+
products: Product | null | undefined | (Product | null | undefined)[],
|
|
160
|
+
{ card = false, images = DEFAULT_CARD_IMAGES }: HydrateOptions = {},
|
|
161
|
+
): string {
|
|
162
|
+
const present = (Array.isArray(products) ? products : [products]).filter((product) => product != null);
|
|
163
|
+
const payload = {
|
|
164
|
+
v: HYDRATE_VERSION,
|
|
165
|
+
at: new Date().toISOString(),
|
|
166
|
+
country: conf.country || null,
|
|
167
|
+
language: conf.language || null,
|
|
168
|
+
products: card ? present.map((product) => cardOf(product, images)) : present,
|
|
169
|
+
};
|
|
170
|
+
return `<script type="application/json" data-tether-hydrate>${scriptJson(payload)}</script>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @returns The `<script type="application/ld+json">` tag, `''` without a product. */
|
|
174
|
+
function jsonLd(product: Product | null | undefined, jsonLdOptions: JsonLdOptions = {}): string {
|
|
175
|
+
if (!product) return '';
|
|
176
|
+
return `<script type="application/ld+json">${scriptJson(jsonLdData(product, jsonLdOptions, site))}</script>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `initialState()` with the storefront's `defaultPurchase` and `lowStock`. */
|
|
180
|
+
function storefrontInitialState(product: Product, initialOptions?: InitialOptions): InitialState;
|
|
181
|
+
function storefrontInitialState(
|
|
182
|
+
product: Product | null | undefined,
|
|
183
|
+
initialOptions?: InitialOptions,
|
|
184
|
+
): InitialState | null;
|
|
185
|
+
function storefrontInitialState(
|
|
186
|
+
product: Product | null | undefined,
|
|
187
|
+
initialOptions: InitialOptions = {},
|
|
188
|
+
): InitialState | null {
|
|
189
|
+
return initialState(product, { defaultPurchase: conf.defaultPurchase, lowStock: conf.lowStock, ...initialOptions });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
config: conf,
|
|
194
|
+
query: <T>(query: string, variables?: Record<string, unknown>, requestOptions?: { context?: boolean }) =>
|
|
195
|
+
// The caller's own query text decides the shape of `data`, and the caller's `T` states it.
|
|
196
|
+
request(conf, query, variables ?? {}, hasData, requestOptions) as Promise<T>,
|
|
197
|
+
...catalog,
|
|
198
|
+
hydrate,
|
|
199
|
+
formatMoney: (money: Money | null | undefined, locale: string | undefined = conf.locale) =>
|
|
200
|
+
formatMoney(money, locale),
|
|
201
|
+
formatPercent: (percentage: number, locale: string | undefined = conf.locale) => formatPercent(percentage, locale),
|
|
202
|
+
imageUrl,
|
|
203
|
+
jsonLd,
|
|
204
|
+
jsonLdData: (product: Product | null | undefined, jsonLdOptions: JsonLdOptions = {}) =>
|
|
205
|
+
jsonLdData(product, jsonLdOptions, site),
|
|
206
|
+
initialState: storefrontInitialState,
|
|
207
|
+
productUrl: (handle: string, urlOptions: { productPath?: string; id?: string } = {}) =>
|
|
208
|
+
productUrl(handle, { productPath: conf.productPath, ...urlOptions }),
|
|
209
|
+
plans,
|
|
210
|
+
};
|
|
211
|
+
}
|