@gemboss/gem-framework 0.18.15 → 0.18.16

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 CHANGED
@@ -93,6 +93,32 @@ Từ 0.8.2 mỗi module có `.d.ts` cạnh nó (`mount(root?: ParentNode | null,
93
93
 
94
94
  ## Nhật ký thay đổi
95
95
 
96
+ - **0.18.16** (20/10/2026): plugin `bundle`, mô hình bộ tự chọn BYOB (GemFactory gap #41). 9 section bundle-builder của GemFactory
97
+ (slot-sidebar, box-size-first, routine-kit, tier-bar, system-list, reward-tiles, silhouette-tray, gift-box, grid-milestone-panel)
98
+ mỗi cái chép một khối "LÕI BYOB" trong behavior.js. Phần chúng VẼ khác nhau nhiều (ô + tab, cỡ hộp + độ đa dạng, thang bậc + bộ
99
+ đếm, kit + bước, hộp quà có nắp), nên engine là mô hình và bộ điều khiển, không vẽ.
100
+ - Section xin engine bằng `data-bb-root`.
101
+ - Engine giữ luật (`data-bb-rules`: min, max, per, repeat, tiers, price_mode, sizes, kits + steps), món đã chọn, cỡ hộp, kit và
102
+ bước, giá. Có ba cách tính: `totals` (theo bậc bộ sẽ đạt), `totalsNow` (theo bậc đang đạt), `unitNow` (giá một món trên thẻ).
103
+ - Engine xử lý mọi thao tác đổi bộ: `[data-bb-add]` / `[data-bb-inc]`, `[data-bb-dec]` / `[data-bb-minus]`, `[data-bb-uncheck]`,
104
+ `[data-bb-remove]`, `[data-bb-clear]`, ô số `input[data-bb-count]`, cách mua `input[data-bb-plan]` (radio hoặc checkbox),
105
+ `input[data-bb-size]` (cỡ hộp, hoặc biến thể của một món khi có `data-bb-variant`), `[data-bb-kit-tab]`, `[data-bb-step-nav]`,
106
+ `[data-bb-cta]` / `[data-bb-skip]` (nút mang `aria-disabled="true"` thì không gửi).
107
+ - Bật theo thuộc tính, không đổi gì của section không khai: cỡ hộp `data-bb-sizes="trim|queue"` (hạ cỡ thì bỏ món cuối, hoặc
108
+ giữ chúng chờ vào lại), bấm lại để bỏ `data-bb-add-toggle`, tiền "1.234đ" `data-bb-money="vi"`, chữ ở `data-bb-labels`; theo
109
+ dữ liệu của món: biến thể theo thẻ (`data-bb-variants` + `data-bb-current`, khoá `<thẻ>~<chỉ số>`), món quà `data-bb-gift`
110
+ (vào giỏ cùng bộ, không tính số món).
111
+ - Giỏ: MỘT `gem:cart:add-many`, mỗi dòng mang `_bundle_id`, `_bundle`, `_bundle_rule`, cộng `sellingPlanId` khi định kỳ. Có cầu giỏ
112
+ gemsite thì chờ `gem:cart:added` / `gem:cart:add-failed`. Phần riêng của section nối vào qua `setBase(giá)` (giá vỏ hộp vào tổng)
113
+ và `setCartHead(fn)` (dòng vỏ hộp đứng đầu, mang thuộc tính nắp / quà).
114
+ - Sau mỗi lần đổi phát `gem:bundle:change` (kèm `action`) trên gốc. API `root.gemBundle` (`state()`, `money`, `fill`, `say`, toán
115
+ bậc / giá, các thao tác) cho `paint()` của section.
116
+ - Đầu ra chung tuỳ chọn `[data-bb-out]` cho trang không tự vẽ.
117
+
118
+ Đối chiếu trong Chromium, 9 section, 1280 và 390: behavior.js gốc so với engine + behavior chỉ còn phần vẽ, cùng chuỗi thao tác
119
+ (thêm, +, −, ✓, ×, xoá hết, ô số, đổi cách mua, tần suất, cỡ hộp, biến thể, chip, kit, bước, nắp, quà, sheet, nút chính + nội
120
+ dung `gem:cart:add-many`). Trạng thái mọi phần tử mang hook, focus và giỏ trùng từng bước (0 khác biệt); engine cố ý làm sai một
121
+ bậc thì cả 9 lệch. Kèm vá của `reviews`: dải ảnh nhỏ trong lightbox đặt `loading` trước `src`.
96
122
  - **0.18.15** (20/10/2026): plugin `reviews`, engine danh sách review (GemFactory gap #44). 8 section product-reviews của
97
123
  GemFactory mỗi cái mang một bản riêng của cùng một lõi trong behavior.js (14–27 KB mỗi cái); nay lõi đó là MỘT plugin:
98
124
  `gem-runtime.js` (load `idle`) hoặc `import { mount } from "@gemboss/gem-framework/plugins/reviews"`. Section xin engine bằng
package/dist/SOURCE.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "@gemboss/gem-framework",
3
- "version": "0.18.15",
4
- "gemtheme_sha": "998fe4dc3096e9bc0a8e9d39e23b27081138b2c9",
3
+ "version": "0.18.16",
4
+ "gemtheme_sha": "bd2fed3a51b065235fd57cda3526879241ed2332",
5
5
  "gemtheme_dirty": false,
6
- "framework_version": "0.18.15"
6
+ "framework_version": "0.18.16"
7
7
  }
@@ -1,5 +1,5 @@
1
1
  (() => {
2
- const config = {"planId":"gem-framework-package","base":"gem-native","dawnAdapter":false,"plugins":[{"key":"cart-drawer","loadStrategy":"interaction","dependsOn":[]},{"key":"mobile-menu","loadStrategy":"interaction","dependsOn":[]},{"key":"quantity-input","loadStrategy":"interaction","dependsOn":[]},{"key":"variant-selects","loadStrategy":"interaction","dependsOn":[]},{"key":"predictive-search","loadStrategy":"critical","dependsOn":[]},{"key":"localization","loadStrategy":"interaction","dependsOn":[]},{"key":"disclosure","loadStrategy":"interaction","dependsOn":[]},{"key":"tabs","loadStrategy":"interaction","dependsOn":[]},{"key":"media-gallery","loadStrategy":"viewport","dependsOn":[]},{"key":"carousel","loadStrategy":"viewport","dependsOn":[]},{"key":"hero-slideshow","loadStrategy":"idle","dependsOn":[]},{"key":"quick-view","loadStrategy":"interaction","dependsOn":[]},{"key":"promo-popup","loadStrategy":"idle","dependsOn":[]},{"key":"before_after","loadStrategy":"viewport","dependsOn":[]},{"key":"runtime-slot-loader","loadStrategy":"idle","dependsOn":[]},{"key":"sticky-atc","loadStrategy":"viewport","dependsOn":[]},{"key":"recently-viewed","loadStrategy":"idle","dependsOn":[]},{"key":"countdown","loadStrategy":"viewport","dependsOn":[]},{"key":"reveal-on-scroll","loadStrategy":"viewport","dependsOn":[]},{"key":"img-in","loadStrategy":"critical","dependsOn":[]},{"key":"seasonal-effects","loadStrategy":"idle","dependsOn":[]},{"key":"context-adapter","loadStrategy":"idle","dependsOn":[]},{"key":"offer-engine","loadStrategy":"interaction","dependsOn":[]},{"key":"reviews","loadStrategy":"idle","dependsOn":[]}]};
2
+ const config = {"planId":"gem-framework-package","base":"gem-native","dawnAdapter":false,"plugins":[{"key":"cart-drawer","loadStrategy":"interaction","dependsOn":[]},{"key":"mobile-menu","loadStrategy":"interaction","dependsOn":[]},{"key":"quantity-input","loadStrategy":"interaction","dependsOn":[]},{"key":"variant-selects","loadStrategy":"interaction","dependsOn":[]},{"key":"predictive-search","loadStrategy":"critical","dependsOn":[]},{"key":"localization","loadStrategy":"interaction","dependsOn":[]},{"key":"disclosure","loadStrategy":"interaction","dependsOn":[]},{"key":"tabs","loadStrategy":"interaction","dependsOn":[]},{"key":"media-gallery","loadStrategy":"viewport","dependsOn":[]},{"key":"carousel","loadStrategy":"viewport","dependsOn":[]},{"key":"hero-slideshow","loadStrategy":"idle","dependsOn":[]},{"key":"quick-view","loadStrategy":"interaction","dependsOn":[]},{"key":"promo-popup","loadStrategy":"idle","dependsOn":[]},{"key":"before_after","loadStrategy":"viewport","dependsOn":[]},{"key":"runtime-slot-loader","loadStrategy":"idle","dependsOn":[]},{"key":"sticky-atc","loadStrategy":"viewport","dependsOn":[]},{"key":"recently-viewed","loadStrategy":"idle","dependsOn":[]},{"key":"countdown","loadStrategy":"viewport","dependsOn":[]},{"key":"reveal-on-scroll","loadStrategy":"viewport","dependsOn":[]},{"key":"img-in","loadStrategy":"critical","dependsOn":[]},{"key":"seasonal-effects","loadStrategy":"idle","dependsOn":[]},{"key":"context-adapter","loadStrategy":"idle","dependsOn":[]},{"key":"offer-engine","loadStrategy":"interaction","dependsOn":[]},{"key":"reviews","loadStrategy":"idle","dependsOn":[]},{"key":"bundle","loadStrategy":"idle","dependsOn":[]}]};
3
3
  const target = window;
4
4
  const existing = target.GemTheme || {};
5
5
  const listeners = existing.listeners || {};
@@ -1584,7 +1584,7 @@
1584
1584
  const b = document.createElement('button');
1585
1585
  b.type = 'button'; b.className = strip.getAttribute('data-rv-thumb-class') || ''; b.setAttribute('data-rv-lb-thumb', String(k));
1586
1586
  b.setAttribute('aria-label', (strip.getAttribute('data-rv-thumb-label') || 'Media {n} of {total}').replace('{n}', String(k + 1)).replace('{total}', String(lbSet.length)));
1587
- const img = document.createElement('img'); img.src = x.thumb; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
1587
+ const img = document.createElement('img'); img.loading = 'lazy'; img.decoding = 'async'; img.alt = ''; img.src = x.thumb;
1588
1588
  b.appendChild(img); strip.appendChild(b);
1589
1589
  });
1590
1590
  strip.hidden = lbSet.length <= 1;
@@ -1708,6 +1708,384 @@
1708
1708
  });
1709
1709
  api.emit('gem:reviews:ready', { source: 'gem-runtime', count: boundReviews });
1710
1710
  });
1711
+ registerPlugin('bundle', ({ api }) => {
1712
+ let boundBundles = 0;
1713
+ const BB_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
1714
+ document.querySelectorAll('[data-bb-root]').forEach((root) => {
1715
+ if (!mark(root, 'bundle')) return;
1716
+ boundBundles += 1;
1717
+ const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };
1718
+ const isEl = (t) => t instanceof window.Element;
1719
+ const parse = (attr, fallback) => { try { const v = JSON.parse(root.getAttribute(attr) || 'null'); return v == null ? fallback : v; } catch (e) { return fallback; } };
1720
+ let rules = parse('data-bb-rules', {});
1721
+ const text = root.hasAttribute('data-bb-messages') ? parse('data-bb-messages', {}) : parse('data-bb-labels', {});
1722
+ const viMoney = root.getAttribute('data-bb-money') === 'vi';
1723
+ const currency = root.getAttribute('data-bb-currency') || (viMoney ? 'VND' : 'USD');
1724
+ let nf = null;
1725
+ if (!viMoney) { try { nf = new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: currency === 'VND' ? 0 : 2 }); } catch (e) { nf = null; } }
1726
+ 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));
1727
+ // A strike price as stores show it: a whole number drops its cents ("$42", not "$42.00").
1728
+ const moneyShort = (n) => { const s = money(n); return Math.abs(n - Math.round(n)) < 0.005 ? s.replace(/[.,]00(?=\D*$)/, '') : s; };
1729
+ const fill = (tpl, vars) => String(tpl || '').replace(/\{(\w+)\}/g, (m, k) => (Object.hasOwn(vars, k) ? String(vars[k]) : m));
1730
+ // A sentence with {reward}: the reward is a <strong>, the rest plain text. Never innerHTML.
1731
+ const say = (el, tpl, vars) => {
1732
+ if (!el) return;
1733
+ const parts = String(tpl || '').split('{reward}');
1734
+ el.replaceChildren();
1735
+ parts.forEach((part, i) => {
1736
+ if (part) el.append(document.createTextNode(fill(part, vars)));
1737
+ if (i < parts.length - 1 && vars.reward) { const s = document.createElement('strong'); s.textContent = vars.reward; el.append(s); }
1738
+ });
1739
+ };
1740
+ let min = Math.max(1, Number(rules.min) || 1);
1741
+ let cap = Number(rules.max) > 0 ? Number(rules.max) : Infinity;
1742
+ const per = Number(rules.per) > 0 ? Number(rules.per) : Infinity;
1743
+ let tiers = Array.isArray(rules.tiers) ? rules.tiers : [];
1744
+ let goal = tiers.length ? tiers[tiers.length - 1] : null;
1745
+ // The catalog: one entry per item, plus one per extra variant of a card (<key>~<index>).
1746
+ const catalog = new Map();
1747
+ const cards = new Map();
1748
+ root.querySelectorAll('[data-bb-item]').forEach((el) => {
1749
+ const key = el.getAttribute('data-bb-item');
1750
+ const base = {
1751
+ el,
1752
+ variantId: el.getAttribute('data-bb-variant') || '',
1753
+ price: Number(el.getAttribute('data-bb-price')) || 0,
1754
+ compare: Number(el.getAttribute('data-bb-compare')) || 0,
1755
+ title: el.getAttribute('data-bb-title') || '',
1756
+ subtitle: el.getAttribute('data-bb-subtitle') || '',
1757
+ image: el.getAttribute('data-bb-image') || '',
1758
+ group: el.getAttribute('data-bb-group') || '',
1759
+ step: el.getAttribute('data-bb-step') || '',
1760
+ short: el.getAttribute('data-bb-short') || '',
1761
+ available: el.getAttribute('data-available') !== 'false',
1762
+ };
1763
+ catalog.set(key, base);
1764
+ let vs = [];
1765
+ try { vs = JSON.parse(el.getAttribute('data-bb-variants') || '[]'); } catch (e) { vs = []; }
1766
+ if (!Array.isArray(vs)) vs = [];
1767
+ vs.forEach((v, i) => {
1768
+ if (!i) return;
1769
+ 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 || '' });
1770
+ });
1771
+ if (vs[0]) base.variantTitle = vs[0].title || '';
1772
+ cards.set(key, { el, variants: vs, gift: el.hasAttribute('data-bb-gift') });
1773
+ });
1774
+ const gifts = Array.from(cards.entries()).filter(([, c]) => c.gift).map(([k]) => k);
1775
+ // The variant a card shows now (data-bb-current), as the key an action picks.
1776
+ 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; };
1777
+ let picks = [];
1778
+ let plan = 'one_time';
1779
+ const planOf = (el) => (el.type === 'checkbox' ? (el.checked ? 'subscribe' : 'one_time') : el.getAttribute('data-bb-plan') || 'one_time');
1780
+ const planInputs = Array.from(root.querySelectorAll('input[data-bb-plan]'));
1781
+ const checkedPlan = planInputs.find((el) => el.checked);
1782
+ if (checkedPlan) plan = planOf(checkedPlan);
1783
+ const tierFor = (n) => tiers.filter((t) => n >= t.min).pop() || null;
1784
+ const pctOf = (t, p) => (!t ? 0 : p === 'subscribe' ? Number(t.sub) || Number(t.pct) || 0 : Number(t.pct) || 0);
1785
+ // 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.
1786
+ const rewardOf = (t, p) => (!t ? '' : pctOf(t, p) + '% off');
1787
+ const qtyOf = (key) => picks.filter((p) => p === key).length;
1788
+ const canAdd = (key) => picks.length < cap && (rules.repeat !== false || !picks.includes(key)) && qtyOf(key) < per;
1789
+
1790
+ // Box sizes, when the root declares them (data-bb-sizes): choosing a size sets floor = ceiling = its item count. "trim":
1791
+ // a smaller box drops the LAST picks; "queue": they wait, in order, and come back when there is room again.
1792
+ const sizeMode = root.getAttribute('data-bb-sizes');
1793
+ const sizes = Array.isArray(rules.sizes) ? rules.sizes : [];
1794
+ let size = null;
1795
+ const waiting = [];
1796
+ const refill = () => { while (sizeMode === 'queue' && picks.length < cap && waiting.length) picks.push(waiting.shift()); };
1797
+ const applySize = (n) => {
1798
+ const s = sizes.find((x) => Number(x.items) === n);
1799
+ if (!s) return false;
1800
+ size = s; min = Number(s.items); cap = Number(s.items);
1801
+ goal = tierFor(min) || (tiers.length ? tiers[tiers.length - 1] : null);
1802
+ if (picks.length > cap) { const out = picks.splice(cap); if (sizeMode === 'queue') waiting.unshift(...out); }
1803
+ refill();
1804
+ return true;
1805
+ };
1806
+ if (sizeMode) { const s0 = sizes.find((s) => Number(s.items) === Number(rules.size)) || sizes[0] || null; if (s0) applySize(Number(s0.items)); }
1807
+
1808
+ // Kits (rules.kits): each keeps its own picks and steps; a kit's own rules replace the bundle's while it is chosen.
1809
+ // A kit with steps fills each step's slots in order and moves on by itself; optional steps open after the required ones.
1810
+ const kits = Array.isArray(rules.kits) && rules.kits.length ? rules.kits : null;
1811
+ const stateOf = new Map();
1812
+ (kits || []).forEach((k) => {
1813
+ const steps = Array.isArray(k.steps) ? k.steps : [];
1814
+ const slots = {};
1815
+ steps.forEach((s) => { slots[s.id] = Array.from({ length: Math.max(1, Number(s.pick) || 1) }, () => null); });
1816
+ stateOf.set(k.id, { kit: k, steps, fill: slots, picks: [], current: steps.length ? steps[0].id : null });
1817
+ });
1818
+ let st = kits ? stateOf.get(kits[0].id) : { kit: null, steps: [], fill: {}, picks, current: null };
1819
+ const useKit = (id) => {
1820
+ if (!kits) return;
1821
+ st = stateOf.get(id) || stateOf.get(kits[0].id);
1822
+ const k = st.kit;
1823
+ rules = { ...rules, min: k.min, max: k.max, tiers: k.tiers, repeat: k.repeat, price_mode: k.price_mode, fixed_price: k.fixed_price };
1824
+ min = Math.max(1, Number(k.min) || 1);
1825
+ cap = Number(k.max) > 0 ? Number(k.max) : Infinity;
1826
+ tiers = Array.isArray(k.tiers) ? k.tiers : [];
1827
+ goal = tiers.length ? tiers[tiers.length - 1] : null;
1828
+ picks = st.picks;
1829
+ };
1830
+ if (kits) useKit(kits[0].id);
1831
+ const stepsMode = () => st.steps.length > 0;
1832
+ const required = () => st.steps.filter((s) => !s.optional);
1833
+ const isFull = (s) => st.fill[s.id].every(Boolean);
1834
+ // picks (in step order) are rebuilt from the slots after every change: price and cart read picks.
1835
+ const syncPicks = () => { picks.length = 0; st.steps.forEach((s) => st.fill[s.id].forEach((k) => { if (k) picks.push(k); })); };
1836
+ const openRequired = () => required().find((s) => !isFull(s)) || null;
1837
+ const nextOpen = () => openRequired() || st.steps.find((s) => s.optional && !isFull(s)) || null;
1838
+ const doneCount = () => required().filter(isFull).length;
1839
+ const isComplete = () => (stepsMode() ? doneCount() === required().length : picks.length >= min);
1840
+ const reachable = (s) => { const open = openRequired(); return !open || st.steps.indexOf(s) <= st.steps.indexOf(open); };
1841
+ const slotRef = (idx) => {
1842
+ let n = 0;
1843
+ for (const s of st.steps) for (let j = 0; j < st.fill[s.id].length; j++) { if (n === idx) return [s, j]; n++; }
1844
+ return null;
1845
+ };
1846
+ // Into the card's step: its first empty slot; a full step swaps its last pick (a full optional step refuses).
1847
+ const addToStep = (key) => {
1848
+ const it = catalog.get(key);
1849
+ const step = (it && st.steps.find((s) => s.id === it.step)) || st.steps.find((s) => s.id === st.current);
1850
+ if (!step || (rules.repeat === false && picks.includes(key))) return false;
1851
+ const row = st.fill[step.id];
1852
+ let i = row.indexOf(null);
1853
+ if (i < 0) { if (step.optional) return false; i = row.length - 1; }
1854
+ row[i] = key;
1855
+ syncPicks();
1856
+ if (isFull(step)) { const n = nextOpen(); if (n) st.current = n.id; }
1857
+ return true;
1858
+ };
1859
+ // "Edit" on a required step empties the slot, closes the gap and goes back to that step; on an optional step it only removes.
1860
+ const editSlot = (idx) => {
1861
+ const ref = slotRef(idx);
1862
+ if (!ref) return;
1863
+ const [s, j] = ref;
1864
+ const row = st.fill[s.id];
1865
+ row.splice(j, 1); row.push(null);
1866
+ syncPicks();
1867
+ if (!s.optional) st.current = s.id;
1868
+ };
1869
+ const clearAll = () => {
1870
+ st.steps.forEach((s) => { st.fill[s.id] = st.fill[s.id].map(() => null); });
1871
+ picks.length = 0; waiting.length = 0;
1872
+ st.current = st.steps.length ? st.steps[0].id : null;
1873
+ };
1874
+
1875
+ // Prices. totals(): the bundle price at the tier the bundle WILL reach once it has its minimum (stores show the bundle
1876
+ // price from the first pick), plus a container's base price. totalsNow(): at the tier reached now, rounded to the cent
1877
+ // first so total + saved add up.
1878
+ let basePrice = 0;
1879
+ let cartHead = null;
1880
+ const sumOf = () => {
1881
+ let sum = 0; let compare = 0;
1882
+ picks.forEach((k) => { const it = catalog.get(k); if (!it) return; sum += it.price; compare += Math.max(it.compare, it.price); });
1883
+ return { sum, compare };
1884
+ };
1885
+ const totals = (p) => {
1886
+ const { sum, compare } = sumOf();
1887
+ if (rules.price_mode === 'fixed' && !basePrice) {
1888
+ // A box size with a fixed one-time price; or a kit with one price whatever is picked (no strike price then).
1889
+ if (size && Number(size.fixed) > 0) { if (p !== 'subscribe') return { sum, compare, total: Number(size.fixed) }; }
1890
+ else if (Number(rules.fixed_price) > 0) return { sum, compare: 0, total: Number(rules.fixed_price) };
1891
+ }
1892
+ const t = tierFor(Math.max(picks.length, min)) || goal;
1893
+ return { sum, compare, total: basePrice + sum * (1 - pctOf(t, p) / 100) };
1894
+ };
1895
+ const totalsNow = (p) => {
1896
+ const { sum, compare } = sumOf();
1897
+ return { sum, compare, total: Math.round(sum * (1 - pctOf(tierFor(picks.length), p) / 100) * 100 + 1e-6) / 100 };
1898
+ };
1899
+ // One item's price on its card at the bundle's current tier, counting at least one item.
1900
+ const unitNow = (it, p) => it.price * (1 - pctOf(tierFor(Math.max(picks.length, 1)), p) / 100);
1901
+
1902
+ const frequency = () => root.querySelector('[data-bb-frequency]');
1903
+ const planId = () => {
1904
+ if (plan !== 'subscribe') return '';
1905
+ const f = frequency();
1906
+ if (!f) return '';
1907
+ if (f.matches('select')) return f.value;
1908
+ const c = f.querySelector('input:checked');
1909
+ return c ? c.value : '';
1910
+ };
1911
+ const bundleName = () => {
1912
+ const n = (st.kit && st.kit.label) || (root.querySelector('[data-bb-name]') || {}).textContent || 'Bundle';
1913
+ return String(n).trim();
1914
+ };
1915
+ const toCart = () => {
1916
+ const counts = new Map();
1917
+ picks.concat(gifts).forEach((k) => { const it = catalog.get(k); if (it && it.variantId) counts.set(it.variantId, (counts.get(it.variantId) || 0) + 1); });
1918
+ const ruleKey = root.getAttribute('data-bb-discount-key') || '';
1919
+ const id = (root.getAttribute('data-gem-section-id') || 'bundle') + '-' + Date.now().toString(36);
1920
+ const props = [{ key: '_bundle_id', value: id }, { key: '_bundle', value: bundleName() }].concat(ruleKey ? [{ key: '_bundle_rule', value: ruleKey }] : []);
1921
+ const pid = planId();
1922
+ const lines = Array.from(counts.entries()).map(([variantId, quantity]) => ({ variantId, quantity, properties: props.slice(), ...(pid ? { sellingPlanId: pid } : {}) }));
1923
+ // A container line (a gift box) goes first and carries the section's own properties (lid, gift message).
1924
+ const head = cartHead ? cartHead() : null;
1925
+ if (head) {
1926
+ const hp = props.concat(Array.isArray(head.properties) ? head.properties : []);
1927
+ if (head.variantId) lines.unshift({ variantId: head.variantId, quantity: 1, properties: hp, ...(pid ? { sellingPlanId: pid } : {}) });
1928
+ else if (lines[0]) lines[0].properties = hp;
1929
+ }
1930
+ if (lines.length && typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:cart:add-many', { detail: { lines, after: 'drawer' } }));
1931
+ return lines.length;
1932
+ };
1933
+
1934
+ let busy = false;
1935
+ let wasComplete = false;
1936
+ const state = () => {
1937
+ const n = picks.length;
1938
+ const counts = {};
1939
+ picks.forEach((k) => { counts[k] = (counts[k] || 0) + 1; });
1940
+ return {
1941
+ picks, count: n, counts, min, cap, max: cap === Infinity ? null : cap, per, rules, text, tiers, goal, plan, size, sizes, waiting,
1942
+ catalog, cards, gifts, reached: tierFor(n), next: tiers.find((t) => n < t.min) || null, left: Math.max(0, min - n),
1943
+ kit: st.kit, kits, steps: st.steps, fill: st.fill, current: st.current, stepsMode: stepsMode(), done: doneCount(),
1944
+ required: required(), complete: isComplete(), busy, base: basePrice,
1945
+ };
1946
+ };
1947
+ const outs = () => root.querySelectorAll('[data-bb-out]');
1948
+ const emit = (action, extra) => {
1949
+ const n = picks.length;
1950
+ const complete = isComplete();
1951
+ root.setAttribute('data-bb-count', String(n));
1952
+ root.setAttribute('data-bb-state', n === 0 ? 'empty' : n >= cap ? 'full' : complete ? 'complete' : 'partial');
1953
+ if (outs().length) {
1954
+ const t = totals(plan); const reached = tierFor(n);
1955
+ 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) : '' };
1956
+ outs().forEach((el) => { const k = el.getAttribute('data-bb-out'); if (Object.hasOwn(v, k)) el.textContent = v[k]; });
1957
+ }
1958
+ 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 };
1959
+ if (typeof window.CustomEvent === 'function') {
1960
+ root.dispatchEvent(new window.CustomEvent('gem:bundle:change', { bubbles: true, detail }));
1961
+ if (complete && !wasComplete) root.dispatchEvent(new window.CustomEvent('gem:bundle:complete', { bubbles: true, detail }));
1962
+ }
1963
+ wasComplete = complete;
1964
+ };
1965
+
1966
+ // Actions: the handlers below call these, and so may a section's own controls (root.gemBundle.add(key)…).
1967
+ const add = (key) => {
1968
+ const before = st.current;
1969
+ if (root.hasAttribute('data-bb-add-toggle') && picks.includes(key)) { picks.splice(picks.indexOf(key), 1); emit('remove', { key }); return true; }
1970
+ if (stepsMode()) { if (!addToStep(key)) return false; }
1971
+ else { if (!canAdd(key)) return false; picks.push(key); }
1972
+ emit('add', { key, from: before });
1973
+ return true;
1974
+ };
1975
+ const removeAt = (idx) => { if (stepsMode()) editSlot(idx); else { picks.splice(idx, 1); refill(); } emit('remove', { index: idx }); };
1976
+ const dec = (key) => { const at = picks.lastIndexOf(key); if (at < 0) return false; picks.splice(at, 1); refill(); emit('dec', { key }); return true; };
1977
+ 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 }); };
1978
+ const clear = () => { clearAll(); emit('clear'); };
1979
+ // A typed quantity: exactly k picks of that item, within the bundle's ceiling, the item's own and no-repeat.
1980
+ const setQty = (key, want) => {
1981
+ if (!catalog.has(key)) return;
1982
+ const has = qtyOf(key);
1983
+ const room = cap === Infinity ? Infinity : cap - (picks.length - has);
1984
+ const k = Math.max(0, Math.min(Math.floor(Number(want)) || 0, room, per, rules.repeat === false ? 1 : Infinity));
1985
+ for (let i = picks.length - 1; i >= 0; i--) if (picks[i] === key) picks.splice(i, 1);
1986
+ for (let i = 0; i < k; i++) picks.push(key);
1987
+ emit('qty', { key });
1988
+ };
1989
+ const setPlan = (p) => { plan = p === 'subscribe' ? 'subscribe' : 'one_time'; emit('plan'); };
1990
+ const setSize = (n) => {
1991
+ if (!sizeMode || !applySize(Number(n))) return false;
1992
+ // A box size may name its default selling plan.
1993
+ const f = frequency();
1994
+ if (f && f.matches('select') && size && size.plan && Array.from(f.options).some((o) => o.value === size.plan)) f.value = size.plan;
1995
+ emit('size');
1996
+ return true;
1997
+ };
1998
+ // An item's variant radio (a pack size): the whole row changes variant and price, its count stays.
1999
+ const applyVariant = (radio) => {
2000
+ const it = catalog.get(radio.getAttribute('data-bb-size'));
2001
+ if (!it) return false;
2002
+ it.variantId = radio.getAttribute('data-bb-variant') || '';
2003
+ it.price = Number(radio.getAttribute('data-bb-price')) || 0;
2004
+ it.compare = Number(radio.getAttribute('data-bb-compare')) || 0;
2005
+ it.el.setAttribute('data-bb-variant', it.variantId);
2006
+ it.el.setAttribute('data-bb-price', String(it.price));
2007
+ it.el.setAttribute('data-bb-compare', it.compare ? String(it.compare) : '');
2008
+ return true;
2009
+ };
2010
+ const selectKit = (id) => { if (!kits) return; useKit(id); emit('kit'); };
2011
+ 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; };
2012
+ const finish = (ok) => {
2013
+ busy = false;
2014
+ root.querySelectorAll('[data-bb-cta]').forEach((c) => { c.removeAttribute('data-state'); c.removeAttribute('aria-busy'); });
2015
+ if (ok) clearAll();
2016
+ emit(ok ? 'added' : 'add-failed');
2017
+ };
2018
+ const checkout = () => {
2019
+ if (!isComplete() || busy || !picks.length) return false;
2020
+ if (!toCart()) return false;
2021
+ // With gemsite's cart bridge: wait for its answer before clearing (a failure keeps the bundle to try again).
2022
+ if (window.__gemCartAddBridge) {
2023
+ busy = true;
2024
+ const box = st.kit ? root.querySelector('[data-bb-kit-box="' + st.kit.id + '"]') : null;
2025
+ (box || root).querySelectorAll('[data-bb-cta]').forEach((c) => { c.setAttribute('data-state', 'loading'); c.setAttribute('aria-busy', 'true'); });
2026
+ emit('checkout');
2027
+ } else finish(true);
2028
+ return true;
2029
+ };
2030
+
2031
+ on(root, 'click', (event) => {
2032
+ const t = isEl(event.target) ? event.target : null;
2033
+ if (!t) return;
2034
+ const addEl = t.closest('[data-bb-add], [data-bb-inc]');
2035
+ if (addEl) { add(currentKey(addEl.getAttribute('data-bb-add') || addEl.getAttribute('data-bb-inc'))); return; }
2036
+ const decEl = t.closest('[data-bb-dec], [data-bb-minus]');
2037
+ if (decEl) { dec(currentKey(decEl.getAttribute('data-bb-dec') || decEl.getAttribute('data-bb-minus'))); return; }
2038
+ const un = t.closest('[data-bb-uncheck]');
2039
+ if (un) { uncheck(currentKey(un.getAttribute('data-bb-uncheck'))); return; }
2040
+ const rm = t.closest('[data-bb-remove]');
2041
+ if (rm) { removeAt(Number(rm.getAttribute('data-bb-remove')) || 0); return; }
2042
+ if (t.closest('[data-bb-clear]')) { clear(); return; }
2043
+ const nav = t.closest('[data-bb-step-nav]');
2044
+ if (nav) { if (!nav.disabled) goStep(nav.getAttribute('data-bb-step-nav')); return; }
2045
+ const tab = t.closest('[data-bb-kit-tab]');
2046
+ if (tab) { selectKit(tab.getAttribute('data-bb-kit-tab')); return; }
2047
+ if (t.closest('[data-bb-skip]')) { checkout(); return; }
2048
+ const cta = t.closest('[data-bb-cta]');
2049
+ if (cta) { event.preventDefault(); if (cta.getAttribute('aria-disabled') !== 'true') checkout(); }
2050
+ });
2051
+ on(root, 'change', (event) => {
2052
+ const t = isEl(event.target) ? event.target : null;
2053
+ if (!t) return;
2054
+ const p = t.closest('input[data-bb-plan]');
2055
+ if (p) { if (p.type === 'checkbox' || p.checked) setPlan(planOf(p)); return; }
2056
+ const s = t.closest('input[data-bb-size]');
2057
+ 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; }
2058
+ const q = t.closest('input[data-bb-count]');
2059
+ if (q) setQty(q.getAttribute('data-bb-count'), q.value);
2060
+ });
2061
+ // A typed quantity applies as soon as it is a number (an emptied box waits for "change" to go back to 0).
2062
+ on(root, 'input', (event) => {
2063
+ const q = isEl(event.target) ? event.target.closest('input[data-bb-count]') : null;
2064
+ if (q && q.value !== '' && Number.isFinite(Number(q.value))) setQty(q.getAttribute('data-bb-count'), q.value);
2065
+ });
2066
+ on(window, 'gem:cart:added', () => { if (busy) finish(true); });
2067
+ on(window, 'gem:cart:add-failed', () => { if (busy) finish(false); });
2068
+
2069
+ // A page brought back from history restores its form: read back the variant radios and typed quantities in view.
2070
+ root.querySelectorAll('input[data-bb-size][data-bb-variant]').forEach((r) => { if (r.checked) applyVariant(r); });
2071
+ root.querySelectorAll('input[data-bb-count]').forEach((inp) => {
2072
+ const v = Math.floor(Number(inp.value)) || 0;
2073
+ const key = inp.getAttribute('data-bb-count');
2074
+ 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); }
2075
+ });
2076
+
2077
+ root.gemBundle = {
2078
+ state, money, moneyShort, fill, say, words: BB_WORDS, tierFor, pctOf, rewardOf, canAdd, qtyOf, currentKey, totals, totalsNow, unitNow,
2079
+ reachable, isFull, slotRef, required, add, removeAt, dec, uncheck, clear, setQty, setPlan, setSize, selectKit, goStep, checkout,
2080
+ setBase: (n) => { basePrice = Number(n) || 0; emit('base'); },
2081
+ setCartHead: (fn) => { cartHead = typeof fn === 'function' ? fn : null; },
2082
+ refresh: () => emit('refresh'),
2083
+ };
2084
+ root.setAttribute('data-bb-ready', '');
2085
+ emit('init');
2086
+ });
2087
+ api.emit('gem:bundle:ready', { source: 'gem-runtime', count: boundBundles });
2088
+ });
1711
2089
  registerNoopPlugin("seasonal-effects", "gem:seasonal:ready");
1712
2090
  registerNoopPlugin("context-adapter", "gem:context:ready");
1713
2091
  const mark = (element, key) => {