@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
@@ -1,257 +0,0 @@
1
- /**
2
- * The DOM writes of a product scope that depend on the product alone, once per product: its binds, the option and
3
- * plan lists and the options of its selects. `variant-render.js` writes what follows the visitor's choice.
4
- */
5
- import {
6
- attr,
7
- sel,
8
- get,
9
- has,
10
- qsa,
11
- formatMoney,
12
- formatDate,
13
- setText,
14
- setImage,
15
- setStoreHtml,
16
- show,
17
- repeat,
18
- hasTemplate,
19
- sameImage,
20
- productUrl,
21
- } from './dom.js';
22
- /** @import { Product, Image } from './fragments.js' */
23
- /** @import { PlanInGroup } from './selling-plans.js' */
24
-
25
- /**
26
- * @typedef {object} RenderScope The product scope as its renders see it.
27
- * @property {HTMLElement} root
28
- * @property {Product} product
29
- * @property {PlanInGroup[]} plans
30
- * @property {(selector: string) => HTMLElement[]} $ Matches in this scope, the root included.
31
- * @property {(authored: string | null) => string} optName The product's option name for one written in the markup.
32
- */
33
- /**
34
- * The shape `fragments.js` selects for an option value's swatch, which it types as `unknown`.
35
- *
36
- * @typedef {{ color: string | null, image: { previewImage: Image | null } | null } | null} Swatch
37
- */
38
-
39
- /** `tether-bind="image:2"`: the second product image. */
40
- const NTH_IMAGE_BIND = /^image:(\d+)$/;
41
- const METAFIELD_BIND = 'metafield:';
42
- const MULTI_LINE_METAFIELD = /^(rich_text|multi_line_text)_field$/;
43
- /** `tether-plan-list` values that list every plan, not one group: Webflow's Designer can't always leave it empty. */
44
- const ALL_PLAN_GROUPS = ['', 'true', 'all', '*'];
45
-
46
- /**
47
- * @param {Element} el
48
- * @returns {el is HTMLSelectElement}
49
- */
50
- export const isSelect = (el) => el.tagName === 'SELECT';
51
-
52
- /**
53
- * @param {RenderScope} scope
54
- * @param {string} selector
55
- * @returns {HTMLSelectElement[]} The `<select>` elements in the scope that match `selector`.
56
- */
57
- export const selectsIn = (scope, selector) => scope.$(`select${selector}`).filter(isSelect);
58
-
59
- /**
60
- * Leaves a server-rendered gallery with the same images as it is.
61
- *
62
- * @param {HTMLElement} el
63
- * @param {Product} product
64
- */
65
- function renderGallery(el, product) {
66
- const images = product.images.nodes;
67
- const rendered = [...el.children].map((node) => (node.tagName === 'IMG' ? node : node.querySelector('img')));
68
- const same =
69
- !hasTemplate(el) &&
70
- rendered.length === images.length &&
71
- rendered.every((img, index) => sameImage(img?.getAttribute('src'), images[index]?.url));
72
- if (same) return;
73
- repeat(el, images, (node, image) => {
74
- const target = node.matches(`img,${sel('bind', 'image')}`) ? node : (qsa(node, 'img')[0] ?? node);
75
- setImage(target, image.url, image.altText || product.title);
76
- });
77
- }
78
-
79
- /**
80
- * @param {HTMLElement} el
81
- * @param {Product} product
82
- * @param {string} ref `namespace.key`
83
- */
84
- function renderMetafield(el, product, ref) {
85
- const dot = ref.indexOf('.');
86
- const namespace = ref.slice(0, dot);
87
- const key = ref.slice(dot + 1);
88
- const metafield = (product.metafields ?? []).find((field) => field?.namespace === namespace && field.key === key);
89
- if (!metafield) {
90
- setText(el, '');
91
- return;
92
- }
93
- const image = metafield.reference?.image;
94
- if (image) {
95
- setImage(el, image.url, image.altText);
96
- return;
97
- }
98
- if (MULTI_LINE_METAFIELD.test(metafield.type)) el.style.whiteSpace = 'pre-line';
99
- setText(el, metafield.value);
100
- }
101
-
102
- /** @type {Map<string, (product: Product) => string>} The product binds written as text. */
103
- const PRODUCT_TEXTS = new Map(
104
- Object.entries({
105
- title: (product) => product.title,
106
- 'description-text': (product) => product.description,
107
- vendor: (product) => product.vendor,
108
- type: (product) => product.productType,
109
- handle: (product) => product.handle,
110
- 'published-at': (product) => formatDate(product.publishedAt),
111
- 'min-price': (product) => formatMoney(product.priceRange.minVariantPrice),
112
- 'max-price': (product) => formatMoney(product.priceRange.maxVariantPrice),
113
- }),
114
- );
115
-
116
- /**
117
- * @param {HTMLElement} el
118
- * @param {Product} product
119
- */
120
- function renderTags(el, product) {
121
- const exclude = (el.getAttribute('exclude') ?? '')
122
- .split(',')
123
- .map((tag) => tag.trim())
124
- .filter(Boolean);
125
- const tags = product.tags.filter((tag) => !exclude.includes(tag));
126
- repeat(el, tags, (node, tag) => {
127
- setText(node, tag);
128
- });
129
- }
130
-
131
- /**
132
- * A `tether-bind` of the product itself; binds of the variant are left to `writeVariant`.
133
- *
134
- * @param {HTMLElement} el
135
- * @param {string} key
136
- * @param {Product} product
137
- */
138
- function renderProductBind(el, key, product) {
139
- const text = PRODUCT_TEXTS.get(key);
140
- if (text) {
141
- setText(el, text(product));
142
- return;
143
- }
144
- switch (key) {
145
- case 'description':
146
- setStoreHtml(el, product.descriptionHtml);
147
- break;
148
- case 'featured-image':
149
- setImage(el, product.featuredImage?.url, product.featuredImage?.altText || product.title);
150
- break;
151
- case 'url':
152
- case 'link':
153
- el.setAttribute('href', productUrl(product.handle, product.id));
154
- if (key === 'url' && !el.firstElementChild && !el.textContent.trim()) setText(el, product.title);
155
- break;
156
- case 'images':
157
- renderGallery(el, product);
158
- break;
159
- case 'tags':
160
- renderTags(el, product);
161
- break;
162
- default:
163
- renderIndexedBind(el, key, product);
164
- }
165
- }
166
-
167
- /**
168
- * `image:<n>` (falling back to the featured image) and `metafield:<namespace.key>`.
169
- *
170
- * @param {HTMLElement} el
171
- * @param {string} key
172
- * @param {Product} product
173
- */
174
- function renderIndexedBind(el, key, product) {
175
- const nth = NTH_IMAGE_BIND.exec(key);
176
- if (nth) {
177
- const image = product.images.nodes[Number(nth[1]) - 1] ?? product.featuredImage;
178
- setImage(el, image?.url, image?.altText || product.title);
179
- } else if (key.startsWith(METAFIELD_BIND)) {
180
- renderMetafield(el, product, key.slice(METAFIELD_BIND.length));
181
- }
182
- }
183
-
184
- /** @param {RenderScope} scope */
185
- function renderOptionLists(scope) {
186
- const { product } = scope;
187
- scope.$(sel('option-list')).forEach((container) => {
188
- const name = scope.optName(get(container, 'option-list'));
189
- const option = product.options.find((candidate) => candidate.name === name);
190
- if (!option || (option.optionValues.length <= 1 && has(container, 'hide-single'))) {
191
- show(container, false);
192
- return;
193
- }
194
- repeat(container, option.optionValues, (node, value) => {
195
- node.setAttribute(attr('option-value'), value.name);
196
- const text = qsa(node, sel('bind', 'option-value'))[0];
197
- if (text) setText(text, value.name);
198
- else if (!node.firstElementChild) setText(node, value.name);
199
- const swatchEl = qsa(node, sel('bind', 'swatch'))[0];
200
- // fragments.js selects `swatch { color image { previewImage { … } } }`.
201
- const swatch = /** @type {Swatch} */ (value.swatch);
202
- if (!swatchEl || !swatch) return;
203
- if (swatch.color) swatchEl.style.backgroundColor = swatch.color;
204
- if (swatch.image?.previewImage) setImage(swatchEl, swatch.image.previewImage.url, value.name);
205
- });
206
- });
207
- }
208
-
209
- /** @param {RenderScope} scope */
210
- function renderSelects(scope) {
211
- const { product } = scope;
212
- selectsIn(scope, sel('option')).forEach((select) => {
213
- const option = product.options.find((candidate) => candidate.name === scope.optName(get(select, 'option')));
214
- if (!option) {
215
- show(select, false);
216
- return;
217
- }
218
- select.replaceChildren(...option.optionValues.map((value) => new Option(value.name, value.name)));
219
- });
220
- selectsIn(scope, sel('variant-select')).forEach((select) => {
221
- select.replaceChildren(...product.variants.nodes.map((variant) => new Option(variant.title, variant.id)));
222
- });
223
- selectsIn(scope, sel('plan')).forEach((select) => {
224
- const plans = scope.plans.map((plan) => new Option(plan.name, plan.id));
225
- const oneTime = !product.requiresSellingPlan && get(select, 'plan') !== 'subscription-only';
226
- const oneTimeLabel = select.getAttribute(attr('one-time-label')) || 'One-time purchase';
227
- select.replaceChildren(...(oneTime ? [new Option(oneTimeLabel, '')] : []), ...plans);
228
- });
229
- }
230
-
231
- /** @param {RenderScope} scope */
232
- function renderPlanLists(scope) {
233
- scope.$(sel('plan-list')).forEach((container) => {
234
- // The value is a selling plan group's name, or one of ALL_PLAN_GROUPS.
235
- const raw = (get(container, 'plan-list') ?? '').trim();
236
- const group = ALL_PLAN_GROUPS.includes(raw.toLowerCase()) ? null : raw;
237
- const plans = scope.plans.filter((plan) => !group || plan.group.name === group);
238
- repeat(container, plans, (node, plan) => {
239
- node.setAttribute(attr('plan-value'), plan.id);
240
- if (!node.firstElementChild && !node.querySelector(sel('plan-bind'))) setText(node, plan.name);
241
- });
242
- });
243
- }
244
-
245
- /**
246
- * Writes what depends on the product alone: its binds, option and plan lists and the selects' options.
247
- *
248
- * @param {RenderScope} scope
249
- */
250
- export function writeProduct(scope) {
251
- scope.$(sel('bind')).forEach((el) => {
252
- renderProductBind(el, get(el, 'bind') ?? '', scope.product);
253
- });
254
- renderOptionLists(scope);
255
- renderSelects(scope);
256
- renderPlanLists(scope);
257
- }
@@ -1,318 +0,0 @@
1
- /**
2
- * What a product scope shows for the visitor's choice: the variant, the purchase it is bought as, its prices, the
3
- * text of every `tether-bind` and every `tether-show` condition. Pure, no DOM and no config: the browser scope
4
- * (`product.js`) and the server's `initialState()` (`initial.js`) derive their view here, so a server-rendered page
5
- * shows what the script shows when it takes over.
6
- */
7
- import {
8
- GID,
9
- allPlans,
10
- allocationFor,
11
- findPlan,
12
- planAdjustment,
13
- planCheckoutCharge,
14
- planPrice,
15
- planSavings,
16
- } from './selling-plans.js';
17
- /** @import { Money } from './shopify-values.js' */
18
- /** @import { Product, ProductVariant } from './fragments.js' */
19
- /** @import { PlanInGroup } from './selling-plans.js' */
20
-
21
- /** @typedef {Record<string, string>} Selection Option name → chosen value. */
22
- /**
23
- * How the visitor buys. Buying once still names the product's plan when it has plans: the plan the subscription
24
- * binds (`subscription-price`, `plan-name`) offer next to the one-time price, and the one subscribing switches to.
25
- *
26
- * @typedef {{ kind: 'one-time', planId: string | null } | { kind: 'subscription', planId: string }} Purchase
27
- */
28
- /**
29
- * @typedef {object} ProductChoice What the visitor chose, before it is checked against the product.
30
- * @property {Selection} selected
31
- * @property {Purchase} purchase
32
- * @property {number} quantity
33
- */
34
- /**
35
- * @typedef {'in-stock' | 'out-of-stock' | 'backorder' | 'on-sale' | 'not-on-sale' | 'has-variants' | 'single-variant'
36
- * | 'low-stock' | 'plenty-in-stock' | 'subscription-available' | 'subscription-unavailable' | 'subscription-only'
37
- * | 'one-time-available' | 'subscription-selected' | 'one-time-selected' | 'has-savings'} ShowCondition
38
- */
39
- /**
40
- * @typedef {object} ProductView
41
- * @property {ProductVariant} variant
42
- * @property {Selection} selected The selection, matching `variant`.
43
- * @property {Purchase} purchase The choice, made valid for the product and `variant`.
44
- * @property {PlanInGroup | null} plan The purchase's plan, offered or chosen.
45
- * @property {boolean} isSubscription The line is bought on `plan`: subscription chosen and the variant is on it.
46
- * @property {boolean} subscriptionAvailable The variant is on at least one plan.
47
- * @property {number} quantity
48
- * @property {Money} price What one item costs as bought.
49
- * @property {Money | null} compareAtPrice Only when on sale.
50
- * @property {{ amount: Money | null, percentage: number }} savings What `plan` saves on the one-time price.
51
- * @property {Record<ShowCondition, boolean>} show
52
- */
53
- /**
54
- * @typedef {object} Formatters How the caller writes values as text.
55
- * @property {(money: Money | null) => string} money
56
- * @property {(percentage: number) => string} percent
57
- */
58
- /** @typedef {string | number | null | undefined} BindText What a bind writes; `null` and `undefined` clear it. */
59
-
60
- /** Money arithmetic works in hundredths, the precision Tether shows Shopify's amounts in. */
61
- const MINOR_UNITS_PER_UNIT = 100;
62
- const PERCENT = 100;
63
-
64
- /** @param {Money} money */
65
- const toMinorUnits = (money) => Math.round(parseFloat(money.amount) * MINOR_UNITS_PER_UNIT);
66
-
67
- /**
68
- * @param {number} minorUnits
69
- * @param {string} currencyCode
70
- * @returns {Money}
71
- */
72
- const fromMinorUnits = (minorUnits, currencyCode) => ({
73
- amount: (minorUnits / MINOR_UNITS_PER_UNIT).toFixed(2),
74
- currencyCode,
75
- });
76
-
77
- /**
78
- * @param {Money} money
79
- * @param {number} quantity
80
- */
81
- const timesQuantity = (money, quantity) => fromMinorUnits(toMinorUnits(money) * quantity, money.currencyCode);
82
-
83
- /**
84
- * @param {ProductVariant} variant
85
- * @returns {Selection}
86
- */
87
- export const selectionOf = (variant) =>
88
- Object.fromEntries(variant.selectedOptions.map((option) => [option.name, option.value]));
89
-
90
- /**
91
- * @param {Product} product
92
- * @param {Selection} selected
93
- * @returns {ProductVariant | null} The variant with exactly these option values.
94
- */
95
- function findVariant(product, selected) {
96
- return (
97
- product.variants.nodes.find((variant) =>
98
- variant.selectedOptions.every((option) => selected[option.name] === option.value),
99
- ) ?? null
100
- );
101
- }
102
-
103
- /**
104
- * @param {Product} product
105
- * @param {Selection} selected
106
- * @param {string} name An option name.
107
- * @param {string} value
108
- * @returns {boolean} True when a variant for sale has this value and the other selected values.
109
- */
110
- export function isValueAvailable(product, selected, name, value) {
111
- return product.variants.nodes.some(
112
- (variant) =>
113
- variant.availableForSale &&
114
- variant.selectedOptions.every((option) =>
115
- option.name === name ? option.value === value : selected[option.name] === option.value,
116
- ),
117
- );
118
- }
119
-
120
- /**
121
- * The purchase made valid for the product and variant: one-time without plans, a subscription when the product
122
- * requires one, and a plan the variant is on when it is on any (else a plan of the product, for the binds to offer).
123
- *
124
- * @param {Product} product
125
- * @param {ProductVariant | null} variant
126
- * @param {{ kind: Purchase['kind'], planId: string | null }} wanted
127
- * @returns {Purchase}
128
- */
129
- export function normalisePurchase(product, variant, wanted) {
130
- const plans = allPlans(product);
131
- const known = plans.find((plan) => plan.id === wanted.planId) ?? plans[0];
132
- if (!known) return { kind: 'one-time', planId: null };
133
- const onVariant = allocationFor(variant, known.id)
134
- ? known
135
- : (plans.find((plan) => allocationFor(variant, plan.id)) ?? known);
136
- if (product.requiresSellingPlan || wanted.kind === 'subscription')
137
- return { kind: 'subscription', planId: onVariant.id };
138
- return { kind: 'one-time', planId: onVariant.id };
139
- }
140
-
141
- /**
142
- * @typedef {object} InitialChoiceOptions
143
- * @property {string | null | undefined} [variant] A variant id or gid (`?variant=`); the first variant for sale without.
144
- * @property {string | null | undefined} [sellingPlan] A plan to subscribe on (`?selling_plan=`).
145
- * @property {string | null | undefined} [defaultPlan] The plan offered first, by id, name or option value.
146
- * @property {string | null | undefined} [defaultPurchase] `subscription` subscribes; anything else buys once.
147
- */
148
-
149
- /**
150
- * What a product starts with before the visitor chooses anything.
151
- *
152
- * @param {Product} product
153
- * @param {InitialChoiceOptions} options
154
- * @returns {Omit<ProductChoice, 'quantity'>}
155
- */
156
- export function initialChoice(product, { variant, sellingPlan, defaultPlan, defaultPurchase }) {
157
- const variants = product.variants.nodes;
158
- const wantedId = GID.variant(variant);
159
- const start =
160
- variants.find((candidate) => candidate.id === wantedId) ??
161
- variants.find((candidate) => candidate.availableForSale) ??
162
- variants[0] ??
163
- null;
164
- const fromUrl = findPlan(product, sellingPlan);
165
- const offered = fromUrl ?? findPlan(product, defaultPlan);
166
- const kind = fromUrl || defaultPurchase === 'subscription' ? 'subscription' : 'one-time';
167
- return {
168
- selected: start ? selectionOf(start) : {},
169
- purchase: normalisePurchase(product, start, { kind, planId: offered?.id ?? null }),
170
- };
171
- }
172
-
173
- /**
174
- * @param {ProductVariant} variant
175
- * @param {number} lowStock
176
- * @param {number} variantCount
177
- * @param {Pick<ProductView, 'isSubscription' | 'subscriptionAvailable' | 'compareAtPrice' | 'savings'>} sale
178
- * @param {boolean} requiresSellingPlan
179
- * @returns {Record<ShowCondition, boolean>}
180
- */
181
- function showConditions(variant, lowStock, variantCount, sale, requiresSellingPlan) {
182
- const stock = variant.quantityAvailable;
183
- const low = stock != null && stock > 0 && stock <= lowStock;
184
- const onSale = sale.compareAtPrice !== null;
185
- return {
186
- 'in-stock': variant.availableForSale,
187
- 'out-of-stock': !variant.availableForSale,
188
- backorder: variant.availableForSale && variant.currentlyNotInStock,
189
- 'on-sale': onSale,
190
- 'not-on-sale': !onSale,
191
- 'has-variants': variantCount > 1,
192
- 'single-variant': variantCount === 1,
193
- 'low-stock': low,
194
- 'plenty-in-stock': variant.availableForSale && !low,
195
- 'subscription-available': sale.subscriptionAvailable,
196
- 'subscription-unavailable': !sale.subscriptionAvailable,
197
- 'subscription-only': requiresSellingPlan,
198
- 'one-time-available': !requiresSellingPlan,
199
- 'subscription-selected': sale.isSubscription,
200
- 'one-time-selected': !sale.isSubscription,
201
- 'has-savings': sale.isSubscription ? sale.savings.percentage > 0 : onSale,
202
- };
203
- }
204
-
205
- /**
206
- * The view for the visitor's choice. A selection no variant has falls back to the first variant.
207
- *
208
- * @param {Product} product
209
- * @param {ProductChoice} choice
210
- * @param {number} lowStock `low-stock` at this many or fewer.
211
- * @returns {ProductView | null} `null` for a product without variants.
212
- */
213
- export function productView(product, choice, lowStock) {
214
- const exact = findVariant(product, choice.selected);
215
- const variant = exact ?? product.variants.nodes[0];
216
- if (!variant) return null;
217
- const purchase = normalisePurchase(product, variant, choice.purchase);
218
- const { planId } = purchase;
219
- const plans = allPlans(product);
220
- const allocation = purchase.kind === 'subscription' ? allocationFor(variant, planId) : null;
221
- // The plan helpers fall back to the one-time price only without a variant, which `variant` rules out.
222
- const price = allocation ? (planPrice(variant, planId) ?? variant.price) : variant.price;
223
- const compareAt = allocation?.priceAdjustments[0]?.compareAtPrice ?? variant.compareAtPrice;
224
- const sale = {
225
- isSubscription: allocation !== null,
226
- subscriptionAvailable: plans.some((plan) => allocationFor(variant, plan.id)),
227
- compareAtPrice: compareAt && parseFloat(compareAt.amount) > parseFloat(price.amount) ? compareAt : null,
228
- savings: planSavings(variant, planId),
229
- };
230
- return {
231
- ...sale,
232
- variant,
233
- selected: exact ? choice.selected : selectionOf(variant),
234
- purchase,
235
- plan: plans.find((plan) => plan.id === planId) ?? null,
236
- quantity: choice.quantity,
237
- price,
238
- show: showConditions(variant, lowStock, product.variants.nodes.length, sale, product.requiresSellingPlan),
239
- };
240
- }
241
-
242
- /**
243
- * The `tether-bind` values that follow the variant and purchase. Thunks, so only the binds on the page are computed.
244
- *
245
- * @param {ProductView} view
246
- * @param {Formatters} format
247
- * @returns {Map<string, () => BindText>}
248
- */
249
- export function variantBindTexts(view, format) {
250
- const { variant, plan, price, compareAtPrice, quantity } = view;
251
- const planId = view.purchase.planId;
252
- // Prices for the quantity count one item when it is 0 (an optional bundle item).
253
- const forQuantity = (/** @type {Money} */ money) => timesQuantity(money, Math.max(1, quantity));
254
- const checkoutCharge = () => {
255
- const charge = view.isSubscription ? (planCheckoutCharge(variant, planId) ?? price) : price;
256
- return format.money(forQuantity(charge));
257
- };
258
- const discountPercentage = () => {
259
- if (!compareAtPrice) return '0%';
260
- return `${Math.round((1 - parseFloat(price.amount) / parseFloat(compareAtPrice.amount)) * PERCENT)}%`;
261
- };
262
- const discountAmount = () => {
263
- if (!compareAtPrice) return '';
264
- return format.money(fromMinorUnits(toMinorUnits(compareAtPrice) - toMinorUnits(price), price.currencyCode));
265
- };
266
- /** @type {Record<string, () => BindText>} */
267
- const texts = {
268
- price: () => format.money(price),
269
- 'total-price': () => format.money(forQuantity(price)),
270
- 'one-time-price': () => format.money(variant.price),
271
- 'subscription-price': () => format.money(planId ? planPrice(variant, planId) : null),
272
- 'subscription-savings': () => format.percent(view.savings.percentage),
273
- 'subscription-savings-amount': () => format.money(view.savings.amount),
274
- 'checkout-charge': checkoutCharge,
275
- 'plan-name': () => plan?.name,
276
- 'plan-description': () => plan?.description,
277
- 'plan-group': () => plan?.group.name,
278
- 'compare-at-price': () => format.money(compareAtPrice),
279
- 'unit-price': () => format.money(variant.unitPrice),
280
- 'discount-percentage': discountPercentage,
281
- 'discount-amount': discountAmount,
282
- 'variant-title': () => variant.title,
283
- 'variant-id': () => variant.id,
284
- sku: () => variant.sku,
285
- barcode: () => variant.barcode,
286
- stock: () => variant.quantityAvailable,
287
- quantity: () => quantity,
288
- };
289
- return new Map(Object.entries(texts));
290
- }
291
- /**
292
- * The `tether-plan-bind` values of one plan in a plan list, for the variant.
293
- *
294
- * @param {ProductVariant} variant
295
- * @param {string} planId The list item's `tether-plan-value`.
296
- * @param {PlanInGroup | undefined} plan The product's plan with that id.
297
- * @param {Formatters} format
298
- * @returns {Map<string, () => BindText>}
299
- */
300
- export function planBindTexts(variant, planId, plan, format) {
301
- const adjustmentText = () => {
302
- const adjustment = planAdjustment(plan);
303
- if (!adjustment) return '';
304
- return adjustment.type === 'percentage' ? `${adjustment.value}%` : format.money(adjustment.value);
305
- };
306
- /** @type {Record<string, () => BindText>} */
307
- const texts = {
308
- name: () => plan?.name,
309
- // The option values alone ("1 month"), without the price text a store may put in the plan name.
310
- option: () => (plan?.options ?? []).map((option) => option.value).join(', '),
311
- description: () => plan?.description,
312
- price: () => format.money(planPrice(variant, planId)),
313
- savings: () => format.percent(planSavings(variant, planId).percentage),
314
- 'savings-amount': () => format.money(planSavings(variant, planId).amount),
315
- adjustment: adjustmentText,
316
- };
317
- return new Map(Object.entries(texts));
318
- }