@gemboss/gem-framework 0.18.15 → 0.19.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 (54) hide show
  1. package/README.md +106 -1
  2. package/dist/SOURCE.json +3 -3
  3. package/dist/gem-framework.css +23 -1
  4. package/dist/gem-runtime.js +473 -55
  5. package/dist/index.js +2062 -44
  6. package/dist/motion/core.d.ts +208 -0
  7. package/dist/motion/core.js +1 -0
  8. package/dist/motion/orbit.d.ts +4 -0
  9. package/dist/motion/orbit.js +1 -0
  10. package/dist/motion/quick-view.d.ts +4 -0
  11. package/dist/motion/quick-view.js +1 -0
  12. package/dist/motion/rail.d.ts +5 -0
  13. package/dist/motion/rail.js +1 -0
  14. package/dist/orbit-base.css +39 -0
  15. package/dist/plugins/before_after.js +1 -1
  16. package/dist/plugins/bundle.d.ts +9 -0
  17. package/dist/plugins/bundle.js +394 -0
  18. package/dist/plugins/carousel.js +1 -1
  19. package/dist/plugins/cart-drawer.js +1 -1
  20. package/dist/plugins/context-adapter.js +1 -1
  21. package/dist/plugins/countdown.js +1 -1
  22. package/dist/plugins/disclosure.js +1 -1
  23. package/dist/plugins/hero-slideshow.js +1 -1
  24. package/dist/plugins/img-in.js +1 -1
  25. package/dist/plugins/localization.js +1 -1
  26. package/dist/plugins/media-gallery.js +1 -1
  27. package/dist/plugins/mobile-menu.js +1 -1
  28. package/dist/plugins/offer-engine.js +1 -1
  29. package/dist/plugins/orbit.d.ts +9 -0
  30. package/dist/plugins/orbit.js +1 -0
  31. package/dist/plugins/predictive-search.js +1 -1
  32. package/dist/plugins/promo-popup.js +1 -1
  33. package/dist/plugins/quantity-input.js +1 -1
  34. package/dist/plugins/quick-view.js +1 -69
  35. package/dist/plugins/rail.d.ts +9 -0
  36. package/dist/plugins/rail.js +1 -0
  37. package/dist/plugins/recently-viewed.js +1 -1
  38. package/dist/plugins/reveal-on-scroll.js +1 -1
  39. package/dist/plugins/reviews.js +2 -2
  40. package/dist/plugins/runtime-slot-loader.js +1 -1
  41. package/dist/plugins/seasonal-effects.js +1 -1
  42. package/dist/plugins/size-guide.d.ts +9 -0
  43. package/dist/plugins/size-guide.js +38 -0
  44. package/dist/plugins/sticky-atc.js +1 -1
  45. package/dist/plugins/tabs.js +1 -1
  46. package/dist/plugins/variant-selects.js +1 -1
  47. package/dist/popup-shell.css +15 -0
  48. package/dist/primitives.css +4 -1
  49. package/dist/rail-base.css +48 -0
  50. package/dist/runtime-core.d.ts +2 -0
  51. package/dist/runtime-core.js +26 -15
  52. package/dist/section-gate.js +2754 -37
  53. package/dist/tokens.css +4 -0
  54. package/package.json +11 -1
@@ -0,0 +1,394 @@
1
+ // @gemboss/gem-framework/plugins/bundle: the gem-runtime plugin "bundle" as an ES module. Generated from
2
+ // packages/gem-framework/src/runtime.ts, the same lines gem-runtime.js carries; do not edit here.
3
+ import { mountPlugin } from "../runtime-core.js";
4
+
5
+ export const key = "bundle";
6
+ export const readyEvent = "gem:bundle:ready";
7
+
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
+ registerPlugin('bundle', ({ api }) => {
10
+ let boundBundles = 0;
11
+ const BB_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
12
+ document.querySelectorAll('[data-bb-root]').forEach((root) => {
13
+ if (!mark(root, 'bundle')) return;
14
+ boundBundles += 1;
15
+ const on = (el, type, fn, opt) => { if (canListen(el)) __gemListen(el, type, fn, opt); };
16
+ const isEl = (t) => t instanceof window.Element;
17
+ const parse = (attr, fallback) => { try { const v = JSON.parse(root.getAttribute(attr) || 'null'); return v == null ? fallback : v; } catch (e) { return fallback; } };
18
+ let rules = parse('data-bb-rules', {});
19
+ const text = root.hasAttribute('data-bb-messages') ? parse('data-bb-messages', {}) : parse('data-bb-labels', {});
20
+ const viMoney = root.getAttribute('data-bb-money') === 'vi';
21
+ const currency = root.getAttribute('data-bb-currency') || (viMoney ? 'VND' : 'USD');
22
+ let nf = null;
23
+ if (!viMoney) { try { nf = new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: currency === 'VND' ? 0 : 2 }); } catch (e) { nf = null; } }
24
+ const money = (n) => (viMoney ? String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, '.') + 'đ' : nf ? nf.format(Math.round(n * 100) / 100) : (Math.round(n * 100) / 100).toFixed(2));
25
+ // A strike price as stores show it: a whole number drops its cents ("$42", not "$42.00").
26
+ const moneyShort = (n) => { const s = money(n); return Math.abs(n - Math.round(n)) < 0.005 ? s.replace(/[.,]00(?=\D*$)/, '') : s; };
27
+ const fill = (tpl, vars) => String(tpl || '').replace(/\{(\w+)\}/g, (m, k) => (Object.hasOwn(vars, k) ? String(vars[k]) : m));
28
+ // A sentence with {reward}: the reward is a <strong>, the rest plain text. Never innerHTML.
29
+ const say = (el, tpl, vars) => {
30
+ if (!el) return;
31
+ const parts = String(tpl || '').split('{reward}');
32
+ el.replaceChildren();
33
+ parts.forEach((part, i) => {
34
+ if (part) el.append(document.createTextNode(fill(part, vars)));
35
+ if (i < parts.length - 1 && vars.reward) { const s = document.createElement('strong'); s.textContent = vars.reward; el.append(s); }
36
+ });
37
+ };
38
+ let min = Math.max(1, Number(rules.min) || 1);
39
+ let cap = Number(rules.max) > 0 ? Number(rules.max) : Infinity;
40
+ const per = Number(rules.per) > 0 ? Number(rules.per) : Infinity;
41
+ let tiers = Array.isArray(rules.tiers) ? rules.tiers : [];
42
+ let goal = tiers.length ? tiers[tiers.length - 1] : null;
43
+ // The catalog: one entry per item, plus one per extra variant of a card (<key>~<index>).
44
+ const catalog = new Map();
45
+ const cards = new Map();
46
+ root.querySelectorAll('[data-bb-item]').forEach((el) => {
47
+ const key = el.getAttribute('data-bb-item');
48
+ const base = {
49
+ el,
50
+ variantId: el.getAttribute('data-bb-variant') || '',
51
+ price: Number(el.getAttribute('data-bb-price')) || 0,
52
+ compare: Number(el.getAttribute('data-bb-compare')) || 0,
53
+ title: el.getAttribute('data-bb-title') || '',
54
+ subtitle: el.getAttribute('data-bb-subtitle') || '',
55
+ image: el.getAttribute('data-bb-image') || '',
56
+ group: el.getAttribute('data-bb-group') || '',
57
+ step: el.getAttribute('data-bb-step') || '',
58
+ short: el.getAttribute('data-bb-short') || '',
59
+ available: el.getAttribute('data-available') !== 'false',
60
+ };
61
+ catalog.set(key, base);
62
+ let vs = [];
63
+ try { vs = JSON.parse(el.getAttribute('data-bb-variants') || '[]'); } catch (e) { vs = []; }
64
+ if (!Array.isArray(vs)) vs = [];
65
+ vs.forEach((v, i) => {
66
+ if (!i) return;
67
+ catalog.set(key + '~' + i, { ...base, variantId: String(v.id || ''), price: Number(v.price) || 0, compare: Number(v.compare) || 0, image: v.image || base.image, variantTitle: v.title || '' });
68
+ });
69
+ if (vs[0]) base.variantTitle = vs[0].title || '';
70
+ cards.set(key, { el, variants: vs, gift: el.hasAttribute('data-bb-gift') });
71
+ });
72
+ const gifts = Array.from(cards.entries()).filter(([, c]) => c.gift).map(([k]) => k);
73
+ // The variant a card shows now (data-bb-current), as the key an action picks.
74
+ const currentKey = (key) => { const c = cards.get(key); const i = c ? Number(c.el.getAttribute('data-bb-current')) || 0 : 0; return i ? key + '~' + i : key; };
75
+ let picks = [];
76
+ let plan = 'one_time';
77
+ const planOf = (el) => (el.type === 'checkbox' ? (el.checked ? 'subscribe' : 'one_time') : el.getAttribute('data-bb-plan') || 'one_time');
78
+ const planInputs = Array.from(root.querySelectorAll('input[data-bb-plan]'));
79
+ const checkedPlan = planInputs.find((el) => el.checked);
80
+ if (checkedPlan) plan = planOf(checkedPlan);
81
+ const tierFor = (n) => tiers.filter((t) => n >= t.min).pop() || null;
82
+ const pctOf = (t, p) => (!t ? 0 : p === 'subscribe' ? Number(t.sub) || Number(t.pct) || 0 : Number(t.pct) || 0);
83
+ // The reward in a progress sentence is always "N% off" for the plan in force; a tier's label is the text on its milestone.
84
+ const rewardOf = (t, p) => (!t ? '' : pctOf(t, p) + '% off');
85
+ const qtyOf = (key) => picks.filter((p) => p === key).length;
86
+ const canAdd = (key) => picks.length < cap && (rules.repeat !== false || !picks.includes(key)) && qtyOf(key) < per;
87
+
88
+ // Box sizes, when the root declares them (data-bb-sizes): choosing a size sets floor = ceiling = its item count. "trim":
89
+ // a smaller box drops the LAST picks; "queue": they wait, in order, and come back when there is room again.
90
+ const sizeMode = root.getAttribute('data-bb-sizes');
91
+ const sizes = Array.isArray(rules.sizes) ? rules.sizes : [];
92
+ let size = null;
93
+ const waiting = [];
94
+ const refill = () => { while (sizeMode === 'queue' && picks.length < cap && waiting.length) picks.push(waiting.shift()); };
95
+ const applySize = (n) => {
96
+ const s = sizes.find((x) => Number(x.items) === n);
97
+ if (!s) return false;
98
+ size = s; min = Number(s.items); cap = Number(s.items);
99
+ goal = tierFor(min) || (tiers.length ? tiers[tiers.length - 1] : null);
100
+ if (picks.length > cap) { const out = picks.splice(cap); if (sizeMode === 'queue') waiting.unshift(...out); }
101
+ refill();
102
+ return true;
103
+ };
104
+ if (sizeMode) { const s0 = sizes.find((s) => Number(s.items) === Number(rules.size)) || sizes[0] || null; if (s0) applySize(Number(s0.items)); }
105
+
106
+ // Kits (rules.kits): each keeps its own picks and steps; a kit's own rules replace the bundle's while it is chosen.
107
+ // A kit with steps fills each step's slots in order and moves on by itself; optional steps open after the required ones.
108
+ const kits = Array.isArray(rules.kits) && rules.kits.length ? rules.kits : null;
109
+ const stateOf = new Map();
110
+ (kits || []).forEach((k) => {
111
+ const steps = Array.isArray(k.steps) ? k.steps : [];
112
+ const slots = {};
113
+ steps.forEach((s) => { slots[s.id] = Array.from({ length: Math.max(1, Number(s.pick) || 1) }, () => null); });
114
+ stateOf.set(k.id, { kit: k, steps, fill: slots, picks: [], current: steps.length ? steps[0].id : null });
115
+ });
116
+ let st = kits ? stateOf.get(kits[0].id) : { kit: null, steps: [], fill: {}, picks, current: null };
117
+ const useKit = (id) => {
118
+ if (!kits) return;
119
+ st = stateOf.get(id) || stateOf.get(kits[0].id);
120
+ const k = st.kit;
121
+ rules = { ...rules, min: k.min, max: k.max, tiers: k.tiers, repeat: k.repeat, price_mode: k.price_mode, fixed_price: k.fixed_price };
122
+ min = Math.max(1, Number(k.min) || 1);
123
+ cap = Number(k.max) > 0 ? Number(k.max) : Infinity;
124
+ tiers = Array.isArray(k.tiers) ? k.tiers : [];
125
+ goal = tiers.length ? tiers[tiers.length - 1] : null;
126
+ picks = st.picks;
127
+ };
128
+ if (kits) useKit(kits[0].id);
129
+ const stepsMode = () => st.steps.length > 0;
130
+ const required = () => st.steps.filter((s) => !s.optional);
131
+ const isFull = (s) => st.fill[s.id].every(Boolean);
132
+ // picks (in step order) are rebuilt from the slots after every change: price and cart read picks.
133
+ const syncPicks = () => { picks.length = 0; st.steps.forEach((s) => st.fill[s.id].forEach((k) => { if (k) picks.push(k); })); };
134
+ const openRequired = () => required().find((s) => !isFull(s)) || null;
135
+ const nextOpen = () => openRequired() || st.steps.find((s) => s.optional && !isFull(s)) || null;
136
+ const doneCount = () => required().filter(isFull).length;
137
+ const isComplete = () => (stepsMode() ? doneCount() === required().length : picks.length >= min);
138
+ const reachable = (s) => { const open = openRequired(); return !open || st.steps.indexOf(s) <= st.steps.indexOf(open); };
139
+ const slotRef = (idx) => {
140
+ let n = 0;
141
+ for (const s of st.steps) for (let j = 0; j < st.fill[s.id].length; j++) { if (n === idx) return [s, j]; n++; }
142
+ return null;
143
+ };
144
+ // Into the card's step: its first empty slot; a full step swaps its last pick (a full optional step refuses).
145
+ const addToStep = (key) => {
146
+ const it = catalog.get(key);
147
+ const step = (it && st.steps.find((s) => s.id === it.step)) || st.steps.find((s) => s.id === st.current);
148
+ if (!step || (rules.repeat === false && picks.includes(key))) return false;
149
+ const row = st.fill[step.id];
150
+ let i = row.indexOf(null);
151
+ if (i < 0) { if (step.optional) return false; i = row.length - 1; }
152
+ row[i] = key;
153
+ syncPicks();
154
+ if (isFull(step)) { const n = nextOpen(); if (n) st.current = n.id; }
155
+ return true;
156
+ };
157
+ // "Edit" on a required step empties the slot, closes the gap and goes back to that step; on an optional step it only removes.
158
+ const editSlot = (idx) => {
159
+ const ref = slotRef(idx);
160
+ if (!ref) return;
161
+ const [s, j] = ref;
162
+ const row = st.fill[s.id];
163
+ row.splice(j, 1); row.push(null);
164
+ syncPicks();
165
+ if (!s.optional) st.current = s.id;
166
+ };
167
+ const clearAll = () => {
168
+ st.steps.forEach((s) => { st.fill[s.id] = st.fill[s.id].map(() => null); });
169
+ picks.length = 0; waiting.length = 0;
170
+ st.current = st.steps.length ? st.steps[0].id : null;
171
+ };
172
+
173
+ // Prices. totals(): the bundle price at the tier the bundle WILL reach once it has its minimum (stores show the bundle
174
+ // price from the first pick), plus a container's base price. totalsNow(): at the tier reached now, rounded to the cent
175
+ // first so total + saved add up.
176
+ let basePrice = 0;
177
+ let cartHead = null;
178
+ const sumOf = () => {
179
+ let sum = 0; let compare = 0;
180
+ picks.forEach((k) => { const it = catalog.get(k); if (!it) return; sum += it.price; compare += Math.max(it.compare, it.price); });
181
+ return { sum, compare };
182
+ };
183
+ const totals = (p) => {
184
+ const { sum, compare } = sumOf();
185
+ if (rules.price_mode === 'fixed' && !basePrice) {
186
+ // A box size with a fixed one-time price; or a kit with one price whatever is picked (no strike price then).
187
+ if (size && Number(size.fixed) > 0) { if (p !== 'subscribe') return { sum, compare, total: Number(size.fixed) }; }
188
+ else if (Number(rules.fixed_price) > 0) return { sum, compare: 0, total: Number(rules.fixed_price) };
189
+ }
190
+ const t = tierFor(Math.max(picks.length, min)) || goal;
191
+ return { sum, compare, total: basePrice + sum * (1 - pctOf(t, p) / 100) };
192
+ };
193
+ const totalsNow = (p) => {
194
+ const { sum, compare } = sumOf();
195
+ return { sum, compare, total: Math.round(sum * (1 - pctOf(tierFor(picks.length), p) / 100) * 100 + 1e-6) / 100 };
196
+ };
197
+ // One item's price on its card at the bundle's current tier, counting at least one item.
198
+ const unitNow = (it, p) => it.price * (1 - pctOf(tierFor(Math.max(picks.length, 1)), p) / 100);
199
+
200
+ const frequency = () => root.querySelector('[data-bb-frequency]');
201
+ const planId = () => {
202
+ if (plan !== 'subscribe') return '';
203
+ const f = frequency();
204
+ if (!f) return '';
205
+ if (f.matches('select')) return f.value;
206
+ const c = f.querySelector('input:checked');
207
+ return c ? c.value : '';
208
+ };
209
+ const bundleName = () => {
210
+ const n = (st.kit && st.kit.label) || (root.querySelector('[data-bb-name]') || {}).textContent || 'Bundle';
211
+ return String(n).trim();
212
+ };
213
+ const toCart = () => {
214
+ const counts = new Map();
215
+ picks.concat(gifts).forEach((k) => { const it = catalog.get(k); if (it && it.variantId) counts.set(it.variantId, (counts.get(it.variantId) || 0) + 1); });
216
+ const ruleKey = root.getAttribute('data-bb-discount-key') || '';
217
+ const id = (root.getAttribute('data-gem-section-id') || 'bundle') + '-' + Date.now().toString(36);
218
+ const props = [{ key: '_bundle_id', value: id }, { key: '_bundle', value: bundleName() }].concat(ruleKey ? [{ key: '_bundle_rule', value: ruleKey }] : []);
219
+ const pid = planId();
220
+ const lines = Array.from(counts.entries()).map(([variantId, quantity]) => ({ variantId, quantity, properties: props.slice(), ...(pid ? { sellingPlanId: pid } : {}) }));
221
+ // A container line (a gift box) goes first and carries the section's own properties (lid, gift message).
222
+ const head = cartHead ? cartHead() : null;
223
+ if (head) {
224
+ const hp = props.concat(Array.isArray(head.properties) ? head.properties : []);
225
+ if (head.variantId) lines.unshift({ variantId: head.variantId, quantity: 1, properties: hp, ...(pid ? { sellingPlanId: pid } : {}) });
226
+ else if (lines[0]) lines[0].properties = hp;
227
+ }
228
+ if (lines.length && typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:cart:add-many', { detail: { lines, after: 'drawer' } }));
229
+ return lines.length;
230
+ };
231
+
232
+ let busy = false;
233
+ let wasComplete = false;
234
+ const state = () => {
235
+ const n = picks.length;
236
+ const counts = {};
237
+ picks.forEach((k) => { counts[k] = (counts[k] || 0) + 1; });
238
+ return {
239
+ picks, count: n, counts, min, cap, max: cap === Infinity ? null : cap, per, rules, text, tiers, goal, plan, size, sizes, waiting,
240
+ catalog, cards, gifts, reached: tierFor(n), next: tiers.find((t) => n < t.min) || null, left: Math.max(0, min - n),
241
+ kit: st.kit, kits, steps: st.steps, fill: st.fill, current: st.current, stepsMode: stepsMode(), done: doneCount(),
242
+ required: required(), complete: isComplete(), busy, base: basePrice,
243
+ };
244
+ };
245
+ const outs = () => root.querySelectorAll('[data-bb-out]');
246
+ const emit = (action, extra) => {
247
+ const n = picks.length;
248
+ const complete = isComplete();
249
+ root.setAttribute('data-bb-count', String(n));
250
+ root.setAttribute('data-bb-state', n === 0 ? 'empty' : n >= cap ? 'full' : complete ? 'complete' : 'partial');
251
+ if (outs().length) {
252
+ const t = totals(plan); const reached = tierFor(n);
253
+ const v = { count: String(n), left: String(Math.max(0, min - n)), total: money(t.total), compare: money(t.compare), saved: money(Math.max(0, t.compare - t.total)), reward: reached ? rewardOf(reached, plan) : '' };
254
+ outs().forEach((el) => { const k = el.getAttribute('data-bb-out'); if (Object.hasOwn(v, k)) el.textContent = v[k]; });
255
+ }
256
+ const detail = { source: 'gem-runtime', action, ...(extra || {}), count: n, min, max: cap === Infinity ? null : cap, plan, size: size ? Number(size.items) : null, kit: st.kit ? st.kit.id : null, step: st.current, complete, reward: tierFor(n) ? rewardOf(tierFor(n), plan) : '', total: totals(plan).total };
257
+ if (typeof window.CustomEvent === 'function') {
258
+ root.dispatchEvent(new window.CustomEvent('gem:bundle:change', { bubbles: true, detail }));
259
+ if (complete && !wasComplete) root.dispatchEvent(new window.CustomEvent('gem:bundle:complete', { bubbles: true, detail }));
260
+ }
261
+ wasComplete = complete;
262
+ };
263
+
264
+ // Actions: the handlers below call these, and so may a section's own controls (root.gemBundle.add(key)…).
265
+ const add = (key) => {
266
+ const before = st.current;
267
+ if (root.hasAttribute('data-bb-add-toggle') && picks.includes(key)) { picks.splice(picks.indexOf(key), 1); emit('remove', { key }); return true; }
268
+ if (stepsMode()) { if (!addToStep(key)) return false; }
269
+ else { if (!canAdd(key)) return false; picks.push(key); }
270
+ emit('add', { key, from: before });
271
+ return true;
272
+ };
273
+ const removeAt = (idx) => { if (stepsMode()) editSlot(idx); else { picks.splice(idx, 1); refill(); } emit('remove', { index: idx }); };
274
+ const dec = (key) => { const at = picks.lastIndexOf(key); if (at < 0) return false; picks.splice(at, 1); refill(); emit('dec', { key }); return true; };
275
+ const uncheck = (key) => { for (let i = picks.length - 1; i >= 0; i -= 1) if (picks[i] === key) picks.splice(i, 1); refill(); emit('uncheck', { key }); };
276
+ const clear = () => { clearAll(); emit('clear'); };
277
+ // A typed quantity: exactly k picks of that item, within the bundle's ceiling, the item's own and no-repeat.
278
+ const setQty = (key, want) => {
279
+ if (!catalog.has(key)) return;
280
+ const has = qtyOf(key);
281
+ const room = cap === Infinity ? Infinity : cap - (picks.length - has);
282
+ const k = Math.max(0, Math.min(Math.floor(Number(want)) || 0, room, per, rules.repeat === false ? 1 : Infinity));
283
+ for (let i = picks.length - 1; i >= 0; i--) if (picks[i] === key) picks.splice(i, 1);
284
+ for (let i = 0; i < k; i++) picks.push(key);
285
+ emit('qty', { key });
286
+ };
287
+ const setPlan = (p) => { plan = p === 'subscribe' ? 'subscribe' : 'one_time'; emit('plan'); };
288
+ const setSize = (n) => {
289
+ if (!sizeMode || !applySize(Number(n))) return false;
290
+ // A box size may name its default selling plan.
291
+ const f = frequency();
292
+ if (f && f.matches('select') && size && size.plan && Array.from(f.options).some((o) => o.value === size.plan)) f.value = size.plan;
293
+ emit('size');
294
+ return true;
295
+ };
296
+ // An item's variant radio (a pack size): the whole row changes variant and price, its count stays.
297
+ const applyVariant = (radio) => {
298
+ const it = catalog.get(radio.getAttribute('data-bb-size'));
299
+ if (!it) return false;
300
+ it.variantId = radio.getAttribute('data-bb-variant') || '';
301
+ it.price = Number(radio.getAttribute('data-bb-price')) || 0;
302
+ it.compare = Number(radio.getAttribute('data-bb-compare')) || 0;
303
+ it.el.setAttribute('data-bb-variant', it.variantId);
304
+ it.el.setAttribute('data-bb-price', String(it.price));
305
+ it.el.setAttribute('data-bb-compare', it.compare ? String(it.compare) : '');
306
+ return true;
307
+ };
308
+ const selectKit = (id) => { if (!kits) return; useKit(id); emit('kit'); };
309
+ const goStep = (id) => { const s = st.steps.find((x) => x.id === id); if (!s || !reachable(s)) return false; st.current = id; emit('step'); return true; };
310
+ const finish = (ok) => {
311
+ busy = false;
312
+ root.querySelectorAll('[data-bb-cta]').forEach((c) => { c.removeAttribute('data-state'); c.removeAttribute('aria-busy'); });
313
+ if (ok) clearAll();
314
+ emit(ok ? 'added' : 'add-failed');
315
+ };
316
+ const checkout = () => {
317
+ if (!isComplete() || busy || !picks.length) return false;
318
+ if (!toCart()) return false;
319
+ // With gemsite's cart bridge: wait for its answer before clearing (a failure keeps the bundle to try again).
320
+ if (window.__gemCartAddBridge) {
321
+ busy = true;
322
+ const box = st.kit ? root.querySelector('[data-bb-kit-box="' + st.kit.id + '"]') : null;
323
+ (box || root).querySelectorAll('[data-bb-cta]').forEach((c) => { c.setAttribute('data-state', 'loading'); c.setAttribute('aria-busy', 'true'); });
324
+ emit('checkout');
325
+ } else finish(true);
326
+ return true;
327
+ };
328
+
329
+ on(root, 'click', (event) => {
330
+ const t = isEl(event.target) ? event.target : null;
331
+ if (!t) return;
332
+ const addEl = t.closest('[data-bb-add], [data-bb-inc]');
333
+ if (addEl) { add(currentKey(addEl.getAttribute('data-bb-add') || addEl.getAttribute('data-bb-inc'))); return; }
334
+ const decEl = t.closest('[data-bb-dec], [data-bb-minus]');
335
+ if (decEl) { dec(currentKey(decEl.getAttribute('data-bb-dec') || decEl.getAttribute('data-bb-minus'))); return; }
336
+ const un = t.closest('[data-bb-uncheck]');
337
+ if (un) { uncheck(currentKey(un.getAttribute('data-bb-uncheck'))); return; }
338
+ const rm = t.closest('[data-bb-remove]');
339
+ if (rm) { removeAt(Number(rm.getAttribute('data-bb-remove')) || 0); return; }
340
+ if (t.closest('[data-bb-clear]')) { clear(); return; }
341
+ const nav = t.closest('[data-bb-step-nav]');
342
+ if (nav) { if (!nav.disabled) goStep(nav.getAttribute('data-bb-step-nav')); return; }
343
+ const tab = t.closest('[data-bb-kit-tab]');
344
+ if (tab) { selectKit(tab.getAttribute('data-bb-kit-tab')); return; }
345
+ if (t.closest('[data-bb-skip]')) { checkout(); return; }
346
+ const cta = t.closest('[data-bb-cta]');
347
+ if (cta) { event.preventDefault(); if (cta.getAttribute('aria-disabled') !== 'true') checkout(); }
348
+ });
349
+ on(root, 'change', (event) => {
350
+ const t = isEl(event.target) ? event.target : null;
351
+ if (!t) return;
352
+ const p = t.closest('input[data-bb-plan]');
353
+ if (p) { if (p.type === 'checkbox' || p.checked) setPlan(planOf(p)); return; }
354
+ const s = t.closest('input[data-bb-size]');
355
+ if (s && s.checked) { if (s.hasAttribute('data-bb-variant')) { if (applyVariant(s)) emit('variant', { key: s.getAttribute('data-bb-size') }); } else setSize(Number(s.value)); return; }
356
+ const q = t.closest('input[data-bb-count]');
357
+ if (q) setQty(q.getAttribute('data-bb-count'), q.value);
358
+ });
359
+ // A typed quantity applies as soon as it is a number (an emptied box waits for "change" to go back to 0).
360
+ on(root, 'input', (event) => {
361
+ const q = isEl(event.target) ? event.target.closest('input[data-bb-count]') : null;
362
+ if (q && q.value !== '' && Number.isFinite(Number(q.value))) setQty(q.getAttribute('data-bb-count'), q.value);
363
+ });
364
+ on(window, 'gem:cart:added', () => { if (busy) finish(true); });
365
+ on(window, 'gem:cart:add-failed', () => { if (busy) finish(false); });
366
+
367
+ // A page brought back from history restores its form: read back the variant radios and typed quantities in view.
368
+ root.querySelectorAll('input[data-bb-size][data-bb-variant]').forEach((r) => { if (r.checked) applyVariant(r); });
369
+ root.querySelectorAll('input[data-bb-count]').forEach((inp) => {
370
+ const v = Math.floor(Number(inp.value)) || 0;
371
+ const key = inp.getAttribute('data-bb-count');
372
+ if (v > 0 && catalog.has(key)) { const k = Math.min(v, per, rules.repeat === false ? 1 : Infinity, cap === Infinity ? Infinity : cap - picks.length); for (let i = 0; i < k; i++) picks.push(key); }
373
+ });
374
+
375
+ root.gemBundle = {
376
+ state, money, moneyShort, fill, say, words: BB_WORDS, tierFor, pctOf, rewardOf, canAdd, qtyOf, currentKey, totals, totalsNow, unitNow,
377
+ reachable, isFull, slotRef, required, add, removeAt, dec, uncheck, clear, setQty, setPlan, setSize, selectKit, goStep, checkout,
378
+ setBase: (n) => { basePrice = Number(n) || 0; emit('base'); },
379
+ setCartHead: (fn) => { cartHead = typeof fn === 'function' ? fn : null; },
380
+ refresh: () => emit('refresh'),
381
+ };
382
+ root.setAttribute('data-bb-ready', '');
383
+ emit('init');
384
+ });
385
+ api.emit('gem:bundle:ready', { source: 'gem-runtime', count: boundBundles });
386
+ });
387
+ }
388
+
389
+ /** Bind every "bundle" hook inside root (default: the whole document). Returns unmount(). Mounting the same root twice binds once. */
390
+ export function mount(root, options) {
391
+ return mountPlugin(key, install, root, options);
392
+ }
393
+
394
+ export default mount;
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "carousel";
6
6
  export const readyEvent = "gem:carousel:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('carousel', ({ api }) => {
10
10
  const carousels = document.querySelectorAll('[data-gem-carousel], .gem-carousel');
11
11
  let bound = 0;
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "cart-drawer";
6
6
  export const readyEvent = "gem:cart:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('cart-drawer', ({ api }) => {
10
10
  const drawers = document.querySelectorAll('[data-gem-cart-drawer], [data-gem-cart-canvas], .gem-cart-drawer');
11
11
  const cartLinks = document.querySelectorAll('[data-gem-cart-drawer-trigger], [data-gem-cart-toggle], a[href*="/cart"]');
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "context-adapter";
6
6
  export const readyEvent = "gem:context:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerNoopPlugin("context-adapter", "gem:context:ready");
10
10
  }
11
11
 
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "countdown";
6
6
  export const readyEvent = "gem:countdown:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('countdown', ({ api }) => {
10
10
  const timers = document.querySelectorAll('[data-gem-countdown], .gem-countdown');
11
11
  let bound = 0;
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "disclosure";
6
6
  export const readyEvent = "gem:disclosure:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('disclosure', ({ api }) => {
10
10
  const items = document.querySelectorAll('details[data-gem-disclosure], .gem-accordion details, .gem-tabs details, .gem-footer-shell details, [data-gem-footer-shell] details');
11
11
  let bound = 0;
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "hero-slideshow";
6
6
  export const readyEvent = "gem:hero-slideshow:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('hero-slideshow', ({ api }) => {
10
10
  const roots = document.querySelectorAll('[data-gem-hero-slideshow]');
11
11
  let bound = 0;
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "img-in";
6
6
  export const readyEvent = "gem:img-in:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('img-in', ({ api }) => {
10
10
  const STATE = 'data-gem-img-in-state';
11
11
  const BOUND = 'data-gem-bound-img-in';
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "localization";
6
6
  export const readyEvent = "gem:localization:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('localization', ({ api }) => {
10
10
  const forms = document.querySelectorAll('form[action*="/localization"], form[action*="localization"], [data-gem-footer-localization], [data-gem-header-localization], .gem-navigation-shell__localization, .gem-shell-footer__localization, .gem-shell-header__localization-form');
11
11
  let bound = 0;
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "media-gallery";
6
6
  export const readyEvent = "gem:media:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('media-gallery', ({ api }) => {
10
10
  const galleries = document.querySelectorAll('[data-gem-media-gallery], .gem-media-gallery, .gem-product-media-gallery');
11
11
  let bound = 0;
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "mobile-menu";
6
6
  export const readyEvent = "gem:menu:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('mobile-menu', ({ api }) => {
10
10
  const menus = document.querySelectorAll('[data-gem-mobile-menu], details.gem-shell-header__drawer');
11
11
  let bound = 0;
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "offer-engine";
6
6
  export const readyEvent = "gem:offer:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('offer-engine', ({ api }) => {
10
10
  (function(){
11
11
  document.querySelectorAll('[data-gem-offer-engine]').forEach(function(root){
@@ -0,0 +1,9 @@
1
+ // @gemboss/gem-framework/plugins/orbit: types of the ES module next to this file. Generated; do not edit here.
2
+ import type { MountOptions, Unmount } from "../runtime-core.js";
3
+
4
+ export type { MountOptions, Unmount };
5
+ export declare const key: "orbit";
6
+ export declare const readyEvent: "gem:orbit:ready";
7
+ /** Bind every "orbit" hook inside root (default: the whole document). Returns unmount(). Mounting the same root twice binds once. */
8
+ export declare function mount(root?: ParentNode | null, options?: MountOptions): Unmount;
9
+ export default mount;
@@ -0,0 +1 @@
1
+ import{mountPlugin as f}from"../runtime-core.js";import{mountEffects as _}from"../motion/core.js";import{orbitEffect as d}from"../motion/orbit.js";const l="orbit",P="gem:orbit:ready";function p({document:t,window:e,target:y,mark:a,canListen:i,listen:m,registerPlugin:s,registerNoopPlugin:w,onUnmount:g}){s("orbit",({api:o})=>{const n=_(d,{window:e,document:t,on:(r,b,c,u)=>{i(r)&&m(r,b,c,u)},mark:a,api:o,css:".gem-orbit{--gem-orbit-css:1;outline:none;user-select:none;-webkit-user-select:none;transition:background-color var(--gem-motion-slow,0.6s) var(--gem-ease-soft,ease)}.gem-orbit[data-gem-state] .gem-orbit__stage{touch-action:pan-y;cursor:grab}.gem-orbit[data-gem-state='dragging'] .gem-orbit__stage{cursor:grabbing}.gem-orbit[data-gem-state] .gem-orbit__item{will-change:transform}.gem-orbit[data-gem-state] .gem-orbit__hit{transition:opacity var(--gem-motion-base,0.35s) var(--gem-ease-soft,ease),transform var(--gem-motion-base,0.35s) var(--gem-ease-soft,ease)}.gem-orbit[data-gem-state] .gem-orbit__bar{transition:transform var(--gem-motion-slow,0.4s) var(--gem-ease-soft,ease)}.gem-orbit[data-gem-state] .gem-orbit__arrow{transition:color var(--gem-motion-fast,0.2s) ease,background-color var(--gem-motion-fast,0.2s) ease}.gem-orbit[data-gem-state='open'] .gem-orbit__item:not([aria-current='true']) .gem-orbit__hit{opacity:0;transform:scale(0.92)}@media (prefers-reduced-motion:reduce){.gem-orbit,.gem-orbit[data-gem-state] .gem-orbit__hit,.gem-orbit[data-gem-state] .gem-orbit__bar,.gem-orbit[data-gem-state] .gem-orbit__arrow{transition:none}}"},g);o.emit("gem:orbit:ready",{source:"gem-runtime",count:n})})}function v(t,e){return f(l,p,t,e)}var N=v;export{N as default,l as key,v as mount,P as readyEvent};
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "predictive-search";
6
6
  export const readyEvent = "gem:search:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('predictive-search', ({ api }) => {
10
10
  const forms = document.querySelectorAll('[data-gem-predictive-search], [data-gem-search-canvas], form[action*="/search"], form[role="search"]');
11
11
  const toggles = document.querySelectorAll('[data-gem-search-toggle]');
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "promo-popup";
6
6
  export const readyEvent = "gem:promo-popup:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('promo-popup', ({ api }) => {
10
10
  const popups = document.querySelectorAll('[data-gem-promo-popup]');
11
11
  let bound = 0;
@@ -5,7 +5,7 @@ import { mountPlugin } from "../runtime-core.js";
5
5
  export const key = "quantity-input";
6
6
  export const readyEvent = "gem:quantity:ready";
7
7
 
8
- function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {
8
+ function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {
9
9
  registerPlugin('quantity-input', ({ api }) => {
10
10
  const controls = document.querySelectorAll('[data-gem-quantity-input], .gem-quantity-input');
11
11
  let bound = 0;