@occluze/tether 0.6.7 → 0.6.8

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 (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +773 -442
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +648 -318
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +331 -242
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3176 -2015
  36. package/dist/tether.markets.js +231 -126
  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 +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +69 -298
  58. package/src/account.js +280 -323
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +320 -488
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -287
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
  120. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  121. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  122. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  123. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  124. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  125. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  126. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  127. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  128. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  129. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  130. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  131. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  132. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  133. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  134. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/product.js CHANGED
@@ -1,56 +1,120 @@
1
1
  /**
2
- * ProductScope: everything inside [tether-product="<handle>"] binds to one
3
- * Shopify product: options, variants, quantity, subscriptions, add to cart.
2
+ * ProductScope: everything inside [tether-product="<handle>"] binds to one Shopify product: options, variants,
3
+ * quantity, subscriptions and add to cart. The scope holds the visitor's choice and the actions on it;
4
+ * `product-state.js` derives what the choice shows, `product-render.js` and `variant-render.js` write it, and
5
+ * `product-events.js` turns clicks and changes into actions.
4
6
  */
5
- import { cfg, reportError } from './config.js';
7
+ import { cfg } from './config.js';
6
8
  import {
7
- attr, sel, get, has, qsa, scoped, emit, formatMoney, formatDate, setText, setImage, show,
8
- setSelected, repeat, productUrl, notifyLayoutChanged, watchImages, busy, parseJSONAttr, toAttributes, sameImage, hasTemplate, formatPercent,
9
+ attr,
10
+ sel,
11
+ get,
12
+ has,
13
+ scoped,
14
+ emit,
15
+ setText,
16
+ notifyLayoutChanged,
17
+ watchImages,
18
+ parseJSONAttr,
19
+ toAttributes,
9
20
  } from './dom.js';
10
21
  import { Cart } from './cart.js';
11
- import { STATUS_KEYS, ownedBy } from './status.js';
12
- import {
13
- allPlans, allocationFor, planPrice, planCheckoutCharge, planSavings, planAdjustment, findPlan, GID,
14
- } from './selling-plans.js';
22
+ import { allPlans, allocationFor, findPlan, GID } from './selling-plans.js';
23
+ import { initialChoice, normalisePurchase, productView, selectionOf } from './product-state.js';
24
+ import { writeProduct } from './product-render.js';
25
+ import { writeVariant } from './variant-render.js';
26
+ import { bindProductEvents } from './product-events.js';
27
+ /** @import { Product, ProductVariant } from './fragments.js' */
28
+ /** @import { PlanInGroup } from './selling-plans.js' */
29
+ /** @import { Purchase, Selection } from './product-state.js' */
15
30
 
16
- export const scopes = new WeakMap(); // element -> ProductScope
31
+ /**
32
+ * @typedef {object} CartLineInput What a scope adds to the cart.
33
+ * @property {string} merchandiseId
34
+ * @property {number} quantity
35
+ * @property {string} [sellingPlanId]
36
+ * @property {{ key: string, value: string }[]} [attributes]
37
+ */
17
38
 
18
- const productSel = () => sel('product');
39
+ /** @type {WeakMap<Element, ProductScope>} */
40
+ export const scopes = new WeakMap();
19
41
 
20
- function metafieldValue(product, ref) {
21
- const i = ref.indexOf('.');
22
- const ns = ref.slice(0, i);
23
- const key = ref.slice(i + 1);
24
- const mf = (product.metafields || []).find((m) => m && m.namespace === ns && m.key === key);
25
- return mf || null;
26
- }
42
+ const productSelector = () => sel('product');
27
43
 
28
- // Option names as written in the markup (the build's language), by product handle. With another
29
- // language Shopify translates them (Size -> Größe), so attributes are matched by position instead.
44
+ /**
45
+ * Option names as written in the markup (the build's language), by product handle. In another language Shopify
46
+ * translates them (Size → Größe), so markup names that aren't the product's are matched by position.
47
+ *
48
+ * @type {Map<string, string[]>}
49
+ */
30
50
  const knownOptionNames = new Map();
51
+
52
+ /** @param {readonly Pick<Product, 'handle' | 'options'>[]} products */
31
53
  export function rememberOptionNames(products) {
32
- (products || []).forEach((p) => { if (p?.handle && !knownOptionNames.has(p.handle)) knownOptionNames.set(p.handle, (p.options || []).map((o) => o.name)); });
54
+ products.forEach((product) => {
55
+ if (!knownOptionNames.has(product.handle)) {
56
+ knownOptionNames.set(
57
+ product.handle,
58
+ product.options.map((option) => option.name),
59
+ );
60
+ }
61
+ });
62
+ }
63
+
64
+ /**
65
+ * @param {Element} el
66
+ * @returns {el is HTMLInputElement}
67
+ */
68
+ const isInput = (el) => el.tagName === 'INPUT';
69
+
70
+ /**
71
+ * @param {Element} el
72
+ * @returns {el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement}
73
+ */
74
+ const isField = (el) => el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA';
75
+
76
+ /**
77
+ * @param {HTMLElement} el A `tether-line-attribute` field.
78
+ * @returns {string | null} Its value for the cart line; `null` for an unchecked or empty field.
79
+ */
80
+ function lineAttributeValue(el) {
81
+ if (isInput(el) && el.type === 'checkbox') {
82
+ if (!el.checked) return null;
83
+ return el.value && el.value !== 'on' ? el.value : 'Yes';
84
+ }
85
+ if (isInput(el) && el.type === 'radio') return el.checked ? el.value : null;
86
+ return (isField(el) && el.value) || null;
33
87
  }
34
88
 
35
89
  export class ProductScope {
90
+ /**
91
+ * @param {HTMLElement} root
92
+ * @param {Product} product
93
+ */
36
94
  constructor(root, product) {
37
95
  this.root = root;
38
96
  this.product = product;
39
- this.selected = {};
40
- this.variant = null;
41
- this.quantity = Math.max(0, parseInt(get(root, 'quantity') ?? '1', 10) || (has(root, 'quantity') ? 0 : 1));
42
97
  this.plans = allPlans(product);
43
- this.purchase = 'one-time';
44
- this.planId = null;
98
+ const params = new URLSearchParams(window.location.search);
99
+ const choice = initialChoice(product, {
100
+ variant: params.get('variant') || get(root, 'variant'),
101
+ sellingPlan: params.get('selling_plan'),
102
+ defaultPlan: get(root, 'default-plan'),
103
+ defaultPurchase: get(root, 'default-purchase') || cfg.defaultPurchase,
104
+ });
105
+ /** @type {Selection} */
106
+ this.selected = choice.selected;
107
+ /** @type {Purchase} */
108
+ this.purchaseChoice = choice.purchase;
109
+ /** @type {ProductVariant | null} */
110
+ this.variant = null;
111
+ this.quantity = has(root, 'quantity') ? Math.max(0, parseInt(get(root, 'quantity') ?? '', 10) || 0) : 1;
45
112
 
46
113
  scopes.set(root, this);
47
114
  root.setAttribute(attr('state'), 'ready');
48
115
  root.removeAttribute(attr('error'));
49
-
50
- this.initSelection();
51
- this.initPurchase();
52
116
  this.renderProduct();
53
- this.bind();
117
+ bindProductEvents(this);
54
118
  this.renderVariant();
55
119
 
56
120
  watchImages(root, 'product');
@@ -58,15 +122,17 @@ export class ProductScope {
58
122
  emit('product-ready', { product, scope: this }, root);
59
123
  }
60
124
 
61
- /** New data for the same product (e.g. after a country switch). Keeps the selection. */
125
+ /**
126
+ * New data for the same product (a country switch). Keeps the visitor's choice.
127
+ *
128
+ * @param {Product} product
129
+ */
62
130
  update(product) {
63
131
  const variantId = this.variant?.id;
64
132
  this.product = product;
65
133
  this.plans = allPlans(product);
66
- if (this.planId && !this.plans.some((p) => p.id === this.planId)) this.planId = this.plans[0]?.id || null;
67
- if (!this.plans.length && this.purchase === 'subscription') this.purchase = 'one-time';
68
- const v = product.variants.nodes.find((x) => x.id === variantId);
69
- if (v) { this.selected = {}; v.selectedOptions.forEach((o) => { this.selected[o.name] = o.value; }); }
134
+ const same = product.variants.nodes.find((variant) => variant.id === variantId);
135
+ if (same) this.selected = selectionOf(same);
70
136
  this.root.setAttribute(attr('state'), 'ready');
71
137
  this.root.removeAttribute(attr('error'));
72
138
  this.renderProduct();
@@ -75,142 +141,156 @@ export class ProductScope {
75
141
  emit('product-updated', { product, scope: this }, this.root);
76
142
  }
77
143
 
78
- /** Matching elements in this scope, including the root itself (e.g. a card link with tether-bind="link"). */
79
- $(s) {
80
- const inner = scoped(this.root, s, productSel());
81
- return this.root.matches(s) ? [this.root, ...inner] : inner;
144
+ /**
145
+ * @param {string} selector
146
+ * @returns {HTMLElement[]} Matches in this scope, including the root itself (a card link with tether-bind="link").
147
+ */
148
+ $(selector) {
149
+ const inner = scoped(this.root, selector, productSelector());
150
+ return this.root.matches(selector) ? [this.root, ...inner] : inner;
82
151
  }
83
152
 
84
- // ------------------------------------------------------------ state
85
-
86
- /** The product's option name for a name used in the markup (same name, or same position). */
153
+ /**
154
+ * @param {string | null} authored An option name in the markup.
155
+ * @returns {string} The product's option of that name, or at that position in the build's language.
156
+ */
87
157
  optName(authored) {
88
- const opts = this.product?.options || [];
89
- if (!authored || opts.some((o) => o.name === authored)) return authored;
90
- const i = (knownOptionNames.get(this.product.handle) || []).indexOf(authored);
91
- return i >= 0 && opts[i] ? opts[i].name : authored;
158
+ if (!authored) return '';
159
+ const { options } = this.product;
160
+ if (options.some((option) => option.name === authored)) return authored;
161
+ const index = (knownOptionNames.get(this.product.handle) ?? []).indexOf(authored);
162
+ return options[index]?.name ?? authored;
92
163
  }
93
164
 
94
- initSelection() {
95
- const vs = this.product.variants.nodes;
96
- const first = vs.find((v) => v.availableForSale) || vs[0];
97
- if (first) first.selectedOptions.forEach((o) => { this.selected[o.name] = o.value; });
98
-
99
- const param = new URLSearchParams(window.location.search).get('variant') || get(this.root, 'variant');
100
- if (param) {
101
- const gid = GID.variant(param);
102
- const match = vs.find((v) => v.id === gid);
103
- if (match) {
104
- this.selected = {};
105
- match.selectedOptions.forEach((o) => { this.selected[o.name] = o.value; });
106
- }
107
- }
165
+ /** @returns {'one-time' | 'subscription'} */
166
+ get purchase() {
167
+ return this.purchaseChoice.kind;
108
168
  }
109
169
 
110
- initPurchase() {
111
- if (!this.plans.length) return;
112
- const params = new URLSearchParams(window.location.search);
113
- const fromUrl = findPlan(this.product, params.get('selling_plan'));
114
- const fromAttr = findPlan(this.product, get(this.root, 'default-plan'));
115
- this.planId = (fromUrl || fromAttr || this.plans[0]).id;
116
-
117
- const wanted = fromUrl ? 'subscription' : (get(this.root, 'default-purchase') || cfg.defaultPurchase);
118
- this.purchase = this.product.requiresSellingPlan || wanted === 'subscription' ? 'subscription' : 'one-time';
170
+ /** @param {string} kind */
171
+ set purchase(kind) {
172
+ const wanted = kind === 'subscription' ? 'subscription' : 'one-time';
173
+ this.purchaseChoice = normalisePurchase(this.product, this.variant, { kind: wanted, planId: this.planId });
119
174
  }
120
175
 
121
- findVariant() {
122
- return this.product.variants.nodes.find((v) =>
123
- v.selectedOptions.every((o) => this.selected[o.name] === o.value)) || null;
176
+ /** @returns {string | null} The plan bought on, or offered while buying once. */
177
+ get planId() {
178
+ return this.purchaseChoice.planId;
124
179
  }
125
180
 
126
- isValueAvailable(name, value) {
127
- return this.product.variants.nodes.some((v) =>
128
- v.availableForSale && v.selectedOptions.every((o) =>
129
- (o.name === name ? o.value === value : this.selected[o.name] === o.value)));
181
+ /** @param {string | null} planId */
182
+ set planId(planId) {
183
+ this.purchaseChoice = normalisePurchase(this.product, this.variant, { kind: this.purchase, planId });
130
184
  }
131
185
 
132
- get plan() { return this.plans.find((p) => p.id === this.planId) || null; }
186
+ /** @returns {PlanInGroup | null} */
187
+ get plan() {
188
+ return this.plans.find((plan) => plan.id === this.planId) ?? null;
189
+ }
133
190
 
134
- /** True when the current variant can be bought on the selected plan. */
191
+ /** True when the current variant can be bought on a plan. */
135
192
  get subscriptionAvailable() {
136
- return Boolean(this.variant && this.plans.some((p) => allocationFor(this.variant, p.id)));
193
+ return this.plans.some((plan) => allocationFor(this.variant, plan.id));
137
194
  }
138
195
 
139
196
  get isSubscription() {
140
- return this.purchase === 'subscription' && Boolean(allocationFor(this.variant, this.planId));
197
+ return this.purchase === 'subscription' && allocationFor(this.variant, this.planId) !== null;
141
198
  }
142
199
 
143
- /** What would be added to the cart right now. */
200
+ /** @returns {CartLineInput | null} What would be added to the cart right now. */
144
201
  getLine() {
145
202
  if (!this.variant) return null;
203
+ /** @type {CartLineInput} */
146
204
  const line = { merchandiseId: this.variant.id, quantity: this.quantity };
147
- if (this.isSubscription) line.sellingPlanId = this.planId;
148
- const attrs = this.collectAttributes();
149
- if (attrs.length) line.attributes = attrs;
205
+ if (this.isSubscription && this.planId) line.sellingPlanId = this.planId;
206
+ const attributes = this.collectAttributes();
207
+ if (attributes.length) line.attributes = attributes;
150
208
  return line;
151
209
  }
152
210
 
211
+ /** @returns {{ key: string, value: string }[]} `tether-line-attributes` and the filled `tether-line-attribute` fields. */
153
212
  collectAttributes() {
154
- const fixed = toAttributes(parseJSONAttr(this.root, 'line-attributes'));
155
- const inputs = this.$(sel('line-attribute')).map((el) => {
156
- const key = get(el, 'line-attribute');
157
- let value;
158
- if (el.type === 'checkbox') value = el.checked ? (el.value && el.value !== 'on' ? el.value : 'Yes') : '';
159
- else if (el.type === 'radio') value = el.checked ? el.value : null;
160
- else value = el.value;
161
- return value == null || value === '' ? null : { key, value: String(value) };
162
- }).filter(Boolean);
163
- return [...fixed, ...inputs];
213
+ // The site's own JSON: toAttributes writes every value with String(), whatever its type.
214
+ const json = /** @type {Parameters<typeof toAttributes>[0]} */ (parseJSONAttr(this.root, 'line-attributes'));
215
+ const fixed = toAttributes(json);
216
+ const fields = this.$(sel('line-attribute')).flatMap((el) => {
217
+ const value = lineAttributeValue(el);
218
+ return value === null ? [] : [{ key: get(el, 'line-attribute') ?? '', value }];
219
+ });
220
+ return [...fixed, ...fields];
164
221
  }
165
222
 
166
- // ------------------------------------------------------------ actions
167
-
223
+ /**
224
+ * Selects an option value. A combination no variant has moves to the closest variant with the value: the most other
225
+ * choices kept, then one for sale.
226
+ *
227
+ * @param {string} name
228
+ * @param {string} value
229
+ */
168
230
  select(name, value) {
169
231
  if (this.selected[name] === value) return;
170
- const vs = this.product.variants.nodes.filter((v) => v.selectedOptions.some((o) => o.name === name && o.value === value));
171
- if (!vs.length) return; // a value no variant has: nothing to select, keep the visitor's choice
232
+ const withValue = this.product.variants.nodes.filter((variant) =>
233
+ variant.selectedOptions.some((option) => option.name === name && option.value === value),
234
+ );
235
+ const [firstWithValue] = withValue;
236
+ // A value no variant has: nothing to select, the visitor's choice stays.
237
+ if (!firstWithValue) return;
172
238
  const next = { ...this.selected, [name]: value };
173
- if (!vs.some((v) => v.selectedOptions.every((o) => next[o.name] === o.value))) {
174
- // That combination doesn't exist: the closest variant with this value (most other choices kept, available first).
175
- const score = (v) => v.selectedOptions.filter((o) => o.name !== name && this.selected[o.name] === o.value).length * 2 + (v.availableForSale ? 1 : 0);
176
- const best = vs.reduce((a, b) => (score(b) > score(a) ? b : a));
177
- best.selectedOptions.forEach((o) => { next[o.name] = o.value; });
239
+ if (!withValue.some((variant) => variant.selectedOptions.every((option) => next[option.name] === option.value))) {
240
+ const score = (/** @type {ProductVariant} */ variant) => {
241
+ const kept = variant.selectedOptions.filter(
242
+ (option) => option.name !== name && this.selected[option.name] === option.value,
243
+ ).length;
244
+ // One kept choice outweighs being for sale.
245
+ return kept * 2 + (variant.availableForSale ? 1 : 0);
246
+ };
247
+ const closest = withValue.reduce(
248
+ (best, variant) => (score(variant) > score(best) ? variant : best),
249
+ firstWithValue,
250
+ );
251
+ Object.assign(next, selectionOf(closest));
178
252
  }
179
253
  this.selected = next;
180
254
  this.renderVariant();
181
255
  }
182
256
 
257
+ /** @param {string} id A variant gid or numeric id. */
183
258
  selectVariant(id) {
184
- const v = this.product.variants.nodes.find((x) => x.id === GID.variant(id));
185
- if (!v) return;
186
- this.selected = {};
187
- v.selectedOptions.forEach((o) => { this.selected[o.name] = o.value; });
259
+ const variant = this.product.variants.nodes.find((candidate) => candidate.id === GID.variant(id));
260
+ if (!variant) return;
261
+ this.selected = selectionOf(variant);
188
262
  this.renderVariant();
189
263
  }
190
264
 
265
+ /** @param {string | null} mode `subscription`, else buying once. */
191
266
  setPurchase(mode) {
192
- const next = mode === 'subscription' ? 'subscription' : 'one-time';
193
- if (next === 'one-time' && this.product.requiresSellingPlan) return;
194
- if (this.purchase === next) return;
195
- this.purchase = next;
267
+ const kind = mode === 'subscription' ? 'subscription' : 'one-time';
268
+ const next = normalisePurchase(this.product, this.variant, { kind, planId: this.planId });
269
+ // The product decides when the wanted kind isn't possible (no plans, or a plan required).
270
+ if (next.kind === this.purchase) return;
271
+ this.purchaseChoice = next;
196
272
  this.renderVariant();
197
273
  }
198
274
 
275
+ /** @param {string | null} ref A plan id, numeric id, name or option value. */
199
276
  selectPlan(ref) {
200
- const p = findPlan(this.product, ref);
201
- if (!p) return;
202
- this.planId = p.id;
203
- this.purchase = 'subscription';
277
+ const plan = findPlan(this.product, ref);
278
+ if (!plan) return;
279
+ this.purchaseChoice = { kind: 'subscription', planId: plan.id };
204
280
  this.renderVariant();
205
281
  }
206
282
 
207
- setQuantity(n) {
208
- const min = has(this.root, 'quantity') ? 0 : 1;
209
- this.quantity = Math.max(min, parseInt(n, 10) || 0);
210
- this.$(sel('qty')).forEach((i) => setText(i, this.quantity));
283
+ /** @param {number | string} quantity */
284
+ setQuantity(quantity) {
285
+ const minimum = has(this.root, 'quantity') ? 0 : 1;
286
+ this.quantity = Math.max(minimum, parseInt(String(quantity), 10) || 0);
287
+ this.$(sel('qty')).forEach((el) => {
288
+ setText(el, this.quantity);
289
+ });
211
290
  this.renderVariant();
212
291
  }
213
292
 
293
+ /** @returns {Promise<unknown>} The cart after adding; `null` when the variant can't be added. */
214
294
  async addToCart() {
215
295
  if (!this.variant?.availableForSale) return null;
216
296
  const line = this.getLine();
@@ -220,351 +300,45 @@ export class ProductScope {
220
300
  return cart;
221
301
  }
222
302
 
223
- // ------------------------------------------------------------ events
224
-
225
- bind() {
226
- const own = (el) => el && el.closest(productSel()) === this.root;
227
-
228
- this.root.addEventListener('click', (e) => {
229
- const t = e.target;
230
- if (!t?.closest) return;
231
-
232
- const opt = t.closest(sel('option-value'));
233
- if (own(opt) && opt.closest(sel('option-list'))) {
234
- e.preventDefault();
235
- this.select(this.optName(get(opt.closest(sel('option-list')), 'option-list')), get(opt, 'option-value'));
236
- return;
237
- }
238
-
239
- const plan = t.closest(sel('plan-value'));
240
- if (own(plan)) { e.preventDefault(); this.selectPlan(get(plan, 'plan-value')); return; }
241
-
242
- const purchase = t.closest(`${sel('purchase')}:not(select)`);
243
- if (own(purchase)) {
244
- if (purchase.tagName !== 'INPUT') e.preventDefault();
245
- this.setPurchase(get(purchase, 'purchase'));
246
- return;
247
- }
248
-
249
- const plus = t.closest(sel('qty-plus'));
250
- if (own(plus)) { e.preventDefault(); this.setQuantity(this.quantity + 1); return; }
251
- const minus = t.closest(sel('qty-minus'));
252
- if (own(minus)) { e.preventDefault(); this.setQuantity(this.quantity - 1); return; }
253
-
254
- const add = t.closest(sel('add-to-cart'));
255
- if (own(add)) { e.preventDefault(); this.onAddClick(add); }
256
- });
257
-
258
- this.root.addEventListener('change', (e) => {
259
- const t = e.target;
260
- if (!own(t)) return;
261
- if (t.matches(`select${sel('option')}`)) this.select(this.optName(get(t, 'option')), t.value);
262
- else if (t.matches(`select${sel('variant-select')}`)) this.selectVariant(t.value);
263
- else if (t.matches(`select${sel('plan')}`)) {
264
- if (t.value) this.selectPlan(t.value); else this.setPurchase('one-time');
265
- } else if (t.matches(`select${sel('purchase')}`)) this.setPurchase(t.value);
266
- else if (t.matches(sel('qty'))) this.setQuantity(t.value);
267
- });
268
- }
269
-
270
- onAddClick(btn) {
271
- const label = btn.querySelector(sel('add-label')) || btn;
272
- const busyText = get(btn, 'adding-text');
273
- const write = (txt) => setText(label, txt);
274
- if (label.dataset.tetherLabel === undefined) label.dataset.tetherLabel = label.tagName === 'INPUT' ? label.value : label.textContent;
275
-
276
- busy(btn, async () => {
277
- if (busyText) write(busyText);
278
- try { await this.addToCart(); } catch (err) {
279
- reportError(err, 'add to cart failed');
280
- emit('error', { error: err, context: 'add-to-cart' }, this.root);
281
- } finally {
282
- if (busyText) write(label.dataset.tetherLabel);
283
- }
284
- }).then(() => {
285
- // Restore mode-dependent labels (subscribe-text / soldout-text) after aria-busy is cleared.
286
- this.renderVariant();
287
- });
288
- }
289
-
290
- // ------------------------------------------------------------ render
291
-
303
+ /** Learns the markup's option names when they are the product's, then writes the product. */
292
304
  renderProduct() {
293
- const p0 = this.product;
294
- if (!knownOptionNames.has(p0.handle)) {
295
- const authored = this.$(`${sel('option-list')},select${sel('option')}`).map((e) => get(e, 'option-list') || get(e, 'option'));
296
- if (authored.some((n) => p0.options.some((o) => o.name === n))) rememberOptionNames([p0]);
297
- }
298
- const p = this.product;
299
-
300
- this.$(sel('bind')).forEach((el) => {
301
- const key = get(el, 'bind') || '';
302
- const txt = {
303
- title: p.title,
304
- 'description-text': p.description,
305
- vendor: p.vendor,
306
- type: p.productType,
307
- handle: p.handle,
308
- }[key];
309
- if (txt !== undefined) return setText(el, txt);
310
- switch (key) {
311
- case 'description': el.innerHTML = p.descriptionHtml || ''; return undefined;
312
- case 'published-at': return setText(el, formatDate(p.publishedAt));
313
- case 'min-price': return setText(el, formatMoney(p.priceRange.minVariantPrice));
314
- case 'max-price': return setText(el, formatMoney(p.priceRange.maxVariantPrice));
315
- case 'featured-image': return setImage(el, p.featuredImage?.url, p.featuredImage?.altText || p.title);
316
- case 'url':
317
- case 'link':
318
- el.setAttribute('href', productUrl(p.handle, p.id));
319
- if (key === 'url' && !el.firstElementChild && !el.textContent.trim()) setText(el, p.title);
320
- return undefined;
321
- case 'images': {
322
- // Server-rendered gallery with the same images: keep it as it is.
323
- const imgs = [...el.children].map((n) => (n.tagName === 'IMG' ? n : n.querySelector('img')));
324
- if (!hasTemplate(el) && imgs.length === p.images.nodes.length && imgs.every((im, i) => sameImage(im?.getAttribute('src'), p.images.nodes[i].url))) return undefined;
325
- }
326
- return repeat(el, p.images.nodes, (node, img) => {
327
- const target = node.matches(`img,${sel('bind', 'image')}`) ? node : node.querySelector('img') || node;
328
- setImage(target, img.url, img.altText || p.title);
329
- });
330
- case 'tags': {
331
- const exclude = (el.getAttribute('exclude') || '').split(',').map((s) => s.trim()).filter(Boolean);
332
- return repeat(el, p.tags.filter((t) => !exclude.includes(t)), (node, tag) => setText(node, tag));
333
- }
334
- default:
335
- // image:2 = the second product image (falls back to the featured image)
336
- if (/^image:\d+$/.test(key)) {
337
- const img = p.images.nodes[key.slice(6) - 1] || p.featuredImage;
338
- return setImage(el, img?.url, img?.altText || p.title);
339
- }
340
- if (key.startsWith('metafield:')) {
341
- const mf = metafieldValue(p, key.slice(10));
342
- if (!mf) return setText(el, '');
343
- if (mf.reference?.image) return setImage(el, mf.reference.image.url, mf.reference.image.altText);
344
- if (/^(rich_text|multi_line_text)_field$/.test(mf.type)) el.style.whiteSpace = 'pre-line';
345
- return setText(el, mf.value);
346
- }
347
- return undefined;
305
+ const { product } = this;
306
+ if (!knownOptionNames.has(product.handle)) {
307
+ const authored = this.$(`${sel('option-list')},select${sel('option')}`).map(
308
+ (el) => get(el, 'option-list') || get(el, 'option'),
309
+ );
310
+ if (authored.some((name) => product.options.some((option) => option.name === name))) {
311
+ rememberOptionNames([product]);
348
312
  }
349
- });
350
-
351
- this.$(sel('option-list')).forEach((container) => {
352
- const name = this.optName(get(container, 'option-list'));
353
- const opt = p.options.find((o) => o.name === name);
354
- if (!opt || (opt.optionValues.length <= 1 && has(container, 'hide-single'))) return show(container, false);
355
- return repeat(container, opt.optionValues, (node, v) => {
356
- node.setAttribute(attr('option-value'), v.name);
357
- const t = node.querySelector(sel('bind', 'option-value'));
358
- if (t) setText(t, v.name); else if (!node.firstElementChild) setText(node, v.name);
359
- const sw = node.querySelector(sel('bind', 'swatch'));
360
- if (sw && v.swatch?.color) sw.style.backgroundColor = v.swatch.color;
361
- if (sw && v.swatch?.image?.previewImage) setImage(sw, v.swatch.image.previewImage.url, v.name);
362
- });
363
- });
364
-
365
- this.$(`select${sel('option')}`).forEach((s) => {
366
- const opt = p.options.find((o) => o.name === this.optName(get(s, 'option')));
367
- if (!opt) return show(s, false);
368
- s.innerHTML = '';
369
- opt.optionValues.forEach((v) => s.appendChild(new Option(v.name, v.name)));
370
- return undefined;
371
- });
372
-
373
- this.$(`select${sel('variant-select')}`).forEach((s) => {
374
- s.innerHTML = '';
375
- p.variants.nodes.forEach((v) => s.appendChild(new Option(v.title, v.id)));
376
- });
377
-
378
- // Subscriptions
379
- this.$(sel('plan-list')).forEach((container) => {
380
- // Value = selling plan group name. Empty, "true", "all" or "*" = every plan
381
- // (Webflow's Designer doesn't always allow empty attribute values).
382
- const raw = (get(container, 'plan-list') || '').trim();
383
- const group = ['', 'true', 'all', '*'].includes(raw.toLowerCase()) ? null : raw;
384
- const plans = this.plans.filter((pl) => !group || pl.group.name === group);
385
- repeat(container, plans, (node, plan) => {
386
- node.setAttribute(attr('plan-value'), plan.id);
387
- if (!node.firstElementChild && !node.querySelector(sel('plan-bind'))) setText(node, plan.name);
388
- });
389
- });
390
-
391
- this.$(`select${sel('plan')}`).forEach((s) => {
392
- s.innerHTML = '';
393
- if (!p.requiresSellingPlan && get(s, 'plan') !== 'subscription-only') {
394
- s.appendChild(new Option(s.getAttribute(attr('one-time-label')) || 'One-time purchase', ''));
395
- }
396
- this.plans.forEach((pl) => s.appendChild(new Option(pl.name, pl.id)));
397
- });
313
+ }
314
+ writeProduct(this);
398
315
  }
399
316
 
317
+ /** Derives the view of the current choice, keeps its variant, selection and purchase, and writes it. */
400
318
  renderVariant() {
401
- const p = this.product;
402
- let v = this.findVariant();
403
- if (!v) {
404
- [v] = p.variants.nodes;
405
- if (v) {
406
- this.selected = {};
407
- v.selectedOptions.forEach((o) => { this.selected[o.name] = o.value; });
408
- }
409
- }
410
- this.variant = v;
411
- if (!v) return;
412
-
413
- // A plan the variant isn't in -> pick the first one it is in.
414
- if (this.plans.length && !allocationFor(v, this.planId)) {
415
- const alt = this.plans.find((pl) => allocationFor(v, pl.id));
416
- if (alt) this.planId = alt.id;
319
+ const view = productView(
320
+ this.product,
321
+ { selected: this.selected, purchase: this.purchaseChoice, quantity: this.quantity },
322
+ cfg.lowStock,
323
+ );
324
+ if (!view) {
325
+ this.variant = null;
326
+ return;
417
327
  }
418
-
419
- const sub = this.isSubscription;
420
- const oneTimePrice = v.price;
421
- const subPrice = this.planId ? planPrice(v, this.planId) : null;
422
- const price = sub ? subPrice : oneTimePrice;
423
- const compareAt = sub ? (allocationFor(v, this.planId)?.priceAdjustments?.[0]?.compareAtPrice || v.compareAtPrice) : v.compareAtPrice;
424
- const onSale = compareAt && parseFloat(compareAt.amount) > parseFloat(price.amount);
425
- const discountPct = onSale ? Math.round((1 - parseFloat(price.amount) / parseFloat(compareAt.amount)) * 100) : 0;
426
- const savings = this.planId ? planSavings(v, this.planId) : { amount: null, percentage: 0 };
427
- const plan = this.plan;
428
- const total = { amount: (parseFloat(price.amount) * Math.max(1, this.quantity)).toFixed(2), currencyCode: price.currencyCode };
429
-
430
- const fm = formatMoney;
431
- const pct = (n) => formatPercent(n);
432
- // Text binds: name -> value (functions, so only the binds on the page are computed).
433
- const text = {
434
- price: () => fm(price),
435
- 'total-price': () => fm(total),
436
- 'one-time-price': () => fm(oneTimePrice),
437
- 'subscription-price': () => fm(subPrice),
438
- 'subscription-savings': () => pct(savings.percentage),
439
- 'subscription-savings-amount': () => fm(savings.amount),
440
- // What the visitor pays today for the chosen quantity (prepaid plans charge more up front).
441
- 'checkout-charge': () => { const c = sub ? planCheckoutCharge(v, this.planId) : price; return c && fm({ amount: (parseFloat(c.amount) * Math.max(1, this.quantity)).toFixed(2), currencyCode: c.currencyCode }); },
442
- 'plan-name': () => plan?.name,
443
- 'plan-description': () => plan?.description,
444
- 'plan-group': () => plan?.group?.name,
445
- 'compare-at-price': () => (onSale ? fm(compareAt) : ''),
446
- 'unit-price': () => fm(v.unitPrice),
447
- 'discount-percentage': () => `${discountPct}%`,
448
- 'discount-amount': () => (onSale ? fm({ amount: String(parseFloat(compareAt.amount) - parseFloat(price.amount)), currencyCode: price.currencyCode }) : ''),
449
- 'variant-title': () => v.title,
450
- 'variant-id': () => v.id,
451
- sku: () => v.sku,
452
- barcode: () => v.barcode,
453
- stock: () => v.quantityAvailable,
454
- quantity: () => this.quantity,
455
- };
456
- this.$(sel('bind')).forEach((el) => {
457
- const k = get(el, 'bind');
458
- if (k === 'variant-image') setImage(el, v.image?.url || p.featuredImage?.url, v.image?.altText || p.title);
459
- else if (text[k]) setText(el, text[k]());
460
- });
461
-
462
- this.$(sel('option-selected')).forEach((el) => setText(el, this.selected[this.optName(get(el, 'option-selected'))] || ''));
463
-
464
- this.$(sel('option-list')).forEach((container) => {
465
- const name = this.optName(get(container, 'option-list'));
466
- qsa(container, sel('option-value')).forEach((node) => {
467
- const value = get(node, 'option-value');
468
- setSelected(node, this.selected[name] === value);
469
- const unavailable = !this.isValueAvailable(name, value);
470
- node.classList.toggle('is-unavailable', unavailable);
471
- node.setAttribute('data-unavailable', unavailable ? 'true' : 'false');
472
- });
473
- });
474
-
475
- this.$(`select${sel('option')}`).forEach((s) => { s.value = this.selected[this.optName(get(s, 'option'))] || ''; });
476
- this.$(`select${sel('variant-select')}`).forEach((s) => { s.value = v.id; });
477
-
478
- // Subscription UI
479
- this.$(sel('purchase')).forEach((el) => {
480
- if (el.tagName === 'SELECT') { el.value = this.purchase; return; }
481
- const mode = get(el, 'purchase');
482
- setSelected(el, mode === this.purchase);
483
- const unavailable = mode === 'subscription' ? !this.subscriptionAvailable : p.requiresSellingPlan;
484
- el.classList.toggle('is-unavailable', unavailable);
485
- });
486
-
487
- this.$(sel('plan-value')).forEach((node) => {
488
- const id = get(node, 'plan-value');
489
- const planObj = this.plans.find((pl) => pl.id === id);
490
- const inVariant = Boolean(allocationFor(v, id));
491
- setSelected(node, sub && id === this.planId);
492
- node.classList.toggle('is-unavailable', !inVariant);
493
- qsa(node, sel('plan-bind')).forEach((el) => {
494
- const adj = planAdjustment(planObj);
495
- const s = planSavings(v, id);
496
- const k = get(el, 'plan-bind');
497
- const val = {
498
- name: () => planObj?.name,
499
- option: () => (planObj?.options || []).map((o) => o.value).join(', '), // e.g. "1 month", without the store's price text
500
- description: () => planObj?.description,
501
- price: () => fm(planPrice(v, id)),
502
- savings: () => pct(s.percentage),
503
- 'savings-amount': () => fm(s.amount),
504
- adjustment: () => (!adj ? '' : adj.type === 'percentage' ? `${adj.value}%` : fm(adj.value)),
505
- }[k];
506
- if (val) setText(el, val());
507
- });
508
- });
509
-
510
- this.$(`select${sel('plan')}`).forEach((s) => { s.value = sub ? this.planId : ''; });
511
-
512
- const low = v.quantityAvailable != null && v.quantityAvailable > 0 && v.quantityAvailable <= (cfg.lowStock ?? 5);
513
- const conds = {
514
- 'in-stock': v.availableForSale,
515
- 'out-of-stock': !v.availableForSale,
516
- 'backorder': v.availableForSale && v.currentlyNotInStock,
517
- 'on-sale': Boolean(onSale),
518
- 'not-on-sale': !onSale,
519
- 'has-variants': p.variants.nodes.length > 1,
520
- 'single-variant': p.variants.nodes.length === 1,
521
- 'low-stock': low,
522
- 'plenty-in-stock': v.availableForSale && !low,
523
- 'subscription-available': this.subscriptionAvailable,
524
- 'subscription-unavailable': !this.subscriptionAvailable,
525
- 'subscription-only': Boolean(p.requiresSellingPlan),
526
- 'one-time-available': !p.requiresSellingPlan,
527
- 'subscription-selected': sub,
528
- 'one-time-selected': !sub,
529
- 'has-savings': sub ? savings.percentage > 0 : Boolean(onSale),
530
- // scope state (see status.js)
531
- 'loading': false,
532
- 'ready': true,
533
- 'error': false,
534
- 'not-found': false,
535
- };
536
- this.$(sel('show')).forEach((el) => {
537
- const k = get(el, 'show');
538
- if (!(k in conds)) return;
539
- // State blocks of a list nested in this product belong to that list.
540
- if (STATUS_KEYS.includes(k) && !ownedBy(el, this.root)) return;
541
- show(el, conds[k]);
542
- });
543
-
544
- this.root.classList.toggle('is-subscription', sub);
545
- this.root.setAttribute(attr('purchase-mode'), sub ? 'subscription' : 'one-time');
546
-
547
- this.$(sel('add-to-cart')).forEach((btn) => {
548
- const disabled = !v.availableForSale;
549
- btn.classList.toggle('is-disabled', disabled);
550
- btn.setAttribute('aria-disabled', String(disabled));
551
- if ('disabled' in btn) btn.disabled = disabled;
552
- const soldOut = get(btn, 'soldout-text');
553
- const subText = get(btn, 'subscribe-text');
554
- if (soldOut || subText) {
555
- const label = btn.querySelector(sel('add-label')) || btn;
556
- if (label.dataset.tetherLabel === undefined) label.dataset.tetherLabel = label.tagName === 'INPUT' ? label.value : label.textContent;
557
- let text = label.dataset.tetherLabel;
558
- if (sub && subText) text = subText;
559
- if (disabled && soldOut) text = soldOut;
560
- if (btn.getAttribute('aria-busy') !== 'true') setText(label, text);
561
- }
562
- });
563
-
564
- emit('variant-changed', {
565
- product: p, variant: v, selected: { ...this.selected },
566
- purchase: this.purchase, sellingPlanId: sub ? this.planId : null,
567
- }, this.root);
568
-
328
+ this.variant = view.variant;
329
+ this.selected = view.selected;
330
+ this.purchaseChoice = view.purchase;
331
+ writeVariant(this, view);
332
+ emit(
333
+ 'variant-changed',
334
+ {
335
+ product: this.product,
336
+ variant: view.variant,
337
+ selected: { ...view.selected },
338
+ purchase: view.purchase.kind,
339
+ sellingPlanId: view.isSubscription ? view.purchase.planId : null,
340
+ },
341
+ this.root,
342
+ );
569
343
  }
570
344
  }