@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.
Files changed (215) hide show
  1. package/README.md +28 -28
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-CXMA5RPZ.js +1 -0
  4. package/dist/esm/chunks/account-pages-IZO7FEOZ.js +1 -0
  5. package/dist/esm/chunks/analytics-PG5FPRDN.js +1 -0
  6. package/dist/esm/chunks/chunk-4HD4J5JC.js +1 -0
  7. package/dist/esm/chunks/chunk-6TFMLT4A.js +4 -0
  8. package/dist/esm/chunks/chunk-BBVUVPMO.js +1 -0
  9. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  10. package/dist/esm/chunks/chunk-EW5SYO6L.js +1 -0
  11. package/dist/esm/chunks/chunk-HJY2JSNB.js +1 -0
  12. package/dist/esm/chunks/chunk-MQYB6BH4.js +1 -0
  13. package/dist/esm/chunks/chunk-N3V45JPS.js +1 -0
  14. package/dist/esm/chunks/chunk-RPMBCAGS.js +1 -0
  15. package/dist/esm/chunks/chunk-VFNHIGCS.js +1 -0
  16. package/dist/esm/chunks/chunk-WT7RGGJU.js +1 -0
  17. package/dist/esm/chunks/chunk-YN6LEBEN.js +1 -0
  18. package/dist/esm/chunks/chunk-Z6HS3H7K.js +1 -0
  19. package/dist/esm/chunks/diagnose-XCEBQKBO.js +14 -0
  20. package/dist/esm/chunks/extras-4HU3VSBQ.js +1 -0
  21. package/dist/esm/chunks/markets-ui-M4IQ62CT.js +1 -0
  22. package/dist/esm/chunks/search-A5DS2IOS.js +1 -0
  23. package/dist/esm/index.js +1 -1
  24. package/dist/esm/server.js +2 -2
  25. package/dist/presets/barba.esm.js +19 -23
  26. package/dist/tether.account-pages.js +332 -367
  27. package/dist/tether.account-pages.min.js +1 -1
  28. package/dist/tether.account.js +253 -492
  29. package/dist/tether.account.min.js +1 -1
  30. package/dist/tether.analytics.js +82 -89
  31. package/dist/tether.analytics.min.js +1 -1
  32. package/dist/tether.diagnose.js +52 -34
  33. package/dist/tether.diagnose.min.js +2 -2
  34. package/dist/tether.extras.js +42 -37
  35. package/dist/tether.extras.min.js +1 -1
  36. package/dist/tether.js +1361 -935
  37. package/dist/tether.markets.js +89 -91
  38. package/dist/tether.markets.min.js +1 -1
  39. package/dist/tether.min.js +0 -0
  40. package/dist/tether.min.js.map +4 -4
  41. package/dist/tether.search.js +60 -69
  42. package/dist/tether.search.min.js +1 -1
  43. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  44. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  45. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  46. package/dist/types/server/src/shared/cart-id.d.ts +2 -0
  47. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  48. package/dist/types/server/src/shared/json.d.ts +25 -0
  49. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  50. package/dist/types/src/account/addresses-view.d.ts +5 -0
  51. package/dist/types/src/account/answers.d.ts +40 -0
  52. package/dist/types/src/account/markup.d.ts +19 -0
  53. package/dist/types/src/account/operations.d.ts +41 -0
  54. package/dist/types/src/account/order-lines.d.ts +13 -0
  55. package/dist/types/src/account/order-list.d.ts +12 -0
  56. package/dist/types/src/account/order-page.d.ts +6 -0
  57. package/dist/types/src/account/page-answers.d.ts +182 -0
  58. package/dist/types/src/account/page-events.d.ts +2 -0
  59. package/dist/types/src/account/server-client.d.ts +60 -0
  60. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  61. package/dist/types/src/account-api.d.ts +18 -0
  62. package/dist/types/src/account-marks.d.ts +15 -0
  63. package/dist/types/src/account-pages.d.ts +8 -0
  64. package/dist/types/src/account.d.ts +64 -0
  65. package/dist/types/src/amounts.d.ts +12 -0
  66. package/dist/types/src/analytics-shopify.d.ts +41 -0
  67. package/dist/types/src/analytics.d.ts +2 -0
  68. package/dist/types/src/api-version.d.ts +28 -0
  69. package/dist/types/src/astro.d.ts +42 -0
  70. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  71. package/dist/types/src/cart/checkout.d.ts +16 -0
  72. package/dist/types/src/cart/drawer.d.ts +18 -0
  73. package/dist/types/src/cart/mutations.d.ts +85 -0
  74. package/dist/types/src/cart/render.d.ts +17 -0
  75. package/dist/types/src/cart/store.d.ts +185 -0
  76. package/dist/types/src/cart.d.ts +106 -0
  77. package/dist/types/src/client.d.ts +134 -0
  78. package/dist/types/src/collection.d.ts +25 -0
  79. package/dist/types/src/config-sections.d.ts +40 -0
  80. package/dist/types/src/config.d.ts +190 -0
  81. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  82. package/dist/types/src/controls/discount.d.ts +2 -0
  83. package/dist/types/src/controls/extras.d.ts +5 -0
  84. package/dist/types/src/controls/navigation.d.ts +12 -0
  85. package/dist/types/src/diagnose-markup.d.ts +19 -0
  86. package/dist/types/src/diagnose.d.ts +60 -0
  87. package/dist/types/src/dom.d.ts +75 -0
  88. package/dist/types/src/extras.d.ts +21 -0
  89. package/dist/types/src/fragments.d.ts +158 -0
  90. package/dist/types/src/globals.d.ts +15 -0
  91. package/dist/types/src/images.d.ts +20 -0
  92. package/dist/types/src/index.d.ts +167 -0
  93. package/dist/types/src/initial.d.ts +56 -0
  94. package/dist/types/src/lazy.d.ts +28 -0
  95. package/dist/types/src/loader.d.ts +84 -0
  96. package/dist/types/src/market-codes.d.ts +6 -0
  97. package/dist/types/src/markets-ui.d.ts +32 -0
  98. package/dist/types/src/markets.d.ts +72 -0
  99. package/dist/types/src/markup.d.ts +131 -0
  100. package/dist/types/src/mounting.d.ts +46 -0
  101. package/dist/types/src/page-market.d.ts +27 -0
  102. package/dist/types/src/presets/barba.d.ts +56 -0
  103. package/dist/types/src/product-events.d.ts +2 -0
  104. package/dist/types/src/product-render.d.ts +17 -0
  105. package/dist/types/src/product-shape.d.ts +90 -0
  106. package/dist/types/src/product-state.d.ts +110 -0
  107. package/dist/types/src/product.d.ts +66 -0
  108. package/dist/types/src/repeaters.d.ts +16 -0
  109. package/dist/types/src/search.d.ts +33 -0
  110. package/dist/types/src/selling-plans.d.ts +64 -0
  111. package/dist/types/src/server.d.ts +88 -0
  112. package/dist/types/src/shopify-values.d.ts +46 -0
  113. package/dist/types/src/status.d.ts +33 -0
  114. package/dist/types/src/storage.d.ts +12 -0
  115. package/dist/types/src/store-html.d.ts +39 -0
  116. package/dist/types/src/storefront/catalog.d.ts +38 -0
  117. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  118. package/dist/types/src/variant-render.d.ts +14 -0
  119. package/package.json +24 -24
  120. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  121. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  122. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  123. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  124. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  125. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  126. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  127. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  128. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  129. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  130. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  131. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  132. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  133. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  134. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  135. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  136. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  137. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  138. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  139. package/src/account/addresses-view.js +0 -92
  140. package/src/account/browser-session.js +0 -359
  141. package/src/account/markup.js +0 -48
  142. package/src/account/operations.js +0 -180
  143. package/src/account/order-lines.js +0 -96
  144. package/src/account/order-list.js +0 -91
  145. package/src/account/order-page.js +0 -192
  146. package/src/account/page-events.js +0 -288
  147. package/src/account/server-client.js +0 -172
  148. package/src/account/shared.js +0 -111
  149. package/src/account/shopify-auth.js +0 -150
  150. package/src/account/subscriptions-view.js +0 -158
  151. package/src/account-api.js +0 -118
  152. package/src/account-links.js +0 -35
  153. package/src/account-pages.js +0 -89
  154. package/src/account.js +0 -342
  155. package/src/amounts.js +0 -36
  156. package/src/analytics-shopify.js +0 -241
  157. package/src/analytics.js +0 -352
  158. package/src/api-version.js +0 -85
  159. package/src/astro.js +0 -266
  160. package/src/cart/checkout.js +0 -97
  161. package/src/cart/drawer.js +0 -138
  162. package/src/cart/mutations.js +0 -234
  163. package/src/cart/render.js +0 -294
  164. package/src/cart/store.js +0 -326
  165. package/src/cart.js +0 -391
  166. package/src/client.js +0 -267
  167. package/src/collection.js +0 -183
  168. package/src/config.js +0 -349
  169. package/src/controls/cart-controls.js +0 -155
  170. package/src/controls/discount.js +0 -96
  171. package/src/controls/extras.js +0 -63
  172. package/src/controls/navigation.js +0 -83
  173. package/src/diagnose-markup.js +0 -333
  174. package/src/diagnose.js +0 -295
  175. package/src/dom.js +0 -287
  176. package/src/extras.js +0 -145
  177. package/src/fragments.js +0 -237
  178. package/src/images.js +0 -104
  179. package/src/index.js +0 -353
  180. package/src/initial.js +0 -87
  181. package/src/lazy-iife.js +0 -116
  182. package/src/lazy.js +0 -39
  183. package/src/loader.js +0 -337
  184. package/src/market-codes.js +0 -35
  185. package/src/markets-ui.js +0 -204
  186. package/src/markets.js +0 -334
  187. package/src/markup.js +0 -64
  188. package/src/mounting.js +0 -277
  189. package/src/page-market.js +0 -116
  190. package/src/parts/account-pages.js +0 -6
  191. package/src/parts/account.js +0 -6
  192. package/src/parts/analytics.js +0 -6
  193. package/src/parts/diagnose.js +0 -6
  194. package/src/parts/extras.js +0 -6
  195. package/src/parts/markets.js +0 -6
  196. package/src/parts/search.js +0 -6
  197. package/src/presets/barba.js +0 -190
  198. package/src/product-events.js +0 -130
  199. package/src/product-render.js +0 -257
  200. package/src/product-state.js +0 -318
  201. package/src/product.js +0 -344
  202. package/src/repeaters.js +0 -173
  203. package/src/search.js +0 -323
  204. package/src/selling-plans.js +0 -154
  205. package/src/server.js +0 -215
  206. package/src/shopify-values.js +0 -89
  207. package/src/status.js +0 -126
  208. package/src/store-html.js +0 -0
  209. package/src/storefront/catalog.js +0 -199
  210. package/src/storefront/json-ld.js +0 -167
  211. package/src/variant-render.js +0 -165
  212. package/types/astro.d.ts +0 -28
  213. package/types/barba.d.ts +0 -16
  214. package/types/index.d.ts +0 -577
  215. 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
- }
@@ -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
- }