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