@occluze/tether 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +55 -39
  25. package/dist/tether.account-pages.js +781 -495
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +462 -381
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +299 -194
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +354 -262
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +82 -45
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3653 -2116
  36. package/dist/tether.markets.js +238 -150
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +3 -3
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +207 -136
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +42 -22
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/order-lines.ts +75 -0
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/page-events.ts +253 -0
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/subscriptions-view.ts +115 -0
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/account-pages.ts +81 -0
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/analytics-shopify.ts +246 -0
  135. package/src/analytics.ts +315 -0
  136. package/src/api-version.ts +79 -0
  137. package/src/astro.ts +272 -0
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/drawer.ts +125 -0
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/render.ts +266 -0
  143. package/src/cart/store.ts +279 -0
  144. package/src/cart.ts +345 -0
  145. package/src/client.ts +391 -0
  146. package/src/collection.ts +175 -0
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/cart-controls.ts +123 -0
  150. package/src/controls/discount.ts +75 -0
  151. package/src/controls/extras.ts +54 -0
  152. package/src/controls/navigation.ts +77 -0
  153. package/src/diagnose-markup.ts +298 -0
  154. package/src/diagnose.ts +294 -0
  155. package/src/dom.ts +268 -0
  156. package/src/extras.ts +136 -0
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/images.ts +91 -0
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/loader.ts +363 -0
  165. package/src/market-codes.ts +6 -0
  166. package/src/markets-ui.ts +227 -0
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/mounting.ts +264 -0
  170. package/src/page-market.ts +110 -0
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/product-events.ts +115 -0
  181. package/src/product-render.ts +218 -0
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/product.ts +308 -0
  185. package/src/repeaters.ts +161 -0
  186. package/src/search.ts +305 -0
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/shopify-values.ts +91 -0
  190. package/src/status.ts +106 -0
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/json-ld.ts +144 -0
  195. package/src/variant-render.ts +139 -0
  196. package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
  197. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  198. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  199. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  200. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  201. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  202. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  203. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  204. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  205. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  206. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  207. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  208. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  209. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  210. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  211. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  212. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
  213. package/src/account-pages.js +0 -318
  214. package/src/account.js +0 -385
  215. package/src/analytics.js +0 -232
  216. package/src/api-version.js +0 -35
  217. package/src/astro.js +0 -101
  218. package/src/cart.js +0 -559
  219. package/src/client.js +0 -131
  220. package/src/collection.js +0 -114
  221. package/src/config.js +0 -85
  222. package/src/diagnose.js +0 -246
  223. package/src/dom.js +0 -288
  224. package/src/extras.js +0 -63
  225. package/src/fragments.js +0 -144
  226. package/src/index.js +0 -395
  227. package/src/initial.js +0 -75
  228. package/src/lazy-iife.js +0 -46
  229. package/src/lazy.js +0 -18
  230. package/src/loader.js +0 -198
  231. package/src/markets-ui.js +0 -90
  232. package/src/markets.js +0 -133
  233. package/src/mounting.js +0 -154
  234. package/src/parts/account-pages.js +0 -3
  235. package/src/parts/account.js +0 -3
  236. package/src/parts/analytics.js +0 -3
  237. package/src/parts/diagnose.js +0 -3
  238. package/src/parts/extras.js +0 -3
  239. package/src/parts/markets.js +0 -3
  240. package/src/parts/search.js +0 -3
  241. package/src/presets/barba.js +0 -100
  242. package/src/product.js +0 -570
  243. package/src/search.js +0 -153
  244. package/src/selling-plans.js +0 -94
  245. package/src/server.js +0 -252
  246. package/src/status.js +0 -66
  247. package/types/astro.d.ts +0 -17
  248. package/types/barba.d.ts +0 -16
  249. package/types/index.d.ts +0 -426
  250. package/types/server.d.ts +0 -110
@@ -1,100 +0,0 @@
1
- /**
2
- * Preset for sites with page transitions (Barba, Swup, …), smooth scroll (Lenis)
3
- * and GSAP. Nothing is read from globals: pass in what your site has.
4
- *
5
- * import Tether from "@occluze/tether";
6
- * import { barbaPreset } from "@occluze/tether/presets/barba";
7
- *
8
- * Tether.init({
9
- * ...window.TETHER_CONFIG,
10
- * ...barbaPreset({
11
- * lenis: () => myLenis, // resized after async renders
12
- * lockScroll: (drawer) => myLock.lock(drawer), // when the cart opens
13
- * unlockScroll: () => myLock.unlock(), // when the cart closes
14
- * onValueChange: (el, previous) => {}, // e.g. a text scramble
15
- * }),
16
- * });
17
- *
18
- * // once per page container, e.g. in Barba's beforeEnter AND once():
19
- * Tether.mount(container);
20
- * // when leaving a page:
21
- * Tether.unmount();
22
- */
23
-
24
- const LINE_DURATION = 0.3;
25
-
26
- const reducedMotion = () =>
27
- typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
28
-
29
- export function barbaPreset(options = {}) {
30
- const {
31
- gsap = () => window.gsap,
32
- lenis = null,
33
- scrollTrigger = () => window.ScrollTrigger,
34
- lockScroll = null,
35
- unlockScroll = null,
36
- onResize = null,
37
- onValueChange = null,
38
- animateLines = true,
39
- lineDuration = LINE_DURATION,
40
- } = options;
41
-
42
- const get = (v) => (typeof v === 'function' ? v() : v);
43
- let locked = false;
44
-
45
- const canAnimate = (node) => animateLines && get(gsap) && !reducedMotion() && node.offsetHeight > 0;
46
-
47
- return {
48
- autoMount: false,
49
-
50
- onLayoutChange() {
51
- get(lenis)?.resize?.();
52
- get(scrollTrigger)?.refresh?.();
53
- },
54
-
55
- onCartOpen(drawers) {
56
- if (!locked && typeof lockScroll === 'function' && drawers[0]) {
57
- lockScroll(drawers[0]);
58
- locked = true;
59
- }
60
- },
61
-
62
- onCartClose() {
63
- // Only release what we locked, so a modal's own lock stays intact.
64
- if (locked && typeof unlockScroll === 'function') unlockScroll();
65
- locked = false;
66
- },
67
-
68
- onLineEnter(node) {
69
- const g = get(gsap);
70
- if (!canAnimate(node)) return;
71
- const st = getComputedStyle(node);
72
- g.fromTo(node, {
73
- height: 0, paddingTop: 0, paddingBottom: 0, borderBottomWidth: 0, opacity: 0, overflow: 'hidden',
74
- }, {
75
- height: node.offsetHeight,
76
- paddingTop: st.paddingTop,
77
- paddingBottom: st.paddingBottom,
78
- borderBottomWidth: st.borderBottomWidth,
79
- opacity: 1,
80
- duration: lineDuration,
81
- clearProps: 'height,paddingTop,paddingBottom,borderBottomWidth,opacity,overflow',
82
- onComplete: () => onResize?.(),
83
- });
84
- },
85
-
86
- onLineLeave(node, done) {
87
- const g = get(gsap);
88
- if (!canAnimate(node)) { done(); return; }
89
- g.to(node, {
90
- height: 0, paddingTop: 0, paddingBottom: 0, borderBottomWidth: 0, opacity: 0, overflow: 'hidden',
91
- duration: lineDuration,
92
- onComplete: () => { done(); onResize?.(); },
93
- });
94
- },
95
-
96
- onValueChange: typeof onValueChange === 'function' ? onValueChange : null,
97
- };
98
- }
99
-
100
- export default barbaPreset;
package/src/product.js DELETED
@@ -1,570 +0,0 @@
1
- /**
2
- * ProductScope: everything inside [tether-product="<handle>"] binds to one
3
- * Shopify product: options, variants, quantity, subscriptions, add to cart.
4
- */
5
- import { cfg, reportError } from './config.js';
6
- 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
- } from './dom.js';
10
- 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';
15
-
16
- export const scopes = new WeakMap(); // element -> ProductScope
17
-
18
- const productSel = () => sel('product');
19
-
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
- }
27
-
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.
30
- const knownOptionNames = new Map();
31
- 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)); });
33
- }
34
-
35
- export class ProductScope {
36
- constructor(root, product) {
37
- this.root = root;
38
- 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
- this.plans = allPlans(product);
43
- this.purchase = 'one-time';
44
- this.planId = null;
45
-
46
- scopes.set(root, this);
47
- root.setAttribute(attr('state'), 'ready');
48
- root.removeAttribute(attr('error'));
49
-
50
- this.initSelection();
51
- this.initPurchase();
52
- this.renderProduct();
53
- this.bind();
54
- this.renderVariant();
55
-
56
- watchImages(root, 'product');
57
- notifyLayoutChanged('product');
58
- emit('product-ready', { product, scope: this }, root);
59
- }
60
-
61
- /** New data for the same product (e.g. after a country switch). Keeps the selection. */
62
- update(product) {
63
- const variantId = this.variant?.id;
64
- this.product = product;
65
- 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; }); }
70
- this.root.setAttribute(attr('state'), 'ready');
71
- this.root.removeAttribute(attr('error'));
72
- this.renderProduct();
73
- this.renderVariant();
74
- notifyLayoutChanged('product');
75
- emit('product-updated', { product, scope: this }, this.root);
76
- }
77
-
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;
82
- }
83
-
84
- // ------------------------------------------------------------ state
85
-
86
- /** The product's option name for a name used in the markup (same name, or same position). */
87
- 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;
92
- }
93
-
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
- }
108
- }
109
-
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';
119
- }
120
-
121
- findVariant() {
122
- return this.product.variants.nodes.find((v) =>
123
- v.selectedOptions.every((o) => this.selected[o.name] === o.value)) || null;
124
- }
125
-
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)));
130
- }
131
-
132
- get plan() { return this.plans.find((p) => p.id === this.planId) || null; }
133
-
134
- /** True when the current variant can be bought on the selected plan. */
135
- get subscriptionAvailable() {
136
- return Boolean(this.variant && this.plans.some((p) => allocationFor(this.variant, p.id)));
137
- }
138
-
139
- get isSubscription() {
140
- return this.purchase === 'subscription' && Boolean(allocationFor(this.variant, this.planId));
141
- }
142
-
143
- /** What would be added to the cart right now. */
144
- getLine() {
145
- if (!this.variant) return null;
146
- 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;
150
- return line;
151
- }
152
-
153
- 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];
164
- }
165
-
166
- // ------------------------------------------------------------ actions
167
-
168
- select(name, value) {
169
- 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
172
- 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; });
178
- }
179
- this.selected = next;
180
- this.renderVariant();
181
- }
182
-
183
- 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; });
188
- this.renderVariant();
189
- }
190
-
191
- 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;
196
- this.renderVariant();
197
- }
198
-
199
- selectPlan(ref) {
200
- const p = findPlan(this.product, ref);
201
- if (!p) return;
202
- this.planId = p.id;
203
- this.purchase = 'subscription';
204
- this.renderVariant();
205
- }
206
-
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));
211
- this.renderVariant();
212
- }
213
-
214
- async addToCart() {
215
- if (!this.variant?.availableForSale) return null;
216
- const line = this.getLine();
217
- if (!line || line.quantity < 1) return null;
218
- const cart = await Cart.add(line);
219
- emit('product-added', { product: this.product, variant: this.variant, line }, this.root);
220
- return cart;
221
- }
222
-
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
-
292
- 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;
348
- }
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
- });
398
- }
399
-
400
- 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;
417
- }
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
-
569
- }
570
- }