@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/presets/barba.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Preset for sites with page transitions (Barba, Swup
|
|
3
|
-
*
|
|
2
|
+
* Preset for sites with page transitions (Barba, Swup), smooth scroll (Lenis) and GSAP. Nothing is read from globals
|
|
3
|
+
* except GSAP and ScrollTrigger by default: pass in what your site has.
|
|
4
4
|
*
|
|
5
5
|
* import Tether from "@occluze/tether";
|
|
6
6
|
* import { barbaPreset } from "@occluze/tether/presets/barba";
|
|
@@ -20,76 +20,166 @@
|
|
|
20
20
|
* // when leaving a page:
|
|
21
21
|
* Tether.unmount();
|
|
22
22
|
*/
|
|
23
|
+
/** @import { TetherConfig } from '../config.js' */
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
/**
|
|
26
|
+
* GSAP isn't a dependency, so the two calls this preset makes are described here.
|
|
27
|
+
*
|
|
28
|
+
* @typedef {Record<string, string | number | (() => void)>} TweenVars
|
|
29
|
+
* @typedef {object} Gsap
|
|
30
|
+
* @property {(target: HTMLElement, from: TweenVars, to: TweenVars) => unknown} fromTo
|
|
31
|
+
* @property {(target: HTMLElement, to: TweenVars) => unknown} to
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} BarbaPresetOptions Each getter may also be the value itself.
|
|
36
|
+
* @property {() => unknown} [gsap] Default `window.gsap`.
|
|
37
|
+
* @property {(() => unknown) | null} [lenis] Resized after async renders.
|
|
38
|
+
* @property {() => unknown} [scrollTrigger] Default `window.ScrollTrigger`, refreshed after async renders.
|
|
39
|
+
* @property {((drawer: HTMLElement) => void) | null} [lockScroll] When the cart opens.
|
|
40
|
+
* @property {(() => void) | null} [unlockScroll] When the cart closes.
|
|
41
|
+
* @property {(() => void) | null} [onResize] After a cart line animated in or out.
|
|
42
|
+
* @property {((el: HTMLElement, previousText: string) => void) | null} [onValueChange]
|
|
43
|
+
* @property {boolean} [animateLines] Animate cart lines in and out (default true).
|
|
44
|
+
* @property {number} [lineDuration] Seconds.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Pick<TetherConfig, 'autoMount' | 'onLayoutChange' | 'onCartOpen' | 'onCartClose' | 'onLineEnter'
|
|
49
|
+
* | 'onLineLeave' | 'onValueChange'>} BarbaPresetConfig
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const LINE_DURATION_SECONDS = 0.3;
|
|
53
|
+
/** @returns {TweenVars} A new object each time: GSAP writes into the vars it is given. */
|
|
54
|
+
const collapsed = () => ({
|
|
55
|
+
height: 0,
|
|
56
|
+
paddingTop: 0,
|
|
57
|
+
paddingBottom: 0,
|
|
58
|
+
borderBottomWidth: 0,
|
|
59
|
+
opacity: 0,
|
|
60
|
+
overflow: 'hidden',
|
|
61
|
+
});
|
|
62
|
+
const ANIMATED_PROPS = 'height,paddingTop,paddingBottom,borderBottomWidth,opacity,overflow';
|
|
25
63
|
|
|
26
64
|
const reducedMotion = () =>
|
|
27
65
|
typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
28
66
|
|
|
67
|
+
/**
|
|
68
|
+
* @param {unknown} option
|
|
69
|
+
* @returns {unknown} What a getter option returns, or the option itself when it is the value.
|
|
70
|
+
*/
|
|
71
|
+
function current(option) {
|
|
72
|
+
if (typeof option !== 'function') return option;
|
|
73
|
+
// Every function option of this preset is a getter that takes no arguments.
|
|
74
|
+
const getter = /** @type {() => unknown} */ (option);
|
|
75
|
+
return getter();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Calls `target[method]()` when it has one: Lenis and ScrollTrigger are optional and versions differ.
|
|
80
|
+
*
|
|
81
|
+
* @param {unknown} target
|
|
82
|
+
* @param {string} method
|
|
83
|
+
*/
|
|
84
|
+
function callIfPresent(target, method) {
|
|
85
|
+
if (typeof target !== 'object' || target === null) return;
|
|
86
|
+
/** @type {unknown} */
|
|
87
|
+
const fn = Reflect.get(target, method);
|
|
88
|
+
// A method of `target`, called on it.
|
|
89
|
+
if (typeof fn === 'function') /** @type {(this: unknown) => unknown} */ (fn).call(target);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {unknown} value
|
|
94
|
+
* @returns {value is Gsap}
|
|
95
|
+
*/
|
|
96
|
+
const isGsap = (value) =>
|
|
97
|
+
typeof value === 'object' &&
|
|
98
|
+
value !== null &&
|
|
99
|
+
typeof Reflect.get(value, 'fromTo') === 'function' &&
|
|
100
|
+
typeof Reflect.get(value, 'to') === 'function';
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Hooks for sites with page transitions, smooth scroll and GSAP: spread into `Tether.init()`.
|
|
104
|
+
*
|
|
105
|
+
* @param {BarbaPresetOptions} [options]
|
|
106
|
+
* @returns {BarbaPresetConfig}
|
|
107
|
+
*/
|
|
29
108
|
export function barbaPreset(options = {}) {
|
|
30
109
|
const {
|
|
31
|
-
gsap = () => window
|
|
110
|
+
gsap = () => /** @type {unknown} */ (Reflect.get(window, 'gsap')),
|
|
32
111
|
lenis = null,
|
|
33
|
-
scrollTrigger = () => window
|
|
112
|
+
scrollTrigger = () => /** @type {unknown} */ (Reflect.get(window, 'ScrollTrigger')),
|
|
34
113
|
lockScroll = null,
|
|
35
114
|
unlockScroll = null,
|
|
36
115
|
onResize = null,
|
|
37
116
|
onValueChange = null,
|
|
38
117
|
animateLines = true,
|
|
39
|
-
lineDuration =
|
|
118
|
+
lineDuration = LINE_DURATION_SECONDS,
|
|
40
119
|
} = options;
|
|
41
120
|
|
|
42
|
-
|
|
121
|
+
/** Whether this preset holds the scroll lock, so a modal's own lock stays intact. */
|
|
43
122
|
let locked = false;
|
|
44
123
|
|
|
45
|
-
|
|
124
|
+
/**
|
|
125
|
+
* @param {HTMLElement} node
|
|
126
|
+
* @returns {Gsap | null} GSAP, when this line should animate.
|
|
127
|
+
*/
|
|
128
|
+
const animator = (node) => {
|
|
129
|
+
if (!animateLines || reducedMotion() || node.offsetHeight <= 0) return null;
|
|
130
|
+
const animation = current(gsap);
|
|
131
|
+
return isGsap(animation) ? animation : null;
|
|
132
|
+
};
|
|
46
133
|
|
|
47
134
|
return {
|
|
48
135
|
autoMount: false,
|
|
49
136
|
|
|
50
137
|
onLayoutChange() {
|
|
51
|
-
|
|
52
|
-
|
|
138
|
+
callIfPresent(current(lenis), 'resize');
|
|
139
|
+
callIfPresent(current(scrollTrigger), 'refresh');
|
|
53
140
|
},
|
|
54
141
|
|
|
55
142
|
onCartOpen(drawers) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
143
|
+
const drawer = drawers[0];
|
|
144
|
+
if (locked || typeof lockScroll !== 'function' || !drawer) return;
|
|
145
|
+
lockScroll(drawer);
|
|
146
|
+
locked = true;
|
|
60
147
|
},
|
|
61
148
|
|
|
62
149
|
onCartClose() {
|
|
63
|
-
// Only release what we locked, so a modal's own lock stays intact.
|
|
64
150
|
if (locked && typeof unlockScroll === 'function') unlockScroll();
|
|
65
151
|
locked = false;
|
|
66
152
|
},
|
|
67
153
|
|
|
68
154
|
onLineEnter(node) {
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
height: 0, paddingTop: 0, paddingBottom: 0, borderBottomWidth: 0, opacity: 0, overflow: 'hidden',
|
|
74
|
-
}, {
|
|
155
|
+
const animation = animator(node);
|
|
156
|
+
if (!animation) return;
|
|
157
|
+
const style = getComputedStyle(node);
|
|
158
|
+
animation.fromTo(node, collapsed(), {
|
|
75
159
|
height: node.offsetHeight,
|
|
76
|
-
paddingTop:
|
|
77
|
-
paddingBottom:
|
|
78
|
-
borderBottomWidth:
|
|
160
|
+
paddingTop: style.paddingTop,
|
|
161
|
+
paddingBottom: style.paddingBottom,
|
|
162
|
+
borderBottomWidth: style.borderBottomWidth,
|
|
79
163
|
opacity: 1,
|
|
80
164
|
duration: lineDuration,
|
|
81
|
-
clearProps:
|
|
165
|
+
clearProps: ANIMATED_PROPS,
|
|
82
166
|
onComplete: () => onResize?.(),
|
|
83
167
|
});
|
|
84
168
|
},
|
|
85
169
|
|
|
86
170
|
onLineLeave(node, done) {
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
|
|
90
|
-
|
|
171
|
+
const animation = animator(node);
|
|
172
|
+
if (!animation) {
|
|
173
|
+
done();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
animation.to(node, {
|
|
177
|
+
...collapsed(),
|
|
91
178
|
duration: lineDuration,
|
|
92
|
-
onComplete: () => {
|
|
179
|
+
onComplete: () => {
|
|
180
|
+
done();
|
|
181
|
+
onResize?.();
|
|
182
|
+
},
|
|
93
183
|
});
|
|
94
184
|
},
|
|
95
185
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A product scope's clicks and changes: option and plan buttons, purchase toggles, quantity steppers, the selects and
|
|
3
|
+
* add to cart. One delegated listener of each kind on the root, so cards a list re-renders need no new listeners.
|
|
4
|
+
*/
|
|
5
|
+
import { reportError } from './config.js';
|
|
6
|
+
import { sel, get, emit, setText, busy } from './dom.js';
|
|
7
|
+
import { addButtonLabel } from './variant-render.js';
|
|
8
|
+
/** @import { ProductScope } from './product.js' */
|
|
9
|
+
|
|
10
|
+
const productSelector = () => sel('product');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {Element} el
|
|
14
|
+
* @returns {el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement}
|
|
15
|
+
*/
|
|
16
|
+
const isField = (el) => el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {EventTarget | null} target
|
|
20
|
+
* @returns {target is Element}
|
|
21
|
+
*/
|
|
22
|
+
const isElement = (target) => target instanceof window.Element;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {ProductScope} scope
|
|
26
|
+
* @param {Element} target
|
|
27
|
+
* @param {string} selector
|
|
28
|
+
* @returns {HTMLElement | null} The nearest `selector` around `target` that belongs to the scope, not to a nested one.
|
|
29
|
+
* Typed as an HTML element, as `qsa` types its matches.
|
|
30
|
+
*/
|
|
31
|
+
function closestOwn(scope, target, selector) {
|
|
32
|
+
const el = /** @type {HTMLElement | null} */ (target.closest(selector));
|
|
33
|
+
return el?.closest(productSelector()) === scope.root ? el : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Adds to the cart with `aria-busy` on the button and `tether-adding-text` as its label meanwhile.
|
|
38
|
+
*
|
|
39
|
+
* @param {ProductScope} scope
|
|
40
|
+
* @param {HTMLElement} button
|
|
41
|
+
*/
|
|
42
|
+
function onAddClick(scope, button) {
|
|
43
|
+
const { label, original } = addButtonLabel(button);
|
|
44
|
+
const addingText = get(button, 'adding-text');
|
|
45
|
+
void busy(button, async () => {
|
|
46
|
+
if (addingText) setText(label, addingText);
|
|
47
|
+
try {
|
|
48
|
+
await scope.addToCart();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
reportError(error, 'add to cart failed');
|
|
51
|
+
emit('error', { error, context: 'add-to-cart' }, scope.root);
|
|
52
|
+
} finally {
|
|
53
|
+
if (addingText) setText(label, original);
|
|
54
|
+
}
|
|
55
|
+
}).then(() => {
|
|
56
|
+
// The mode-dependent labels (tether-subscribe-text, tether-soldout-text) wait for aria-busy to clear.
|
|
57
|
+
scope.renderVariant();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {ProductScope} scope
|
|
63
|
+
* @param {Event} event
|
|
64
|
+
* @param {Element} target
|
|
65
|
+
*/
|
|
66
|
+
function onClick(scope, event, target) {
|
|
67
|
+
const optionValue = closestOwn(scope, target, sel('option-value'));
|
|
68
|
+
const optionList = optionValue?.closest(sel('option-list'));
|
|
69
|
+
if (optionValue && optionList) {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
scope.select(scope.optName(get(optionList, 'option-list')), get(optionValue, 'option-value') ?? '');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const plan = closestOwn(scope, target, sel('plan-value'));
|
|
75
|
+
if (plan) {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
scope.selectPlan(get(plan, 'plan-value'));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const purchase = closestOwn(scope, target, `${sel('purchase')}:not(select)`);
|
|
81
|
+
if (purchase) {
|
|
82
|
+
// A radio or checkbox input keeps its own click, so it shows checked.
|
|
83
|
+
if (purchase.tagName !== 'INPUT') event.preventDefault();
|
|
84
|
+
scope.setPurchase(get(purchase, 'purchase'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const plus = closestOwn(scope, target, sel('qty-plus'));
|
|
88
|
+
const minus = plus ? null : closestOwn(scope, target, sel('qty-minus'));
|
|
89
|
+
if (plus || minus) {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
scope.setQuantity(scope.quantity + (plus ? 1 : -1));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const add = closestOwn(scope, target, sel('add-to-cart'));
|
|
95
|
+
if (add) {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
onAddClick(scope, add);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {ProductScope} scope
|
|
103
|
+
* @param {Element} target A form field in the scope.
|
|
104
|
+
*/
|
|
105
|
+
function onChange(scope, target) {
|
|
106
|
+
const value = isField(target) ? target.value : '';
|
|
107
|
+
if (target.matches(`select${sel('option')}`)) {
|
|
108
|
+
scope.select(scope.optName(get(target, 'option')), value);
|
|
109
|
+
} else if (target.matches(`select${sel('variant-select')}`)) {
|
|
110
|
+
scope.selectVariant(value);
|
|
111
|
+
} else if (target.matches(`select${sel('plan')}`)) {
|
|
112
|
+
if (value) scope.selectPlan(value);
|
|
113
|
+
else scope.setPurchase('one-time');
|
|
114
|
+
} else if (target.matches(`select${sel('purchase')}`)) {
|
|
115
|
+
scope.setPurchase(value);
|
|
116
|
+
} else if (target.matches(sel('qty'))) {
|
|
117
|
+
scope.setQuantity(value);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {ProductScope} scope */
|
|
122
|
+
export function bindProductEvents(scope) {
|
|
123
|
+
scope.root.addEventListener('click', (event) => {
|
|
124
|
+
if (isElement(event.target)) onClick(scope, event, event.target);
|
|
125
|
+
});
|
|
126
|
+
scope.root.addEventListener('change', (event) => {
|
|
127
|
+
const { target } = event;
|
|
128
|
+
if (isElement(target) && target.closest(productSelector()) === scope.root) onChange(scope, target);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
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
|
+
}
|