@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.
Files changed (259) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -2
  24. package/dist/presets/barba.esm.js +19 -23
  25. package/dist/tether.account-pages.js +320 -365
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +242 -491
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +66 -88
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +36 -33
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +28 -36
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +1270 -894
  36. package/dist/tether.markets.js +73 -90
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +44 -68
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +24 -23
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/{order-lines.js → order-lines.ts} +20 -41
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/{page-events.js → page-events.ts} +55 -90
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/{account-pages.js → account-pages.ts} +29 -37
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
  135. package/src/analytics.ts +315 -0
  136. package/src/{api-version.js → api-version.ts} +25 -31
  137. package/src/{astro.js → astro.ts} +110 -104
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/{drawer.js → drawer.ts} +18 -31
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/{render.js → render.ts} +68 -96
  143. package/src/cart/store.ts +279 -0
  144. package/src/{cart.js → cart.ts} +82 -128
  145. package/src/client.ts +391 -0
  146. package/src/{collection.js → collection.ts} +30 -38
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
  150. package/src/controls/{discount.js → discount.ts} +13 -34
  151. package/src/controls/{extras.js → extras.ts} +15 -24
  152. package/src/controls/{navigation.js → navigation.ts} +24 -30
  153. package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
  154. package/src/{diagnose.js → diagnose.ts} +95 -96
  155. package/src/dom.ts +268 -0
  156. package/src/{extras.js → extras.ts} +43 -52
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/{images.js → images.ts} +30 -43
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/{loader.js → loader.ts} +149 -123
  165. package/src/market-codes.ts +6 -0
  166. package/src/{markets-ui.js → markets-ui.ts} +91 -68
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/{mounting.js → mounting.ts} +88 -101
  170. package/src/{page-market.js → page-market.ts} +32 -38
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/{product-events.js → product-events.ts} +33 -48
  181. package/src/{product-render.js → product-render.ts} +38 -77
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/{product.js → product.ts} +90 -126
  185. package/src/{repeaters.js → repeaters.ts} +40 -52
  186. package/src/{search.js → search.ts} +85 -103
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/{shopify-values.js → shopify-values.ts} +24 -22
  190. package/src/{status.js → status.ts} +34 -54
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
  195. package/src/{variant-render.js → variant-render.ts} +24 -50
  196. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  197. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  198. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  199. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  200. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  201. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  202. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  203. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  204. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  205. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  206. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  207. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  208. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  209. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  210. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  211. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  212. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  213. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  214. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  215. package/src/account/addresses-view.js +0 -92
  216. package/src/account/browser-session.js +0 -359
  217. package/src/account/markup.js +0 -48
  218. package/src/account/operations.js +0 -180
  219. package/src/account/order-list.js +0 -91
  220. package/src/account/order-page.js +0 -192
  221. package/src/account/server-client.js +0 -172
  222. package/src/account/shared.js +0 -111
  223. package/src/account/shopify-auth.js +0 -150
  224. package/src/account-api.js +0 -118
  225. package/src/account-links.js +0 -35
  226. package/src/account.js +0 -342
  227. package/src/amounts.js +0 -36
  228. package/src/analytics.js +0 -352
  229. package/src/cart/checkout.js +0 -97
  230. package/src/cart/mutations.js +0 -234
  231. package/src/cart/store.js +0 -326
  232. package/src/client.js +0 -267
  233. package/src/config.js +0 -349
  234. package/src/dom.js +0 -287
  235. package/src/fragments.js +0 -237
  236. package/src/index.js +0 -353
  237. package/src/initial.js +0 -87
  238. package/src/lazy-iife.js +0 -116
  239. package/src/lazy.js +0 -39
  240. package/src/market-codes.js +0 -35
  241. package/src/markets.js +0 -334
  242. package/src/markup.js +0 -64
  243. package/src/parts/account-pages.js +0 -6
  244. package/src/parts/account.js +0 -6
  245. package/src/parts/analytics.js +0 -6
  246. package/src/parts/diagnose.js +0 -6
  247. package/src/parts/extras.js +0 -6
  248. package/src/parts/markets.js +0 -6
  249. package/src/parts/search.js +0 -6
  250. package/src/presets/barba.js +0 -190
  251. package/src/product-state.js +0 -318
  252. package/src/selling-plans.js +0 -154
  253. package/src/server.js +0 -215
  254. package/src/store-html.js +0 -0
  255. package/src/storefront/catalog.js +0 -199
  256. package/types/astro.d.ts +0 -28
  257. package/types/barba.d.ts +0 -16
  258. package/types/index.d.ts +0 -577
  259. 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.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' */
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
- /** @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 */
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
- /** @type {WeakSet<Element>} */
53
- const mounted = new WeakSet();
77
+ const mounted = new WeakSet<Element>();
54
78
  let searchBoxCount = 0;
55
79
 
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) {
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
- * @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'));
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
- * @param {Element} node
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
- * @param {HTMLElement} root
101
- * @param {HTMLInputElement} input
102
- * @param {HTMLElement} results
103
- */
104
- constructor(root, input, results) {
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(/** @type {Node | null} */ (event.target))) this.open(false);
162
+ if (!this.root.contains(event.target as Node | null)) this.open(false);
153
163
  });
154
164
  }
155
165
 
156
- /** @param {SearchState} state */
157
- setState(state) {
166
+ setState(state: SearchState) {
158
167
  this.root.setAttribute(attr('search-state'), state);
159
168
  }
160
169
 
161
- /** @param {boolean} open */
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
- /** @type {{ predictiveSearch: SearchResults | null }} */
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
- /** @param {string} term */
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
- * @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) {
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
- /** @param {string} text */
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
- /** @param {number} count */
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
- /** @type {Partial<Pick<HTMLElement, 'scrollIntoView'>>} */ (option).scrollIntoView?.({ block: 'nearest' });
271
+ (option as Partial<Pick<HTMLElement, 'scrollIntoView'>>).scrollIntoView?.({ block: 'nearest' });
285
272
  }
286
273
 
287
- /** @param {KeyboardEvent} event */
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 = /** @type {HTMLInputElement | null} */ (
318
- el.querySelector(sel('search-input')) ?? el.querySelector('input')
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
+ }