@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 +26 -0
- package/dist/SOURCE.json +3 -3
- package/dist/gem-runtime.js +380 -2
- package/dist/index.js +397 -4
- package/dist/plugins/bundle.d.ts +9 -0
- package/dist/plugins/bundle.js +394 -0
- package/dist/plugins/reviews.js +1 -1
- package/dist/section-gate.js +386 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -435,7 +435,7 @@ function gemButtonGradientEndCss(accent = "var(--gem-color-accent)", accentText
|
|
|
435
435
|
}
|
|
436
436
|
|
|
437
437
|
// ../../packages/gem-framework/src/tokens.ts
|
|
438
|
-
var gemFrameworkVersion = "0.18.
|
|
438
|
+
var gemFrameworkVersion = "0.18.16";
|
|
439
439
|
var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
|
|
440
440
|
var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
|
|
441
441
|
var GEM_FOCUS_OFFSET_CSS_VAR = "--gem-focus-offset: 3px;";
|
|
@@ -1462,6 +1462,11 @@ var gemFrameworkRuntimePlugins = [
|
|
|
1462
1462
|
key: "reviews",
|
|
1463
1463
|
load_strategy: "idle",
|
|
1464
1464
|
events: ["gem:reviews:ready", "gem:reviews:render", "gem:review:vote"]
|
|
1465
|
+
},
|
|
1466
|
+
{
|
|
1467
|
+
key: "bundle",
|
|
1468
|
+
load_strategy: "idle",
|
|
1469
|
+
events: ["gem:bundle:ready", "gem:bundle:change", "gem:bundle:complete"]
|
|
1465
1470
|
}
|
|
1466
1471
|
];
|
|
1467
1472
|
var gemFrameworkValidationRules = [
|
|
@@ -5445,7 +5450,7 @@ function reviewsEngineRuntimeScript() {
|
|
|
5445
5450
|
const b = document.createElement('button');
|
|
5446
5451
|
b.type = 'button'; b.className = strip.getAttribute('data-rv-thumb-class') || ''; b.setAttribute('data-rv-lb-thumb', String(k));
|
|
5447
5452
|
b.setAttribute('aria-label', (strip.getAttribute('data-rv-thumb-label') || 'Media {n} of {total}').replace('{n}', String(k + 1)).replace('{total}', String(lbSet.length)));
|
|
5448
|
-
const img = document.createElement('img'); img.
|
|
5453
|
+
const img = document.createElement('img'); img.loading = 'lazy'; img.decoding = 'async'; img.alt = ''; img.src = x.thumb;
|
|
5449
5454
|
b.appendChild(img); strip.appendChild(b);
|
|
5450
5455
|
});
|
|
5451
5456
|
strip.hidden = lbSet.length <= 1;
|
|
@@ -5570,6 +5575,386 @@ function reviewsEngineRuntimeScript() {
|
|
|
5570
5575
|
api.emit('gem:reviews:ready', { source: 'gem-runtime', count: boundReviews });`;
|
|
5571
5576
|
}
|
|
5572
5577
|
|
|
5578
|
+
// ../../packages/gem-framework/src/bundle-engine.ts
|
|
5579
|
+
function bundleEngineRuntimeScript() {
|
|
5580
|
+
return `let boundBundles = 0;
|
|
5581
|
+
const BB_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
|
|
5582
|
+
document.querySelectorAll('[data-bb-root]').forEach((root) => {
|
|
5583
|
+
if (!mark(root, 'bundle')) return;
|
|
5584
|
+
boundBundles += 1;
|
|
5585
|
+
const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };
|
|
5586
|
+
const isEl = (t) => t instanceof window.Element;
|
|
5587
|
+
const parse = (attr, fallback) => { try { const v = JSON.parse(root.getAttribute(attr) || 'null'); return v == null ? fallback : v; } catch (e) { return fallback; } };
|
|
5588
|
+
let rules = parse('data-bb-rules', {});
|
|
5589
|
+
const text = root.hasAttribute('data-bb-messages') ? parse('data-bb-messages', {}) : parse('data-bb-labels', {});
|
|
5590
|
+
const viMoney = root.getAttribute('data-bb-money') === 'vi';
|
|
5591
|
+
const currency = root.getAttribute('data-bb-currency') || (viMoney ? 'VND' : 'USD');
|
|
5592
|
+
let nf = null;
|
|
5593
|
+
if (!viMoney) { try { nf = new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: currency === 'VND' ? 0 : 2 }); } catch (e) { nf = null; } }
|
|
5594
|
+
const money = (n) => (viMoney ? String(Math.round(n)).replace(/\\B(?=(\\d{3})+(?!\\d))/g, '.') + '\u0111' : nf ? nf.format(Math.round(n * 100) / 100) : (Math.round(n * 100) / 100).toFixed(2));
|
|
5595
|
+
// A strike price as stores show it: a whole number drops its cents ("$42", not "$42.00").
|
|
5596
|
+
const moneyShort = (n) => { const s = money(n); return Math.abs(n - Math.round(n)) < 0.005 ? s.replace(/[.,]00(?=\\D*$)/, '') : s; };
|
|
5597
|
+
const fill = (tpl, vars) => String(tpl || '').replace(/\\{(\\w+)\\}/g, (m, k) => (Object.hasOwn(vars, k) ? String(vars[k]) : m));
|
|
5598
|
+
// A sentence with {reward}: the reward is a <strong>, the rest plain text. Never innerHTML.
|
|
5599
|
+
const say = (el, tpl, vars) => {
|
|
5600
|
+
if (!el) return;
|
|
5601
|
+
const parts = String(tpl || '').split('{reward}');
|
|
5602
|
+
el.replaceChildren();
|
|
5603
|
+
parts.forEach((part, i) => {
|
|
5604
|
+
if (part) el.append(document.createTextNode(fill(part, vars)));
|
|
5605
|
+
if (i < parts.length - 1 && vars.reward) { const s = document.createElement('strong'); s.textContent = vars.reward; el.append(s); }
|
|
5606
|
+
});
|
|
5607
|
+
};
|
|
5608
|
+
let min = Math.max(1, Number(rules.min) || 1);
|
|
5609
|
+
let cap = Number(rules.max) > 0 ? Number(rules.max) : Infinity;
|
|
5610
|
+
const per = Number(rules.per) > 0 ? Number(rules.per) : Infinity;
|
|
5611
|
+
let tiers = Array.isArray(rules.tiers) ? rules.tiers : [];
|
|
5612
|
+
let goal = tiers.length ? tiers[tiers.length - 1] : null;
|
|
5613
|
+
// The catalog: one entry per item, plus one per extra variant of a card (<key>~<index>).
|
|
5614
|
+
const catalog = new Map();
|
|
5615
|
+
const cards = new Map();
|
|
5616
|
+
root.querySelectorAll('[data-bb-item]').forEach((el) => {
|
|
5617
|
+
const key = el.getAttribute('data-bb-item');
|
|
5618
|
+
const base = {
|
|
5619
|
+
el,
|
|
5620
|
+
variantId: el.getAttribute('data-bb-variant') || '',
|
|
5621
|
+
price: Number(el.getAttribute('data-bb-price')) || 0,
|
|
5622
|
+
compare: Number(el.getAttribute('data-bb-compare')) || 0,
|
|
5623
|
+
title: el.getAttribute('data-bb-title') || '',
|
|
5624
|
+
subtitle: el.getAttribute('data-bb-subtitle') || '',
|
|
5625
|
+
image: el.getAttribute('data-bb-image') || '',
|
|
5626
|
+
group: el.getAttribute('data-bb-group') || '',
|
|
5627
|
+
step: el.getAttribute('data-bb-step') || '',
|
|
5628
|
+
short: el.getAttribute('data-bb-short') || '',
|
|
5629
|
+
available: el.getAttribute('data-available') !== 'false',
|
|
5630
|
+
};
|
|
5631
|
+
catalog.set(key, base);
|
|
5632
|
+
let vs = [];
|
|
5633
|
+
try { vs = JSON.parse(el.getAttribute('data-bb-variants') || '[]'); } catch (e) { vs = []; }
|
|
5634
|
+
if (!Array.isArray(vs)) vs = [];
|
|
5635
|
+
vs.forEach((v, i) => {
|
|
5636
|
+
if (!i) return;
|
|
5637
|
+
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 || '' });
|
|
5638
|
+
});
|
|
5639
|
+
if (vs[0]) base.variantTitle = vs[0].title || '';
|
|
5640
|
+
cards.set(key, { el, variants: vs, gift: el.hasAttribute('data-bb-gift') });
|
|
5641
|
+
});
|
|
5642
|
+
const gifts = Array.from(cards.entries()).filter(([, c]) => c.gift).map(([k]) => k);
|
|
5643
|
+
// The variant a card shows now (data-bb-current), as the key an action picks.
|
|
5644
|
+
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; };
|
|
5645
|
+
let picks = [];
|
|
5646
|
+
let plan = 'one_time';
|
|
5647
|
+
const planOf = (el) => (el.type === 'checkbox' ? (el.checked ? 'subscribe' : 'one_time') : el.getAttribute('data-bb-plan') || 'one_time');
|
|
5648
|
+
const planInputs = Array.from(root.querySelectorAll('input[data-bb-plan]'));
|
|
5649
|
+
const checkedPlan = planInputs.find((el) => el.checked);
|
|
5650
|
+
if (checkedPlan) plan = planOf(checkedPlan);
|
|
5651
|
+
const tierFor = (n) => tiers.filter((t) => n >= t.min).pop() || null;
|
|
5652
|
+
const pctOf = (t, p) => (!t ? 0 : p === 'subscribe' ? Number(t.sub) || Number(t.pct) || 0 : Number(t.pct) || 0);
|
|
5653
|
+
// 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.
|
|
5654
|
+
const rewardOf = (t, p) => (!t ? '' : pctOf(t, p) + '% off');
|
|
5655
|
+
const qtyOf = (key) => picks.filter((p) => p === key).length;
|
|
5656
|
+
const canAdd = (key) => picks.length < cap && (rules.repeat !== false || !picks.includes(key)) && qtyOf(key) < per;
|
|
5657
|
+
|
|
5658
|
+
// Box sizes, when the root declares them (data-bb-sizes): choosing a size sets floor = ceiling = its item count. "trim":
|
|
5659
|
+
// a smaller box drops the LAST picks; "queue": they wait, in order, and come back when there is room again.
|
|
5660
|
+
const sizeMode = root.getAttribute('data-bb-sizes');
|
|
5661
|
+
const sizes = Array.isArray(rules.sizes) ? rules.sizes : [];
|
|
5662
|
+
let size = null;
|
|
5663
|
+
const waiting = [];
|
|
5664
|
+
const refill = () => { while (sizeMode === 'queue' && picks.length < cap && waiting.length) picks.push(waiting.shift()); };
|
|
5665
|
+
const applySize = (n) => {
|
|
5666
|
+
const s = sizes.find((x) => Number(x.items) === n);
|
|
5667
|
+
if (!s) return false;
|
|
5668
|
+
size = s; min = Number(s.items); cap = Number(s.items);
|
|
5669
|
+
goal = tierFor(min) || (tiers.length ? tiers[tiers.length - 1] : null);
|
|
5670
|
+
if (picks.length > cap) { const out = picks.splice(cap); if (sizeMode === 'queue') waiting.unshift(...out); }
|
|
5671
|
+
refill();
|
|
5672
|
+
return true;
|
|
5673
|
+
};
|
|
5674
|
+
if (sizeMode) { const s0 = sizes.find((s) => Number(s.items) === Number(rules.size)) || sizes[0] || null; if (s0) applySize(Number(s0.items)); }
|
|
5675
|
+
|
|
5676
|
+
// Kits (rules.kits): each keeps its own picks and steps; a kit's own rules replace the bundle's while it is chosen.
|
|
5677
|
+
// A kit with steps fills each step's slots in order and moves on by itself; optional steps open after the required ones.
|
|
5678
|
+
const kits = Array.isArray(rules.kits) && rules.kits.length ? rules.kits : null;
|
|
5679
|
+
const stateOf = new Map();
|
|
5680
|
+
(kits || []).forEach((k) => {
|
|
5681
|
+
const steps = Array.isArray(k.steps) ? k.steps : [];
|
|
5682
|
+
const slots = {};
|
|
5683
|
+
steps.forEach((s) => { slots[s.id] = Array.from({ length: Math.max(1, Number(s.pick) || 1) }, () => null); });
|
|
5684
|
+
stateOf.set(k.id, { kit: k, steps, fill: slots, picks: [], current: steps.length ? steps[0].id : null });
|
|
5685
|
+
});
|
|
5686
|
+
let st = kits ? stateOf.get(kits[0].id) : { kit: null, steps: [], fill: {}, picks, current: null };
|
|
5687
|
+
const useKit = (id) => {
|
|
5688
|
+
if (!kits) return;
|
|
5689
|
+
st = stateOf.get(id) || stateOf.get(kits[0].id);
|
|
5690
|
+
const k = st.kit;
|
|
5691
|
+
rules = { ...rules, min: k.min, max: k.max, tiers: k.tiers, repeat: k.repeat, price_mode: k.price_mode, fixed_price: k.fixed_price };
|
|
5692
|
+
min = Math.max(1, Number(k.min) || 1);
|
|
5693
|
+
cap = Number(k.max) > 0 ? Number(k.max) : Infinity;
|
|
5694
|
+
tiers = Array.isArray(k.tiers) ? k.tiers : [];
|
|
5695
|
+
goal = tiers.length ? tiers[tiers.length - 1] : null;
|
|
5696
|
+
picks = st.picks;
|
|
5697
|
+
};
|
|
5698
|
+
if (kits) useKit(kits[0].id);
|
|
5699
|
+
const stepsMode = () => st.steps.length > 0;
|
|
5700
|
+
const required = () => st.steps.filter((s) => !s.optional);
|
|
5701
|
+
const isFull = (s) => st.fill[s.id].every(Boolean);
|
|
5702
|
+
// picks (in step order) are rebuilt from the slots after every change: price and cart read picks.
|
|
5703
|
+
const syncPicks = () => { picks.length = 0; st.steps.forEach((s) => st.fill[s.id].forEach((k) => { if (k) picks.push(k); })); };
|
|
5704
|
+
const openRequired = () => required().find((s) => !isFull(s)) || null;
|
|
5705
|
+
const nextOpen = () => openRequired() || st.steps.find((s) => s.optional && !isFull(s)) || null;
|
|
5706
|
+
const doneCount = () => required().filter(isFull).length;
|
|
5707
|
+
const isComplete = () => (stepsMode() ? doneCount() === required().length : picks.length >= min);
|
|
5708
|
+
const reachable = (s) => { const open = openRequired(); return !open || st.steps.indexOf(s) <= st.steps.indexOf(open); };
|
|
5709
|
+
const slotRef = (idx) => {
|
|
5710
|
+
let n = 0;
|
|
5711
|
+
for (const s of st.steps) for (let j = 0; j < st.fill[s.id].length; j++) { if (n === idx) return [s, j]; n++; }
|
|
5712
|
+
return null;
|
|
5713
|
+
};
|
|
5714
|
+
// Into the card's step: its first empty slot; a full step swaps its last pick (a full optional step refuses).
|
|
5715
|
+
const addToStep = (key) => {
|
|
5716
|
+
const it = catalog.get(key);
|
|
5717
|
+
const step = (it && st.steps.find((s) => s.id === it.step)) || st.steps.find((s) => s.id === st.current);
|
|
5718
|
+
if (!step || (rules.repeat === false && picks.includes(key))) return false;
|
|
5719
|
+
const row = st.fill[step.id];
|
|
5720
|
+
let i = row.indexOf(null);
|
|
5721
|
+
if (i < 0) { if (step.optional) return false; i = row.length - 1; }
|
|
5722
|
+
row[i] = key;
|
|
5723
|
+
syncPicks();
|
|
5724
|
+
if (isFull(step)) { const n = nextOpen(); if (n) st.current = n.id; }
|
|
5725
|
+
return true;
|
|
5726
|
+
};
|
|
5727
|
+
// "Edit" on a required step empties the slot, closes the gap and goes back to that step; on an optional step it only removes.
|
|
5728
|
+
const editSlot = (idx) => {
|
|
5729
|
+
const ref = slotRef(idx);
|
|
5730
|
+
if (!ref) return;
|
|
5731
|
+
const [s, j] = ref;
|
|
5732
|
+
const row = st.fill[s.id];
|
|
5733
|
+
row.splice(j, 1); row.push(null);
|
|
5734
|
+
syncPicks();
|
|
5735
|
+
if (!s.optional) st.current = s.id;
|
|
5736
|
+
};
|
|
5737
|
+
const clearAll = () => {
|
|
5738
|
+
st.steps.forEach((s) => { st.fill[s.id] = st.fill[s.id].map(() => null); });
|
|
5739
|
+
picks.length = 0; waiting.length = 0;
|
|
5740
|
+
st.current = st.steps.length ? st.steps[0].id : null;
|
|
5741
|
+
};
|
|
5742
|
+
|
|
5743
|
+
// Prices. totals(): the bundle price at the tier the bundle WILL reach once it has its minimum (stores show the bundle
|
|
5744
|
+
// price from the first pick), plus a container's base price. totalsNow(): at the tier reached now, rounded to the cent
|
|
5745
|
+
// first so total + saved add up.
|
|
5746
|
+
let basePrice = 0;
|
|
5747
|
+
let cartHead = null;
|
|
5748
|
+
const sumOf = () => {
|
|
5749
|
+
let sum = 0; let compare = 0;
|
|
5750
|
+
picks.forEach((k) => { const it = catalog.get(k); if (!it) return; sum += it.price; compare += Math.max(it.compare, it.price); });
|
|
5751
|
+
return { sum, compare };
|
|
5752
|
+
};
|
|
5753
|
+
const totals = (p) => {
|
|
5754
|
+
const { sum, compare } = sumOf();
|
|
5755
|
+
if (rules.price_mode === 'fixed' && !basePrice) {
|
|
5756
|
+
// A box size with a fixed one-time price; or a kit with one price whatever is picked (no strike price then).
|
|
5757
|
+
if (size && Number(size.fixed) > 0) { if (p !== 'subscribe') return { sum, compare, total: Number(size.fixed) }; }
|
|
5758
|
+
else if (Number(rules.fixed_price) > 0) return { sum, compare: 0, total: Number(rules.fixed_price) };
|
|
5759
|
+
}
|
|
5760
|
+
const t = tierFor(Math.max(picks.length, min)) || goal;
|
|
5761
|
+
return { sum, compare, total: basePrice + sum * (1 - pctOf(t, p) / 100) };
|
|
5762
|
+
};
|
|
5763
|
+
const totalsNow = (p) => {
|
|
5764
|
+
const { sum, compare } = sumOf();
|
|
5765
|
+
return { sum, compare, total: Math.round(sum * (1 - pctOf(tierFor(picks.length), p) / 100) * 100 + 1e-6) / 100 };
|
|
5766
|
+
};
|
|
5767
|
+
// One item's price on its card at the bundle's current tier, counting at least one item.
|
|
5768
|
+
const unitNow = (it, p) => it.price * (1 - pctOf(tierFor(Math.max(picks.length, 1)), p) / 100);
|
|
5769
|
+
|
|
5770
|
+
const frequency = () => root.querySelector('[data-bb-frequency]');
|
|
5771
|
+
const planId = () => {
|
|
5772
|
+
if (plan !== 'subscribe') return '';
|
|
5773
|
+
const f = frequency();
|
|
5774
|
+
if (!f) return '';
|
|
5775
|
+
if (f.matches('select')) return f.value;
|
|
5776
|
+
const c = f.querySelector('input:checked');
|
|
5777
|
+
return c ? c.value : '';
|
|
5778
|
+
};
|
|
5779
|
+
const bundleName = () => {
|
|
5780
|
+
const n = (st.kit && st.kit.label) || (root.querySelector('[data-bb-name]') || {}).textContent || 'Bundle';
|
|
5781
|
+
return String(n).trim();
|
|
5782
|
+
};
|
|
5783
|
+
const toCart = () => {
|
|
5784
|
+
const counts = new Map();
|
|
5785
|
+
picks.concat(gifts).forEach((k) => { const it = catalog.get(k); if (it && it.variantId) counts.set(it.variantId, (counts.get(it.variantId) || 0) + 1); });
|
|
5786
|
+
const ruleKey = root.getAttribute('data-bb-discount-key') || '';
|
|
5787
|
+
const id = (root.getAttribute('data-gem-section-id') || 'bundle') + '-' + Date.now().toString(36);
|
|
5788
|
+
const props = [{ key: '_bundle_id', value: id }, { key: '_bundle', value: bundleName() }].concat(ruleKey ? [{ key: '_bundle_rule', value: ruleKey }] : []);
|
|
5789
|
+
const pid = planId();
|
|
5790
|
+
const lines = Array.from(counts.entries()).map(([variantId, quantity]) => ({ variantId, quantity, properties: props.slice(), ...(pid ? { sellingPlanId: pid } : {}) }));
|
|
5791
|
+
// A container line (a gift box) goes first and carries the section's own properties (lid, gift message).
|
|
5792
|
+
const head = cartHead ? cartHead() : null;
|
|
5793
|
+
if (head) {
|
|
5794
|
+
const hp = props.concat(Array.isArray(head.properties) ? head.properties : []);
|
|
5795
|
+
if (head.variantId) lines.unshift({ variantId: head.variantId, quantity: 1, properties: hp, ...(pid ? { sellingPlanId: pid } : {}) });
|
|
5796
|
+
else if (lines[0]) lines[0].properties = hp;
|
|
5797
|
+
}
|
|
5798
|
+
if (lines.length && typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:cart:add-many', { detail: { lines, after: 'drawer' } }));
|
|
5799
|
+
return lines.length;
|
|
5800
|
+
};
|
|
5801
|
+
|
|
5802
|
+
let busy = false;
|
|
5803
|
+
let wasComplete = false;
|
|
5804
|
+
const state = () => {
|
|
5805
|
+
const n = picks.length;
|
|
5806
|
+
const counts = {};
|
|
5807
|
+
picks.forEach((k) => { counts[k] = (counts[k] || 0) + 1; });
|
|
5808
|
+
return {
|
|
5809
|
+
picks, count: n, counts, min, cap, max: cap === Infinity ? null : cap, per, rules, text, tiers, goal, plan, size, sizes, waiting,
|
|
5810
|
+
catalog, cards, gifts, reached: tierFor(n), next: tiers.find((t) => n < t.min) || null, left: Math.max(0, min - n),
|
|
5811
|
+
kit: st.kit, kits, steps: st.steps, fill: st.fill, current: st.current, stepsMode: stepsMode(), done: doneCount(),
|
|
5812
|
+
required: required(), complete: isComplete(), busy, base: basePrice,
|
|
5813
|
+
};
|
|
5814
|
+
};
|
|
5815
|
+
const outs = () => root.querySelectorAll('[data-bb-out]');
|
|
5816
|
+
const emit = (action, extra) => {
|
|
5817
|
+
const n = picks.length;
|
|
5818
|
+
const complete = isComplete();
|
|
5819
|
+
root.setAttribute('data-bb-count', String(n));
|
|
5820
|
+
root.setAttribute('data-bb-state', n === 0 ? 'empty' : n >= cap ? 'full' : complete ? 'complete' : 'partial');
|
|
5821
|
+
if (outs().length) {
|
|
5822
|
+
const t = totals(plan); const reached = tierFor(n);
|
|
5823
|
+
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) : '' };
|
|
5824
|
+
outs().forEach((el) => { const k = el.getAttribute('data-bb-out'); if (Object.hasOwn(v, k)) el.textContent = v[k]; });
|
|
5825
|
+
}
|
|
5826
|
+
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 };
|
|
5827
|
+
if (typeof window.CustomEvent === 'function') {
|
|
5828
|
+
root.dispatchEvent(new window.CustomEvent('gem:bundle:change', { bubbles: true, detail }));
|
|
5829
|
+
if (complete && !wasComplete) root.dispatchEvent(new window.CustomEvent('gem:bundle:complete', { bubbles: true, detail }));
|
|
5830
|
+
}
|
|
5831
|
+
wasComplete = complete;
|
|
5832
|
+
};
|
|
5833
|
+
|
|
5834
|
+
// Actions: the handlers below call these, and so may a section's own controls (root.gemBundle.add(key)\u2026).
|
|
5835
|
+
const add = (key) => {
|
|
5836
|
+
const before = st.current;
|
|
5837
|
+
if (root.hasAttribute('data-bb-add-toggle') && picks.includes(key)) { picks.splice(picks.indexOf(key), 1); emit('remove', { key }); return true; }
|
|
5838
|
+
if (stepsMode()) { if (!addToStep(key)) return false; }
|
|
5839
|
+
else { if (!canAdd(key)) return false; picks.push(key); }
|
|
5840
|
+
emit('add', { key, from: before });
|
|
5841
|
+
return true;
|
|
5842
|
+
};
|
|
5843
|
+
const removeAt = (idx) => { if (stepsMode()) editSlot(idx); else { picks.splice(idx, 1); refill(); } emit('remove', { index: idx }); };
|
|
5844
|
+
const dec = (key) => { const at = picks.lastIndexOf(key); if (at < 0) return false; picks.splice(at, 1); refill(); emit('dec', { key }); return true; };
|
|
5845
|
+
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 }); };
|
|
5846
|
+
const clear = () => { clearAll(); emit('clear'); };
|
|
5847
|
+
// A typed quantity: exactly k picks of that item, within the bundle's ceiling, the item's own and no-repeat.
|
|
5848
|
+
const setQty = (key, want) => {
|
|
5849
|
+
if (!catalog.has(key)) return;
|
|
5850
|
+
const has = qtyOf(key);
|
|
5851
|
+
const room = cap === Infinity ? Infinity : cap - (picks.length - has);
|
|
5852
|
+
const k = Math.max(0, Math.min(Math.floor(Number(want)) || 0, room, per, rules.repeat === false ? 1 : Infinity));
|
|
5853
|
+
for (let i = picks.length - 1; i >= 0; i--) if (picks[i] === key) picks.splice(i, 1);
|
|
5854
|
+
for (let i = 0; i < k; i++) picks.push(key);
|
|
5855
|
+
emit('qty', { key });
|
|
5856
|
+
};
|
|
5857
|
+
const setPlan = (p) => { plan = p === 'subscribe' ? 'subscribe' : 'one_time'; emit('plan'); };
|
|
5858
|
+
const setSize = (n) => {
|
|
5859
|
+
if (!sizeMode || !applySize(Number(n))) return false;
|
|
5860
|
+
// A box size may name its default selling plan.
|
|
5861
|
+
const f = frequency();
|
|
5862
|
+
if (f && f.matches('select') && size && size.plan && Array.from(f.options).some((o) => o.value === size.plan)) f.value = size.plan;
|
|
5863
|
+
emit('size');
|
|
5864
|
+
return true;
|
|
5865
|
+
};
|
|
5866
|
+
// An item's variant radio (a pack size): the whole row changes variant and price, its count stays.
|
|
5867
|
+
const applyVariant = (radio) => {
|
|
5868
|
+
const it = catalog.get(radio.getAttribute('data-bb-size'));
|
|
5869
|
+
if (!it) return false;
|
|
5870
|
+
it.variantId = radio.getAttribute('data-bb-variant') || '';
|
|
5871
|
+
it.price = Number(radio.getAttribute('data-bb-price')) || 0;
|
|
5872
|
+
it.compare = Number(radio.getAttribute('data-bb-compare')) || 0;
|
|
5873
|
+
it.el.setAttribute('data-bb-variant', it.variantId);
|
|
5874
|
+
it.el.setAttribute('data-bb-price', String(it.price));
|
|
5875
|
+
it.el.setAttribute('data-bb-compare', it.compare ? String(it.compare) : '');
|
|
5876
|
+
return true;
|
|
5877
|
+
};
|
|
5878
|
+
const selectKit = (id) => { if (!kits) return; useKit(id); emit('kit'); };
|
|
5879
|
+
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; };
|
|
5880
|
+
const finish = (ok) => {
|
|
5881
|
+
busy = false;
|
|
5882
|
+
root.querySelectorAll('[data-bb-cta]').forEach((c) => { c.removeAttribute('data-state'); c.removeAttribute('aria-busy'); });
|
|
5883
|
+
if (ok) clearAll();
|
|
5884
|
+
emit(ok ? 'added' : 'add-failed');
|
|
5885
|
+
};
|
|
5886
|
+
const checkout = () => {
|
|
5887
|
+
if (!isComplete() || busy || !picks.length) return false;
|
|
5888
|
+
if (!toCart()) return false;
|
|
5889
|
+
// With gemsite's cart bridge: wait for its answer before clearing (a failure keeps the bundle to try again).
|
|
5890
|
+
if (window.__gemCartAddBridge) {
|
|
5891
|
+
busy = true;
|
|
5892
|
+
const box = st.kit ? root.querySelector('[data-bb-kit-box="' + st.kit.id + '"]') : null;
|
|
5893
|
+
(box || root).querySelectorAll('[data-bb-cta]').forEach((c) => { c.setAttribute('data-state', 'loading'); c.setAttribute('aria-busy', 'true'); });
|
|
5894
|
+
emit('checkout');
|
|
5895
|
+
} else finish(true);
|
|
5896
|
+
return true;
|
|
5897
|
+
};
|
|
5898
|
+
|
|
5899
|
+
on(root, 'click', (event) => {
|
|
5900
|
+
const t = isEl(event.target) ? event.target : null;
|
|
5901
|
+
if (!t) return;
|
|
5902
|
+
const addEl = t.closest('[data-bb-add], [data-bb-inc]');
|
|
5903
|
+
if (addEl) { add(currentKey(addEl.getAttribute('data-bb-add') || addEl.getAttribute('data-bb-inc'))); return; }
|
|
5904
|
+
const decEl = t.closest('[data-bb-dec], [data-bb-minus]');
|
|
5905
|
+
if (decEl) { dec(currentKey(decEl.getAttribute('data-bb-dec') || decEl.getAttribute('data-bb-minus'))); return; }
|
|
5906
|
+
const un = t.closest('[data-bb-uncheck]');
|
|
5907
|
+
if (un) { uncheck(currentKey(un.getAttribute('data-bb-uncheck'))); return; }
|
|
5908
|
+
const rm = t.closest('[data-bb-remove]');
|
|
5909
|
+
if (rm) { removeAt(Number(rm.getAttribute('data-bb-remove')) || 0); return; }
|
|
5910
|
+
if (t.closest('[data-bb-clear]')) { clear(); return; }
|
|
5911
|
+
const nav = t.closest('[data-bb-step-nav]');
|
|
5912
|
+
if (nav) { if (!nav.disabled) goStep(nav.getAttribute('data-bb-step-nav')); return; }
|
|
5913
|
+
const tab = t.closest('[data-bb-kit-tab]');
|
|
5914
|
+
if (tab) { selectKit(tab.getAttribute('data-bb-kit-tab')); return; }
|
|
5915
|
+
if (t.closest('[data-bb-skip]')) { checkout(); return; }
|
|
5916
|
+
const cta = t.closest('[data-bb-cta]');
|
|
5917
|
+
if (cta) { event.preventDefault(); if (cta.getAttribute('aria-disabled') !== 'true') checkout(); }
|
|
5918
|
+
});
|
|
5919
|
+
on(root, 'change', (event) => {
|
|
5920
|
+
const t = isEl(event.target) ? event.target : null;
|
|
5921
|
+
if (!t) return;
|
|
5922
|
+
const p = t.closest('input[data-bb-plan]');
|
|
5923
|
+
if (p) { if (p.type === 'checkbox' || p.checked) setPlan(planOf(p)); return; }
|
|
5924
|
+
const s = t.closest('input[data-bb-size]');
|
|
5925
|
+
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; }
|
|
5926
|
+
const q = t.closest('input[data-bb-count]');
|
|
5927
|
+
if (q) setQty(q.getAttribute('data-bb-count'), q.value);
|
|
5928
|
+
});
|
|
5929
|
+
// A typed quantity applies as soon as it is a number (an emptied box waits for "change" to go back to 0).
|
|
5930
|
+
on(root, 'input', (event) => {
|
|
5931
|
+
const q = isEl(event.target) ? event.target.closest('input[data-bb-count]') : null;
|
|
5932
|
+
if (q && q.value !== '' && Number.isFinite(Number(q.value))) setQty(q.getAttribute('data-bb-count'), q.value);
|
|
5933
|
+
});
|
|
5934
|
+
on(window, 'gem:cart:added', () => { if (busy) finish(true); });
|
|
5935
|
+
on(window, 'gem:cart:add-failed', () => { if (busy) finish(false); });
|
|
5936
|
+
|
|
5937
|
+
// A page brought back from history restores its form: read back the variant radios and typed quantities in view.
|
|
5938
|
+
root.querySelectorAll('input[data-bb-size][data-bb-variant]').forEach((r) => { if (r.checked) applyVariant(r); });
|
|
5939
|
+
root.querySelectorAll('input[data-bb-count]').forEach((inp) => {
|
|
5940
|
+
const v = Math.floor(Number(inp.value)) || 0;
|
|
5941
|
+
const key = inp.getAttribute('data-bb-count');
|
|
5942
|
+
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); }
|
|
5943
|
+
});
|
|
5944
|
+
|
|
5945
|
+
root.gemBundle = {
|
|
5946
|
+
state, money, moneyShort, fill, say, words: BB_WORDS, tierFor, pctOf, rewardOf, canAdd, qtyOf, currentKey, totals, totalsNow, unitNow,
|
|
5947
|
+
reachable, isFull, slotRef, required, add, removeAt, dec, uncheck, clear, setQty, setPlan, setSize, selectKit, goStep, checkout,
|
|
5948
|
+
setBase: (n) => { basePrice = Number(n) || 0; emit('base'); },
|
|
5949
|
+
setCartHead: (fn) => { cartHead = typeof fn === 'function' ? fn : null; },
|
|
5950
|
+
refresh: () => emit('refresh'),
|
|
5951
|
+
};
|
|
5952
|
+
root.setAttribute('data-bb-ready', '');
|
|
5953
|
+
emit('init');
|
|
5954
|
+
});
|
|
5955
|
+
api.emit('gem:bundle:ready', { source: 'gem-runtime', count: boundBundles });`;
|
|
5956
|
+
}
|
|
5957
|
+
|
|
5573
5958
|
// ../../packages/gem-framework/src/runtime.ts
|
|
5574
5959
|
var runtimePluginReadyEvents = {
|
|
5575
5960
|
"cart-drawer": "gem:cart:ready",
|
|
@@ -5594,7 +5979,8 @@ var runtimePluginReadyEvents = {
|
|
|
5594
5979
|
"seasonal-effects": "gem:seasonal:ready",
|
|
5595
5980
|
"context-adapter": "gem:context:ready",
|
|
5596
5981
|
"offer-engine": "gem:offer:ready",
|
|
5597
|
-
reviews: "gem:reviews:ready"
|
|
5982
|
+
reviews: "gem:reviews:ready",
|
|
5983
|
+
bundle: "gem:bundle:ready"
|
|
5598
5984
|
};
|
|
5599
5985
|
var cartDrawerPluginRegistrationLines = [
|
|
5600
5986
|
" registerPlugin('cart-drawer', ({ api }) => {",
|
|
@@ -6677,6 +7063,11 @@ var reviewsPluginRegistrationLines = [
|
|
|
6677
7063
|
" " + reviewsEngineRuntimeScript(),
|
|
6678
7064
|
" });"
|
|
6679
7065
|
];
|
|
7066
|
+
var bundlePluginRegistrationLines = [
|
|
7067
|
+
" registerPlugin('bundle', ({ api }) => {",
|
|
7068
|
+
" " + bundleEngineRuntimeScript(),
|
|
7069
|
+
" });"
|
|
7070
|
+
];
|
|
6680
7071
|
var runtimePluginSourceLines = [
|
|
6681
7072
|
["cart-drawer", cartDrawerPluginRegistrationLines],
|
|
6682
7073
|
["mobile-menu", mobileMenuPluginRegistrationLines],
|
|
@@ -6699,7 +7090,8 @@ var runtimePluginSourceLines = [
|
|
|
6699
7090
|
["recently-viewed", recentlyViewedPluginRegistrationLines],
|
|
6700
7091
|
["runtime-slot-loader", runtimeSlotLoaderPluginRegistrationLines],
|
|
6701
7092
|
["offer-engine", offerEnginePluginRegistrationLines],
|
|
6702
|
-
["reviews", reviewsPluginRegistrationLines]
|
|
7093
|
+
["reviews", reviewsPluginRegistrationLines],
|
|
7094
|
+
["bundle", bundlePluginRegistrationLines]
|
|
6703
7095
|
];
|
|
6704
7096
|
var noopPluginRegistrationLine = (key, readyEvent) => ` registerNoopPlugin(${JSON.stringify(key)}, ${JSON.stringify(readyEvent)});`;
|
|
6705
7097
|
function gemRuntimePluginSources() {
|
|
@@ -8376,6 +8768,7 @@ export {
|
|
|
8376
8768
|
aaLabelColor,
|
|
8377
8769
|
aaTextColor,
|
|
8378
8770
|
auditGemFrameworkClassName,
|
|
8771
|
+
bundleEngineRuntimeScript,
|
|
8379
8772
|
createGemBaseCss,
|
|
8380
8773
|
createGemFrameworkManifest,
|
|
8381
8774
|
createGemRuntimeBootstrap,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @gemboss/gem-framework/plugins/bundle: 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: "bundle";
|
|
6
|
+
export declare const readyEvent: "gem:bundle:ready";
|
|
7
|
+
/** Bind every "bundle" 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;
|