@occluze/tether 0.6.6 → 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.
- package/README.md +3 -3
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
- package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
- package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
- package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
- package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
- package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
- package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
- package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
- package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
- package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
- package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
- package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
- package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
- package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
- package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
- package/dist/esm/chunks/search-OU75RNQL.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +56 -36
- package/dist/tether.account-pages.js +776 -432
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +655 -321
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +318 -191
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +334 -243
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +78 -33
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3171 -1998
- package/dist/tether.markets.js +231 -126
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +221 -126
- package/dist/tether.search.min.js +1 -1
- package/package.json +23 -4
- package/src/account/addresses-view.js +92 -0
- package/src/account/browser-session.js +359 -0
- package/src/account/markup.js +48 -0
- package/src/account/operations.js +180 -0
- package/src/account/order-lines.js +96 -0
- package/src/account/order-list.js +91 -0
- package/src/account/order-page.js +192 -0
- package/src/account/page-events.js +288 -0
- package/src/account/server-client.js +172 -0
- package/src/account/shared.js +111 -0
- package/src/account/shopify-auth.js +150 -0
- package/src/account/subscriptions-view.js +158 -0
- package/src/account-api.js +118 -0
- package/src/account-links.js +35 -0
- package/src/account-pages.js +68 -284
- package/src/account.js +281 -317
- package/src/amounts.js +36 -0
- package/src/analytics-shopify.js +241 -0
- package/src/analytics.js +304 -184
- package/src/api-version.js +75 -25
- package/src/astro.js +233 -68
- package/src/cart/checkout.js +97 -0
- package/src/cart/drawer.js +138 -0
- package/src/cart/mutations.js +234 -0
- package/src/cart/render.js +294 -0
- package/src/cart/store.js +326 -0
- package/src/cart.js +322 -481
- package/src/client.js +214 -78
- package/src/collection.js +126 -57
- package/src/config.js +323 -59
- package/src/controls/cart-controls.js +155 -0
- package/src/controls/discount.js +96 -0
- package/src/controls/extras.js +63 -0
- package/src/controls/navigation.js +83 -0
- package/src/diagnose-markup.js +333 -0
- package/src/diagnose.js +251 -202
- package/src/dom.js +219 -220
- package/src/extras.js +119 -37
- package/src/fragments.js +115 -22
- package/src/images.js +104 -0
- package/src/index.js +245 -285
- package/src/initial.js +78 -66
- package/src/lazy-iife.js +92 -22
- package/src/lazy.js +25 -4
- package/src/loader.js +263 -124
- package/src/market-codes.js +35 -0
- package/src/markets-ui.js +169 -55
- package/src/markets.js +286 -85
- package/src/markup.js +64 -0
- package/src/mounting.js +214 -91
- package/src/page-market.js +116 -0
- package/src/parts/account-pages.js +4 -1
- package/src/parts/account.js +4 -1
- package/src/parts/analytics.js +4 -1
- package/src/parts/diagnose.js +4 -1
- package/src/parts/extras.js +4 -1
- package/src/parts/markets.js +4 -1
- package/src/parts/search.js +4 -1
- package/src/presets/barba.js +120 -30
- package/src/product-events.js +130 -0
- package/src/product-render.js +257 -0
- package/src/product-state.js +318 -0
- package/src/product.js +233 -459
- package/src/repeaters.js +173 -0
- package/src/search.js +277 -107
- package/src/selling-plans.js +107 -47
- package/src/server.js +168 -205
- package/src/shopify-values.js +89 -0
- package/src/status.js +99 -39
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +199 -0
- package/src/storefront/json-ld.js +167 -0
- package/src/variant-render.js +165 -0
- package/types/astro.d.ts +13 -2
- package/types/index.d.ts +178 -27
- package/types/server.d.ts +89 -17
- package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
- package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
- package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
- package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
- package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
- package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
- package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
- package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
- 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
|
-
*
|
|
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
|
|
7
|
+
import { cfg } from './config.js';
|
|
6
8
|
import {
|
|
7
|
-
attr,
|
|
8
|
-
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
} from './
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
/** @type {WeakMap<Element, ProductScope>} */
|
|
40
|
+
export const scopes = new WeakMap();
|
|
19
41
|
|
|
20
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
67
|
-
if (
|
|
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
|
-
/**
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
191
|
+
/** True when the current variant can be bought on a plan. */
|
|
135
192
|
get subscriptionAvailable() {
|
|
136
|
-
return
|
|
193
|
+
return this.plans.some((plan) => allocationFor(this.variant, plan.id));
|
|
137
194
|
}
|
|
138
195
|
|
|
139
196
|
get isSubscription() {
|
|
140
|
-
return this.purchase === 'subscription' &&
|
|
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
|
|
149
|
-
if (
|
|
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
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
|
|
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 (!
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
185
|
-
if (!
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
this.purchase
|
|
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
|
|
201
|
-
if (!
|
|
202
|
-
this.
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
this
|
|
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
|
-
|
|
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
|
|
294
|
-
if (!knownOptionNames.has(
|
|
295
|
-
const authored = this.$(`${sel('option-list')},select${sel('option')}`).map(
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
}
|