@gemboss/gem-framework 0.18.13 → 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 +54 -0
- package/dist/SOURCE.json +3 -3
- package/dist/editions.js +2 -0
- package/dist/gem-framework.css +7 -6
- package/dist/gem-runtime.js +1033 -1
- package/dist/index.js +1085 -11
- package/dist/plugins/bundle.d.ts +9 -0
- package/dist/plugins/bundle.js +394 -0
- package/dist/plugins/reviews.d.ts +9 -0
- package/dist/plugins/reviews.js +670 -0
- package/dist/popup-shell.css +4 -4
- package/dist/primitives.css +1 -1
- package/dist/section-gate.js +1066 -8
- package/dist/sections.css +1 -1
- package/dist/tokens.css +1 -0
- package/package.json +1 -1
package/dist/section-gate.js
CHANGED
|
@@ -432,6 +432,8 @@ function gemMotionScaleCssVars(motion) {
|
|
|
432
432
|
return toMotionCssVars(scale).filter((line) => !declared.has(cssVarName(line)));
|
|
433
433
|
}
|
|
434
434
|
var optionalGemTokenKeys = {
|
|
435
|
+
// Lớp phủ tối (GemFactory gap #17): xem GEM_SCRIM_FALLBACK / gemScrim bên dưới.
|
|
436
|
+
color: ["scrim"],
|
|
435
437
|
typography: [...GEM_TYPE_RAMP_KEYS, ...GEM_LINE_HEIGHT_TOKEN_KEYS, ...GEM_BUTTON_TOKEN_KEYS.typography],
|
|
436
438
|
spacing: [...Object.keys(GEM_DERIVED_SPACE_STEPS), ...GEM_PHONE_TOKEN_TARGETS.spacing.map((k) => `${k}Mobile`)],
|
|
437
439
|
layout: GEM_PHONE_TOKEN_TARGETS.layout.map((k) => `${k}Mobile`),
|
|
@@ -439,6 +441,8 @@ var optionalGemTokenKeys = {
|
|
|
439
441
|
// Khoá motion ngoài duration / easing: nền ambient (đã phát từ trước nhưng chưa khai ở đây) và thang "Gọn" (#32).
|
|
440
442
|
motion: ["easingSoft", "ambientSlow", "ambientMid", "ambientFast", "ambientAmp", ...Object.keys(GEM_MOTION_SCALE_DEFAULTS)]
|
|
441
443
|
};
|
|
444
|
+
var GEM_SCRIM_FALLBACK = "#000";
|
|
445
|
+
var gemScrim = (percent) => `color-mix(in srgb, var(--gem-color-scrim, ${GEM_SCRIM_FALLBACK}) ${percent}%, transparent)`;
|
|
442
446
|
var GEM_HEADING_SIZE_CSS_VARS = [
|
|
443
447
|
"--gem-section-heading-size: calc(var(--gem-h2) * var(--gem-typography-heading-scale, 1));",
|
|
444
448
|
"--gem-hero-heading-size: calc(var(--gem-h1) * var(--gem-typography-heading-scale, 1));",
|
|
@@ -462,7 +466,9 @@ var defaultGemTokens = {
|
|
|
462
466
|
// GemFactory sections set `color: var(--gem-color-warning)` on words; white text on a warning badge was 3.64:1 too.
|
|
463
467
|
// #9a661a keeps the hue at 4.52:1 / 4.90:1. default-colours-meet-aa.test.ts holds every foreground default to 4.5:1.
|
|
464
468
|
warning: "#9a661a",
|
|
465
|
-
danger: "#d72c0d"
|
|
469
|
+
danger: "#d72c0d",
|
|
470
|
+
// Lớp phủ tối ở độ đậm tối đa (gap #17, khoá tuỳ chọn): mỗi lớp phủ lấy N% của nó qua gemScrim.
|
|
471
|
+
scrim: "#000"
|
|
466
472
|
},
|
|
467
473
|
typography: {
|
|
468
474
|
body: "Inter, system-ui, sans-serif",
|
|
@@ -1034,7 +1040,7 @@ var GEM_POPUP_SHELL_CSS_LINES = [
|
|
|
1034
1040
|
" width: min(880px, 92vw); max-width: none; max-height: min(640px, 92vh); overflow: hidden;",
|
|
1035
1041
|
" border-radius: var(--gem-radius-lg); box-shadow: var(--gem-shadow-md);",
|
|
1036
1042
|
"}",
|
|
1037
|
-
|
|
1043
|
+
`.gem-popup-shell__dialog::backdrop { background: ${gemScrim(55)}; }`,
|
|
1038
1044
|
".gem-popup-shell__frame { position: relative; display: grid; grid-template-columns: 1fr 1fr; min-height: 520px; height: 100%; }",
|
|
1039
1045
|
".gem-popup-shell__media { position: relative; min-width: 0; overflow: hidden; background: var(--gem-color-surface); }",
|
|
1040
1046
|
".gem-popup-shell__image { display: block; width: 100%; height: 100%; object-fit: cover; }",
|
|
@@ -1061,7 +1067,7 @@ var GEM_POPUP_SHELL_CSS_LINES = [
|
|
|
1061
1067
|
"}",
|
|
1062
1068
|
// Image behind the text: light text over a dark wash, every size. A card over the image has its own
|
|
1063
1069
|
// background, so on desktop it keeps the preset's text colour.
|
|
1064
|
-
|
|
1070
|
+
`.gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__media::after { content: ''; position: absolute; inset: 0; background: linear-gradient(${gemScrim(35)}, ${gemScrim(60)}); }`,
|
|
1065
1071
|
".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__panel,",
|
|
1066
1072
|
".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__close { color: #fff; }",
|
|
1067
1073
|
".gem-popup-shell__close {",
|
|
@@ -1125,10 +1131,10 @@ var GEM_POPUP_SHELL_CSS_LINES = [
|
|
|
1125
1131
|
// Dialog định vị ĐÚNG như modal của trình duyệt (position: fixed; inset: 0; margin: auto), nên mọi bố cục (fullscreen,
|
|
1126
1132
|
// thẻ, takeover, sheet kéo tới 88dvh) ra cùng khung chữ nhật với popup mở thật; nền chỉ thay ::backdrop (không có ở
|
|
1127
1133
|
// dialog không-modal).
|
|
1128
|
-
|
|
1134
|
+
`:root:not(:has([data-gem-canvas])) .gem-popup-shell[data-gem-popup-preview='true'] { position: fixed; inset: 0; z-index: 1000; padding: 0; background: ${gemScrim(55)}; }`,
|
|
1129
1135
|
":root:not(:has([data-gem-canvas])) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: fixed; inset: 0; display: block; }",
|
|
1130
1136
|
":root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: relative; display: block; inset: auto; }",
|
|
1131
|
-
|
|
1137
|
+
`:root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] { padding: var(--gem-space-xl) var(--gem-layout-gutter); background: ${gemScrim(55)}; }`,
|
|
1132
1138
|
"@media (min-width: 750px) {",
|
|
1133
1139
|
" :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] { padding: 0; }",
|
|
1134
1140
|
" :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] .gem-popup-shell__dialog { height: 720px; max-height: none; }",
|
|
@@ -2649,7 +2655,7 @@ function createPrimitiveCss() {
|
|
|
2649
2655
|
".gem-cart-drawer__overlay {",
|
|
2650
2656
|
" position: fixed;",
|
|
2651
2657
|
" inset: 0;",
|
|
2652
|
-
|
|
2658
|
+
` background: ${gemScrim(36)};`,
|
|
2653
2659
|
"}",
|
|
2654
2660
|
// Opening motion (gemfactory request #13, 01/10/2026): 28 stored drawers each declared their own
|
|
2655
2661
|
// `@keyframes …-in { from { transform: translateX(24px); opacity: 0 } }`. OPT-IN on the drawer root,
|
|
@@ -3441,7 +3447,7 @@ function createDawnParityCss() {
|
|
|
3441
3447
|
".gem-hero-banner--default { position: relative; overflow: hidden; padding: 0; color: white; place-items: stretch; }",
|
|
3442
3448
|
".gem-hero-banner--default .gem-hero-banner__inner { position: static; width: 100%; min-height: 720px; height: 100%; display: grid; align-items: end; justify-items: center; padding-bottom: 0; }",
|
|
3443
3449
|
".gem-hero-banner--default .gem-hero-banner__media { position: absolute; inset: 0; z-index: 0; }",
|
|
3444
|
-
|
|
3450
|
+
`.gem-hero-banner--default .gem-hero-banner__media::after { content: ''; position: absolute; inset: 0; background: ${gemScrim(32)}; }`,
|
|
3445
3451
|
".gem-hero-banner--default .gem-hero-banner__image, .gem-hero-banner--default .gem-hero-banner__placeholder { width: 100%; height: 100%; object-fit: cover; display: block; }",
|
|
3446
3452
|
".gem-hero-banner--default .gem-hero-banner__copy { position: absolute; z-index: 1; left: 50%; bottom: 50px; transform: translateX(-50%); width: min(702px, 100%); margin: 0; padding: 40px 0; text-align: center; display: block; }",
|
|
3447
3453
|
".gem-hero-banner--default .gem-section__heading { margin: 0; font-size: 52px; line-height: 67.6px; color: currentColor; }",
|
|
@@ -4384,6 +4390,1042 @@ function offerEngineRuntimeScript() {
|
|
|
4384
4390
|
})();`;
|
|
4385
4391
|
}
|
|
4386
4392
|
|
|
4393
|
+
// ../../packages/gem-framework/src/reviews-engine.ts
|
|
4394
|
+
function reviewsEngineRuntimeScript() {
|
|
4395
|
+
return `let boundReviews = 0;
|
|
4396
|
+
document.querySelectorAll('[data-rv-root]').forEach((root) => {
|
|
4397
|
+
if (!mark(root, 'reviews')) return;
|
|
4398
|
+
boundReviews += 1;
|
|
4399
|
+
const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };
|
|
4400
|
+
const all = (sel) => Array.from(root.querySelectorAll(sel));
|
|
4401
|
+
const isEl = (t) => t instanceof window.Element;
|
|
4402
|
+
const list = root.querySelector('[data-rv-list]');
|
|
4403
|
+
const items = list ? Array.from(list.querySelectorAll('[data-rv-item]')) : [];
|
|
4404
|
+
let pageSize = Math.max(1, parseInt((list && list.getAttribute('data-rv-page-size')) || '5', 10) || 5);
|
|
4405
|
+
const moreSize = () => Math.max(1, parseInt((list && list.getAttribute('data-rv-more-size')) || '', 10) || pageSize);
|
|
4406
|
+
const collapsedN = Math.max(0, parseInt((list && list.getAttribute('data-rv-collapsed')) || '0', 10) || 0);
|
|
4407
|
+
const st = { sort: '', star: 0, media: false, topic: '', q: '', country: '', page: 1, shown: pageSize, collapsed: collapsedN > 0, staged: 0 };
|
|
4408
|
+
const section = root.getAttribute('data-gem-section-library-id') || undefined;
|
|
4409
|
+
const sectionId = root.getAttribute('data-gem-section-id') || root.id || undefined;
|
|
4410
|
+
const pagesNavs = all('[data-rv-pages]');
|
|
4411
|
+
const pagesMq = pagesNavs.length && window.matchMedia ? window.matchMedia(pagesNavs[0].getAttribute('data-rv-pages-media') || 'all') : null;
|
|
4412
|
+
const usesPagesNow = () => pagesNavs.length > 0 && (!pagesMq || pagesMq.matches);
|
|
4413
|
+
const prevPages = all('[data-rv-prev]');
|
|
4414
|
+
const nextPages = all('[data-rv-next]');
|
|
4415
|
+
const none = root.querySelector('[data-rv-none]');
|
|
4416
|
+
const more = root.querySelector('[data-rv-more]');
|
|
4417
|
+
const expand = root.querySelector('[data-rv-expand]');
|
|
4418
|
+
const num = (el, a) => Number(el.getAttribute(a) || 0);
|
|
4419
|
+
const isOn = (el, a) => el.getAttribute(a) === '1';
|
|
4420
|
+
const byOrder = (a, b) => num(a, 'data-rv-order') - num(b, 'data-rv-order');
|
|
4421
|
+
const newest = (a, b) => (a.hasAttribute('data-rv-recent') ? num(a, 'data-rv-recent') - num(b, 'data-rv-recent') : byOrder(a, b));
|
|
4422
|
+
const leastHelpful = (a, b) => num(a, 'data-rv-helpful') - num(b, 'data-rv-helpful') || byOrder(a, b);
|
|
4423
|
+
const SORTS = {
|
|
4424
|
+
relevant: byOrder,
|
|
4425
|
+
featured: byOrder,
|
|
4426
|
+
recent: newest,
|
|
4427
|
+
oldest: (a, b) => (a.hasAttribute('data-rv-recent') ? num(b, 'data-rv-recent') - num(a, 'data-rv-recent') : byOrder(b, a)),
|
|
4428
|
+
verified: (a, b) => isOn(b, 'data-rv-verified') - isOn(a, 'data-rv-verified') || byOrder(a, b),
|
|
4429
|
+
highest: (a, b) => num(b, 'data-rv-rating') - num(a, 'data-rv-rating') || byOrder(a, b),
|
|
4430
|
+
lowest: (a, b) => num(a, 'data-rv-rating') - num(b, 'data-rv-rating') || byOrder(a, b),
|
|
4431
|
+
helpful: (a, b) => num(b, 'data-rv-helpful') - num(a, 'data-rv-helpful') || byOrder(a, b),
|
|
4432
|
+
leasthelpful: leastHelpful,
|
|
4433
|
+
unhelpful: leastHelpful,
|
|
4434
|
+
media: (a, b) => isOn(b, 'data-rv-media') - isOn(a, 'data-rv-media') || byOrder(a, b),
|
|
4435
|
+
'media-only': newest,
|
|
4436
|
+
videos: (a, b) => isOn(b, 'data-rv-video') - isOn(a, 'data-rv-video') || newest(a, b),
|
|
4437
|
+
};
|
|
4438
|
+
const matches = (it) => {
|
|
4439
|
+
if (st.star && num(it, 'data-rv-rating') !== st.star) return false;
|
|
4440
|
+
if ((st.media || st.sort === 'media-only') && !isOn(it, 'data-rv-media')) return false;
|
|
4441
|
+
if (st.country && it.getAttribute('data-rv-country-code') !== st.country) return false;
|
|
4442
|
+
if (st.topic) {
|
|
4443
|
+
const tags = (it.getAttribute('data-rv-topics') || '').toLowerCase().split('|').filter(Boolean);
|
|
4444
|
+
if (tags.length ? !tags.includes(st.topic) : !(it.textContent || '').toLowerCase().includes(st.topic)) return false;
|
|
4445
|
+
}
|
|
4446
|
+
if (st.q && !(it.textContent || '').toLowerCase().includes(st.q)) return false;
|
|
4447
|
+
return true;
|
|
4448
|
+
};
|
|
4449
|
+
const filterKeys = ['star', 'media', 'topic', 'q', 'country'];
|
|
4450
|
+
const only = (k) => filterKeys.every((x) => x === k || !st[x]);
|
|
4451
|
+
// Counts as written ("5284") unless the list names a locale: data-rv-locale="en-US" \u2192 5,284; "auto" \u2192 the page's lang.
|
|
4452
|
+
const locale = list ? list.getAttribute('data-rv-locale') : null;
|
|
4453
|
+
const fmtCount = (n) => {
|
|
4454
|
+
const v = Number(n) || 0;
|
|
4455
|
+
if (!locale) return String(v);
|
|
4456
|
+
try { return v.toLocaleString(locale === 'auto' ? (document.documentElement && document.documentElement.lang) || undefined : locale); } catch (err) { return String(v); }
|
|
4457
|
+
};
|
|
4458
|
+
|
|
4459
|
+
// Read more: the button opens the body of its scope; short text hides the button. Measured after every draw, on
|
|
4460
|
+
// resize and once the theme's fonts are in, because a review on a later page (or under a filter) measures 0.
|
|
4461
|
+
const moreScope = (b) => b.closest('[data-rv-more-scope]') || b.closest('[data-rv-item], [data-rv-scope]') || root;
|
|
4462
|
+
const syncReadMore = () => all('[data-rv-readmore]').forEach((b) => {
|
|
4463
|
+
const it = b.closest('[data-rv-item]');
|
|
4464
|
+
if ((it && it.hidden) || b.hasAttribute('data-rv-keep')) return;
|
|
4465
|
+
const body = moreScope(b).querySelector('[data-rv-body]');
|
|
4466
|
+
if (!body || !body.getClientRects().length) return;
|
|
4467
|
+
b.hidden = !body.hasAttribute('data-rv-open') && body.scrollHeight <= body.clientHeight + 2;
|
|
4468
|
+
});
|
|
4469
|
+
|
|
4470
|
+
// Search (and, with data-rv-mark-topic, topic) words marked in [data-rv-hl]; the original nodes come back on every draw.
|
|
4471
|
+
const hlOrig = new Map();
|
|
4472
|
+
const markClass = (list && list.getAttribute('data-rv-mark-class')) || '';
|
|
4473
|
+
const highlight = () => {
|
|
4474
|
+
const terms = [st.q, list && list.hasAttribute('data-rv-mark-topic') ? st.topic : ''].filter((x) => x && x.length > 1);
|
|
4475
|
+
const re = terms.length ? new RegExp('(' + terms.map((x) => x.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&')).join('|') + ')', 'gi') : null;
|
|
4476
|
+
all('[data-rv-hl]').forEach((el) => {
|
|
4477
|
+
if (!hlOrig.has(el)) { if (!re) return; hlOrig.set(el, Array.from(el.childNodes).map((n) => n.cloneNode(true))); }
|
|
4478
|
+
else el.replaceChildren(...hlOrig.get(el).map((n) => n.cloneNode(true)));
|
|
4479
|
+
if (!re) return;
|
|
4480
|
+
const walker = document.createTreeWalker(el, 4);
|
|
4481
|
+
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
4482
|
+
nodes.forEach((n) => {
|
|
4483
|
+
const parts = (n.nodeValue || '').split(re);
|
|
4484
|
+
if (parts.length < 2) return;
|
|
4485
|
+
const frag = document.createDocumentFragment();
|
|
4486
|
+
parts.forEach((p, i) => { if (i % 2 === 0) { if (p) frag.append(p); return; } const m = document.createElement('mark'); m.className = markClass; m.textContent = p; frag.append(m); });
|
|
4487
|
+
n.replaceWith(frag);
|
|
4488
|
+
});
|
|
4489
|
+
});
|
|
4490
|
+
};
|
|
4491
|
+
|
|
4492
|
+
const drawPages = (nav, pages) => {
|
|
4493
|
+
const cur = st.page;
|
|
4494
|
+
const win = Number(nav.getAttribute('data-rv-page-window') || 0);
|
|
4495
|
+
let seq;
|
|
4496
|
+
if (win > 0) {
|
|
4497
|
+
// A window of N numbers around the current page, no \u2026 (Yotpo: 1 2 3 4 5).
|
|
4498
|
+
const start = Math.max(1, Math.min(cur - Math.floor(win / 2), pages - win + 1));
|
|
4499
|
+
seq = Array.from({ length: Math.min(win, pages) }, (_, k) => start + k);
|
|
4500
|
+
} else {
|
|
4501
|
+
// 1 \u2026 n around the current page (Judge.me: 1 2 3 4 \u2026 18).
|
|
4502
|
+
const want = new Set([1, pages, cur - 1, cur, cur + 1].filter((p) => p >= 1 && p <= pages));
|
|
4503
|
+
if (cur <= 3) [2, 3, 4].forEach((p) => { if (p <= pages) want.add(p); });
|
|
4504
|
+
seq = Array.from(want).sort((a, b) => a - b);
|
|
4505
|
+
}
|
|
4506
|
+
const tpl = nav.querySelector('[data-rv-page-tpl]');
|
|
4507
|
+
const anchor = nextPages.find((b) => nav.contains(b)) || null;
|
|
4508
|
+
nav.querySelectorAll('[data-rv-page]:not([data-rv-page-tpl]), [data-rv-gap]').forEach((e) => e.remove());
|
|
4509
|
+
let last = 0;
|
|
4510
|
+
seq.forEach((p) => {
|
|
4511
|
+
if (!win && p - last > 1) {
|
|
4512
|
+
const g = document.createElement('span');
|
|
4513
|
+
g.setAttribute('data-rv-gap', ''); g.setAttribute('aria-hidden', 'true');
|
|
4514
|
+
g.className = (tpl && tpl.getAttribute('data-rv-gap-class')) || ''; g.textContent = '\u2026';
|
|
4515
|
+
nav.insertBefore(g, anchor);
|
|
4516
|
+
}
|
|
4517
|
+
const b = document.createElement('button');
|
|
4518
|
+
b.type = 'button'; b.className = tpl ? tpl.className : ''; b.textContent = String(p);
|
|
4519
|
+
b.setAttribute('data-rv-page', String(p)); b.setAttribute('aria-label', 'Page ' + p);
|
|
4520
|
+
if (p === cur) b.setAttribute('aria-current', 'page');
|
|
4521
|
+
nav.insertBefore(b, anchor);
|
|
4522
|
+
last = p;
|
|
4523
|
+
});
|
|
4524
|
+
};
|
|
4525
|
+
|
|
4526
|
+
// Active-filter chips: one per filter in force, a click removes that filter.
|
|
4527
|
+
const chipsBox = root.querySelector('[data-rv-chips]');
|
|
4528
|
+
const chipTpl = root.querySelector('[data-rv-chip-tpl]');
|
|
4529
|
+
const starText = (v) => { const e = root.querySelector('[data-rv-star="' + v + '"][data-rv-star-text]'); return e ? e.getAttribute('data-rv-star-text') || '' : ''; };
|
|
4530
|
+
const countryText = (c) => { const e = root.querySelector('[data-rv-country="' + c + '"][data-rv-country-text]'); return e ? e.getAttribute('data-rv-country-text') || c : c; };
|
|
4531
|
+
const drawChips = () => {
|
|
4532
|
+
if (!chipsBox || !chipTpl) return;
|
|
4533
|
+
chipsBox.replaceChildren();
|
|
4534
|
+
const add = (kind, label, star) => {
|
|
4535
|
+
const b = chipTpl.cloneNode(true);
|
|
4536
|
+
b.removeAttribute('data-rv-chip-tpl'); b.hidden = false;
|
|
4537
|
+
const t = b.querySelector('[data-rv-chip-text]'); if (t) t.textContent = label;
|
|
4538
|
+
const s = b.querySelector('[data-rv-chip-star]'); if (s) s.hidden = !star;
|
|
4539
|
+
const v = b.querySelector('[data-rv-chip-value]'); if (v) v.textContent = star || '';
|
|
4540
|
+
b.setAttribute('data-rv-chip', kind);
|
|
4541
|
+
b.setAttribute('aria-label', ((b.getAttribute('data-rv-remove') || 'Remove') + ' ' + label + ' ' + (star || '')).trim());
|
|
4542
|
+
chipsBox.appendChild(b);
|
|
4543
|
+
};
|
|
4544
|
+
if (st.star) add('star', chipsBox.getAttribute('data-rv-chip-rating') || '', starText(st.star) || String(st.star));
|
|
4545
|
+
if (st.media) add('media', chipsBox.getAttribute('data-rv-chip-media') || '', '');
|
|
4546
|
+
if (st.country) add('country', countryText(st.country), '');
|
|
4547
|
+
chipsBox.hidden = !chipsBox.childElementCount;
|
|
4548
|
+
};
|
|
4549
|
+
|
|
4550
|
+
const render = (focusFirstNew, scroll) => {
|
|
4551
|
+
if (!list) return;
|
|
4552
|
+
const sorted = st.sort && SORTS[st.sort] ? items.slice().sort(SORTS[st.sort]) : items;
|
|
4553
|
+
if (st.sort) sorted.forEach((it) => list.appendChild(it));
|
|
4554
|
+
const hit = sorted.filter(matches);
|
|
4555
|
+
const hitSet = new Set(hit);
|
|
4556
|
+
const total = hit.length;
|
|
4557
|
+
const usesPages = !st.collapsed && usesPagesNow();
|
|
4558
|
+
const pages = Math.max(1, Math.ceil(total / pageSize));
|
|
4559
|
+
if (st.page > pages) st.page = pages;
|
|
4560
|
+
const from = usesPages ? (st.page - 1) * pageSize : 0;
|
|
4561
|
+
const to = st.collapsed ? collapsedN : usesPages ? from + pageSize : st.shown;
|
|
4562
|
+
let firstNew = null;
|
|
4563
|
+
items.forEach((it) => { if (!hitSet.has(it)) it.hidden = true; });
|
|
4564
|
+
hit.forEach((it, i) => {
|
|
4565
|
+
const show = i >= from && i < to;
|
|
4566
|
+
if (show && it.hidden && !firstNew && focusFirstNew) firstNew = it;
|
|
4567
|
+
it.hidden = !show;
|
|
4568
|
+
});
|
|
4569
|
+
if (none) none.hidden = total > 0;
|
|
4570
|
+
if (more) more.hidden = st.collapsed || usesPages || to >= total;
|
|
4571
|
+
if (expand) expand.hidden = !st.collapsed || total <= collapsedN;
|
|
4572
|
+
if (list.hasAttribute('data-rv-collapsed')) root.toggleAttribute('data-rv-expanded', !st.collapsed);
|
|
4573
|
+
const filtered = filterKeys.some((k) => !!st[k]);
|
|
4574
|
+
// Found: the store's own count when nothing filters; the star row's (or topic chip's) count when only that filters
|
|
4575
|
+
// (the breakdown of the WHOLE store, as review widgets show it); otherwise what matched on the page.
|
|
4576
|
+
const starEl = st.star ? root.querySelector('[data-rv-star="' + st.star + '"][data-rv-star-count]') : null;
|
|
4577
|
+
const topicEl = st.topic ? all('[data-rv-topic][data-rv-topic-count]').find((x) => (x.getAttribute('data-rv-topic') || '').toLowerCase() === st.topic) : null;
|
|
4578
|
+
const found = !filtered ? Number(list.getAttribute('data-rv-total') || total)
|
|
4579
|
+
: starEl && only('star') ? Number(starEl.getAttribute('data-rv-star-count'))
|
|
4580
|
+
: topicEl && only('topic') ? Number(topicEl.getAttribute('data-rv-topic-count'))
|
|
4581
|
+
: total;
|
|
4582
|
+
const shown = Math.max(0, Math.min(to, total) - from);
|
|
4583
|
+
root.toggleAttribute('data-rv-filtered', filtered);
|
|
4584
|
+
root.setAttribute('data-rv-f-star', String(st.star || ''));
|
|
4585
|
+
root.setAttribute('data-rv-f-topic', st.topic);
|
|
4586
|
+
root.setAttribute('data-rv-f-country', st.country);
|
|
4587
|
+
root.toggleAttribute('data-rv-f-media', st.media);
|
|
4588
|
+
all('[data-rv-star-value]').forEach((e) => (e.querySelector('.gem-stars') || e).style.setProperty('--gem-stars-value', String(st.star || 0)));
|
|
4589
|
+
all('[data-rv-star-text]:not([data-rv-star])').forEach((e) => { e.textContent = String(st.star || ''); });
|
|
4590
|
+
all('[data-rv-star-label]').forEach((e) => { e.textContent = st.star ? starText(st.star) : ''; });
|
|
4591
|
+
all('[data-rv-country-label]').forEach((e) => { e.textContent = st.country ? countryText(st.country) : ''; });
|
|
4592
|
+
all('[data-rv-filter-count]').forEach((e) => { const n = (st.star ? 1 : 0) + (st.media ? 1 : 0) + (st.topic ? 1 : 0) + (st.country ? 1 : 0); e.textContent = String(n); e.hidden = n === 0; });
|
|
4593
|
+
all('[data-rv-count-label]').forEach((e) => { e.textContent = ((found === 1 && e.getAttribute('data-rv-count-one')) || e.getAttribute('data-rv-count-label') || '').replace('{count}', fmtCount(found)); });
|
|
4594
|
+
all('[data-rv-status]').forEach((s) => {
|
|
4595
|
+
const tpl = (found === 1 && s.getAttribute('data-rv-status-one')) || s.getAttribute('data-rv-status-template') || '';
|
|
4596
|
+
s.textContent = tpl && filtered ? tpl.replace('{count}', fmtCount(found)).replace('{shown}', String(shown)).replace('{total}', String(total)) : '';
|
|
4597
|
+
});
|
|
4598
|
+
all('[data-rv-hl-topic]').forEach((m) => m.toggleAttribute('data-rv-hl-on', !!st.topic && (m.getAttribute('data-rv-hl-topic') || '').toLowerCase() === st.topic));
|
|
4599
|
+
highlight();
|
|
4600
|
+
drawChips();
|
|
4601
|
+
pagesNavs.forEach((nav) => {
|
|
4602
|
+
if (usesPages && nav.querySelector('[data-rv-page-tpl]')) drawPages(nav, pages);
|
|
4603
|
+
nav.hidden = !usesPages || pages <= 1;
|
|
4604
|
+
});
|
|
4605
|
+
all('[data-rv-page-label]').forEach((l) => { l.textContent = (l.getAttribute('data-rv-page-template') || '{page}').replace('{page}', String(st.page)).replace('{pages}', String(pages)); });
|
|
4606
|
+
prevPages.forEach((b) => { b.toggleAttribute('disabled', st.page <= 1); b.setAttribute('aria-disabled', String(st.page <= 1)); });
|
|
4607
|
+
nextPages.forEach((b) => { b.toggleAttribute('disabled', st.page >= pages); b.setAttribute('aria-disabled', String(st.page >= pages)); });
|
|
4608
|
+
syncReadMore();
|
|
4609
|
+
if (firstNew) {
|
|
4610
|
+
const f = firstNew.querySelector('h3, [tabindex], a, button') || firstNew;
|
|
4611
|
+
if (f === firstNew && !f.hasAttribute('tabindex')) f.setAttribute('tabindex', '-1');
|
|
4612
|
+
f.focus({ preventScroll: false });
|
|
4613
|
+
}
|
|
4614
|
+
if (scroll) { const t = root.querySelector('[data-rv-scroll-to]') || list; if (t.scrollIntoView) t.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
|
|
4615
|
+
const detail = { source: 'gem-runtime', section, sectionId, total, found, shown, page: st.page, pages, filtered, star: st.star, sort: st.sort };
|
|
4616
|
+
if (typeof window.CustomEvent === 'function') root.dispatchEvent(new window.CustomEvent('gem:reviews:render', { detail }));
|
|
4617
|
+
api.emit('gem:reviews:render', detail);
|
|
4618
|
+
};
|
|
4619
|
+
const reset = () => { st.page = 1; st.shown = pageSize; };
|
|
4620
|
+
|
|
4621
|
+
// Menus: one open at a time; Escape, a click outside, or choosing an item closes it.
|
|
4622
|
+
const menus = all('[data-rv-menu]');
|
|
4623
|
+
const panelOf = (b) => root.querySelector('[data-rv-menu-panel="' + b.getAttribute('data-rv-menu') + '"]');
|
|
4624
|
+
const closeMenus = (except) => menus.forEach((b) => {
|
|
4625
|
+
if (b === except) return;
|
|
4626
|
+
b.setAttribute('aria-expanded', 'false');
|
|
4627
|
+
const p = panelOf(b); if (p) p.hidden = true;
|
|
4628
|
+
});
|
|
4629
|
+
// A choice made inside a menu panel closes it, and focus goes back to the menu's button (the section behaviors left
|
|
4630
|
+
// it on a now hidden item, so it fell to <body>). A filter row in a panel with data-rv-menu-keep stays open where
|
|
4631
|
+
// that media query matches; sort and Show N always close.
|
|
4632
|
+
const closeAfterChoice = (el, always) => {
|
|
4633
|
+
const p = el.closest('[data-rv-menu-panel]');
|
|
4634
|
+
if (!p) { if (always) closeMenus(); return; }
|
|
4635
|
+
const keep = p.getAttribute('data-rv-menu-keep');
|
|
4636
|
+
if (!always && keep && window.matchMedia && window.matchMedia(keep).matches) return;
|
|
4637
|
+
const owner = menus.find((b) => panelOf(b) === p);
|
|
4638
|
+
closeMenus();
|
|
4639
|
+
if (owner && p.contains(document.activeElement)) owner.focus({ preventScroll: true });
|
|
4640
|
+
};
|
|
4641
|
+
menus.forEach((b) => on(b, 'click', (e) => {
|
|
4642
|
+
e.preventDefault();
|
|
4643
|
+
const open = b.getAttribute('aria-expanded') !== 'true';
|
|
4644
|
+
closeMenus(b);
|
|
4645
|
+
b.setAttribute('aria-expanded', String(open));
|
|
4646
|
+
const p = panelOf(b);
|
|
4647
|
+
if (p) { p.hidden = !open; if (open) { const f = p.querySelector('[aria-checked=true], [aria-current=true], button, a, input') || p; if (f.focus) f.focus({ preventScroll: true }); } }
|
|
4648
|
+
}));
|
|
4649
|
+
if (menus.length) {
|
|
4650
|
+
on(window, 'keydown', (e) => {
|
|
4651
|
+
if (e.key !== 'Escape') return;
|
|
4652
|
+
const openMenu = menus.find((x) => x.getAttribute('aria-expanded') === 'true');
|
|
4653
|
+
if (openMenu) { closeMenus(); openMenu.focus(); }
|
|
4654
|
+
});
|
|
4655
|
+
on(window, 'click', (e) => {
|
|
4656
|
+
if (!(e.target instanceof window.Node)) return;
|
|
4657
|
+
if (menus.some((b) => b.contains(e.target)) || all('[data-rv-menu-panel]').some((p) => p.contains(e.target))) return;
|
|
4658
|
+
closeMenus();
|
|
4659
|
+
});
|
|
4660
|
+
}
|
|
4661
|
+
|
|
4662
|
+
// Every filter control reads its state from st (buttons, menu items, chips, Clear).
|
|
4663
|
+
const syncStars = () => all('[data-rv-star]').forEach((x) => {
|
|
4664
|
+
const yes = Number(x.getAttribute('data-rv-star')) === st.star;
|
|
4665
|
+
if (x.hasAttribute('aria-checked')) x.setAttribute('aria-checked', String(yes));
|
|
4666
|
+
else if (x.hasAttribute('aria-current')) x.setAttribute('aria-current', String(yes && st.star > 0));
|
|
4667
|
+
else x.setAttribute('aria-pressed', String(yes && st.star > 0));
|
|
4668
|
+
});
|
|
4669
|
+
const syncControls = () => {
|
|
4670
|
+
syncStars();
|
|
4671
|
+
all('[data-rv-media-filter]').forEach((x) => { if (x.matches('input')) x.checked = st.media; else x.setAttribute('aria-pressed', String(st.media)); });
|
|
4672
|
+
all('[data-rv-country]').forEach((x) => x.setAttribute('aria-checked', String((x.getAttribute('data-rv-country') || '') === st.country)));
|
|
4673
|
+
all('[data-rv-topic]').forEach((x) => x.setAttribute('aria-pressed', String(!!st.topic && (x.getAttribute('data-rv-topic') || '').toLowerCase() === st.topic)));
|
|
4674
|
+
};
|
|
4675
|
+
|
|
4676
|
+
// Toggles: an inline panel / region / collapse opened by its button, not closed by a click outside. Closing a region
|
|
4677
|
+
// that has data-rv-region-clear drops the filters inside it (closing the filter row = no star filter; closing the
|
|
4678
|
+
// search box = no search), as Judge.me does.
|
|
4679
|
+
const PANEL_ATTRS = ['data-rv-toggle-panel', 'data-rv-region', 'data-rv-collapse-panel'];
|
|
4680
|
+
const togglePanels = (id) => all('[data-rv-toggle-panel], [data-rv-region], [data-rv-collapse-panel]').filter((p) => PANEL_ATTRS.some((a) => p.hasAttribute(a) && p.getAttribute(a) === id));
|
|
4681
|
+
const toggleId = (t) => (t.hasAttribute('data-rv-toggle') ? t.getAttribute('data-rv-toggle') : t.getAttribute('data-rv-collapse')) || '';
|
|
4682
|
+
const setToggle = (id, open) => {
|
|
4683
|
+
const ps = togglePanels(id);
|
|
4684
|
+
if (!ps.length) return;
|
|
4685
|
+
const was = ps.some((p) => !p.hidden);
|
|
4686
|
+
ps.forEach((p) => { p.hidden = !open; });
|
|
4687
|
+
all('[data-rv-toggle], [data-rv-collapse]').forEach((t) => { if (toggleId(t) === id) t.setAttribute('aria-expanded', String(open)); });
|
|
4688
|
+
const r = !open && was ? ps.find((p) => p.hasAttribute('data-rv-region-clear')) : null;
|
|
4689
|
+
if (!r) return;
|
|
4690
|
+
let changed = false;
|
|
4691
|
+
if (r.querySelector('[data-rv-star]') && st.star) { st.star = 0; changed = true; syncControls(); }
|
|
4692
|
+
r.querySelectorAll('input[data-rv-search]').forEach((x) => { if (x.value || st.q) { x.value = ''; st.q = ''; changed = true; } });
|
|
4693
|
+
if (changed) { reset(); render(); }
|
|
4694
|
+
};
|
|
4695
|
+
all('[data-rv-toggle], [data-rv-collapse]').forEach((t) => on(t, 'click', (e) => {
|
|
4696
|
+
e.preventDefault();
|
|
4697
|
+
const ps = togglePanels(toggleId(t));
|
|
4698
|
+
setToggle(toggleId(t), ps.length ? ps.every((p) => p.hidden) : t.getAttribute('aria-expanded') !== 'true');
|
|
4699
|
+
}));
|
|
4700
|
+
|
|
4701
|
+
const setSort = (key, label) => {
|
|
4702
|
+
st.sort = key; reset();
|
|
4703
|
+
all('[data-rv-sort-option]').forEach((o) => o.setAttribute('aria-checked', String(o.getAttribute('data-rv-sort-option') === key)));
|
|
4704
|
+
all('select[data-rv-sort]').forEach((s) => { if (s.value !== key) s.value = key; });
|
|
4705
|
+
if (label) {
|
|
4706
|
+
all('[data-rv-sort-label]').forEach((l) => { l.textContent = label; });
|
|
4707
|
+
all('[data-rv-menu][data-rv-sort-aria]').forEach((b) => b.setAttribute('aria-label', (b.getAttribute('data-rv-sort-aria') || '').replace('{label}', label)));
|
|
4708
|
+
}
|
|
4709
|
+
render();
|
|
4710
|
+
};
|
|
4711
|
+
all('select[data-rv-sort]').forEach((s) => on(s, 'change', () => setSort(s.value, '')));
|
|
4712
|
+
all('[data-rv-sort-option]').forEach((o) => on(o, 'click', (e) => {
|
|
4713
|
+
e.preventDefault();
|
|
4714
|
+
setSort(o.getAttribute('data-rv-sort-option') || '', (o.getAttribute('data-rv-sort-text') || o.textContent || '').trim());
|
|
4715
|
+
closeAfterChoice(o, true);
|
|
4716
|
+
}));
|
|
4717
|
+
const starKeep = root.getAttribute('data-rv-star-keep');
|
|
4718
|
+
all('[data-rv-star]').forEach((b) => on(b, 'click', (e) => {
|
|
4719
|
+
e.preventDefault();
|
|
4720
|
+
const v = Number(b.getAttribute('data-rv-star')) || 0;
|
|
4721
|
+
const keep = starKeep !== null && (starKeep !== 'menu' || !!b.closest('[data-rv-menu-panel]'));
|
|
4722
|
+
st.star = keep ? v : st.star === v ? 0 : v; reset();
|
|
4723
|
+
syncControls();
|
|
4724
|
+
if (b.hasAttribute('data-rv-open-region')) setToggle(b.getAttribute('data-rv-open-region') || '', true);
|
|
4725
|
+
closeAfterChoice(b);
|
|
4726
|
+
render();
|
|
4727
|
+
}));
|
|
4728
|
+
all('[data-rv-media-filter]').forEach((m) => on(m, m.matches('input') ? 'change' : 'click', (e) => {
|
|
4729
|
+
if (!m.matches('input')) e.preventDefault();
|
|
4730
|
+
st.media = m.matches('input') ? m.checked : !st.media; reset();
|
|
4731
|
+
syncControls();
|
|
4732
|
+
render();
|
|
4733
|
+
}));
|
|
4734
|
+
all('[data-rv-country]').forEach((c) => on(c, 'click', (e) => {
|
|
4735
|
+
e.preventDefault();
|
|
4736
|
+
st.country = c.getAttribute('data-rv-country') || ''; reset();
|
|
4737
|
+
syncControls();
|
|
4738
|
+
closeAfterChoice(c);
|
|
4739
|
+
render();
|
|
4740
|
+
}));
|
|
4741
|
+
all('[data-rv-topic]').forEach((t) => on(t, 'click', (e) => {
|
|
4742
|
+
e.preventDefault();
|
|
4743
|
+
const v = (t.getAttribute('data-rv-topic') || '').toLowerCase();
|
|
4744
|
+
st.topic = st.topic === v ? '' : v; reset();
|
|
4745
|
+
syncControls();
|
|
4746
|
+
render();
|
|
4747
|
+
}));
|
|
4748
|
+
all('input[data-rv-search]').forEach((s) => {
|
|
4749
|
+
let wait = 0;
|
|
4750
|
+
const apply = () => { st.q = s.value.trim().toLowerCase(); reset(); render(); };
|
|
4751
|
+
on(s, 'input', () => { window.clearTimeout(wait); wait = window.setTimeout(apply, 180); });
|
|
4752
|
+
on(s.form || s, 'submit', (e) => { e.preventDefault(); apply(); });
|
|
4753
|
+
});
|
|
4754
|
+
// Clear: data-rv-clear="star" | "topic" drops that filter only (the "\xD7 Clear" beside a label); empty drops them all.
|
|
4755
|
+
all('[data-rv-clear]').forEach((c) => on(c, 'click', (e) => {
|
|
4756
|
+
e.preventDefault();
|
|
4757
|
+
const scope = c.getAttribute('data-rv-clear') || '';
|
|
4758
|
+
if (!scope || scope === 'star') st.star = 0;
|
|
4759
|
+
if (!scope || scope === 'topic') st.topic = '';
|
|
4760
|
+
if (!scope) { st.media = false; st.q = ''; st.country = ''; all('input[data-rv-search]').forEach((x) => { x.value = ''; }); }
|
|
4761
|
+
reset();
|
|
4762
|
+
syncControls();
|
|
4763
|
+
render();
|
|
4764
|
+
}));
|
|
4765
|
+
on(chipsBox, 'click', (e) => {
|
|
4766
|
+
const b = isEl(e.target) ? e.target.closest('[data-rv-chip]') : null;
|
|
4767
|
+
if (!b) return;
|
|
4768
|
+
e.preventDefault();
|
|
4769
|
+
const k = b.getAttribute('data-rv-chip');
|
|
4770
|
+
if (k === 'star') st.star = 0;
|
|
4771
|
+
if (k === 'media') st.media = false;
|
|
4772
|
+
if (k === 'country') st.country = '';
|
|
4773
|
+
reset(); syncControls(); render();
|
|
4774
|
+
});
|
|
4775
|
+
// A filter sheet with Apply: its star chips only stage a choice, Apply filters, Reset un-stages.
|
|
4776
|
+
const syncStage = () => all('[data-rv-stage]').forEach((c) => c.setAttribute('aria-pressed', String(Number(c.getAttribute('data-rv-stage')) === st.staged)));
|
|
4777
|
+
all('[data-rv-stage]').forEach((c) => on(c, 'click', (e) => { e.preventDefault(); st.staged = Number(c.getAttribute('data-rv-stage')) || 0; syncStage(); }));
|
|
4778
|
+
all('[data-rv-reset]').forEach((c) => on(c, 'click', (e) => { e.preventDefault(); st.staged = 0; syncStage(); }));
|
|
4779
|
+
all('[data-rv-apply]').forEach((c) => on(c, 'click', (e) => {
|
|
4780
|
+
e.preventDefault();
|
|
4781
|
+
st.star = st.staged; reset(); syncControls(); render();
|
|
4782
|
+
const d = c.closest('dialog'); if (d && d.open) d.close();
|
|
4783
|
+
}));
|
|
4784
|
+
|
|
4785
|
+
on(more, 'click', (e) => { e.preventDefault(); st.shown += moreSize(); render(true); });
|
|
4786
|
+
on(expand, 'click', (e) => { e.preventDefault(); st.collapsed = false; st.page = 1; render(); });
|
|
4787
|
+
pagesNavs.forEach((nav) => on(nav, 'click', (e) => {
|
|
4788
|
+
const b = isEl(e.target) ? e.target.closest('[data-rv-page]') : null;
|
|
4789
|
+
if (!b || b.hasAttribute('data-rv-page-tpl')) return;
|
|
4790
|
+
e.preventDefault(); st.page = Number(b.getAttribute('data-rv-page')) || 1; render(false, true);
|
|
4791
|
+
}));
|
|
4792
|
+
prevPages.forEach((b) => on(b, 'click', (e) => { e.preventDefault(); if (st.page > 1) { st.page -= 1; render(false, true); } }));
|
|
4793
|
+
nextPages.forEach((b) => on(b, 'click', (e) => { e.preventDefault(); st.page += 1; render(false, true); }));
|
|
4794
|
+
all('[data-rv-per-page-option]').forEach((o) => on(o, 'click', (e) => {
|
|
4795
|
+
e.preventDefault();
|
|
4796
|
+
pageSize = Math.max(1, Number(o.getAttribute('data-rv-per-page-option')) || pageSize); reset();
|
|
4797
|
+
all('[data-rv-per-page-option]').forEach((x) => x.setAttribute('aria-checked', String(x === o)));
|
|
4798
|
+
all('[data-rv-per-page-label]').forEach((l) => { l.textContent = String(pageSize); });
|
|
4799
|
+
closeAfterChoice(o, true);
|
|
4800
|
+
render();
|
|
4801
|
+
}));
|
|
4802
|
+
if (pagesMq) on(pagesMq, 'change', () => { reset(); render(); });
|
|
4803
|
+
|
|
4804
|
+
all('[data-rv-readmore]').forEach((b) => on(b, 'click', (e) => {
|
|
4805
|
+
e.preventDefault();
|
|
4806
|
+
const body = moreScope(b).querySelector('[data-rv-body]');
|
|
4807
|
+
const open = b.getAttribute('aria-expanded') !== 'true';
|
|
4808
|
+
b.setAttribute('aria-expanded', String(open));
|
|
4809
|
+
if (body) body.toggleAttribute('data-rv-open', open);
|
|
4810
|
+
const less = b.getAttribute('data-rv-less'); const moreText = b.getAttribute('data-rv-more-text');
|
|
4811
|
+
if (less && moreText) b.textContent = open ? less : moreText;
|
|
4812
|
+
}));
|
|
4813
|
+
let resizeWait = 0;
|
|
4814
|
+
on(window, 'resize', () => { window.clearTimeout(resizeWait); resizeWait = window.setTimeout(syncReadMore, 150); });
|
|
4815
|
+
if (document.fonts && document.fonts.ready) document.fonts.ready.then(syncReadMore).catch(() => {});
|
|
4816
|
+
on(root, 'gem:reviews:refresh', () => render());
|
|
4817
|
+
|
|
4818
|
+
// Tracks (a strip of photos or cards): back / forward one card; the buttons switch off at either end. A looping
|
|
4819
|
+
// track (data-rv-loop) is the section's own carousel and is left to it.
|
|
4820
|
+
all('[data-rv-track]').forEach((track) => {
|
|
4821
|
+
if (track.hasAttribute('data-rv-loop')) return;
|
|
4822
|
+
const scope = track.closest('[data-rv-carousel]') || root;
|
|
4823
|
+
const prev = scope.querySelector('[data-rv-slide-prev]'); const next = scope.querySelector('[data-rv-slide-next]');
|
|
4824
|
+
const step = () => { const c = track.firstElementChild; const gap = parseFloat(window.getComputedStyle(track).columnGap || '0') || 0; return c ? c.getBoundingClientRect().width + gap : track.clientWidth; };
|
|
4825
|
+
const sync = () => { const max = track.scrollWidth - track.clientWidth - 2; if (prev) prev.toggleAttribute('disabled', track.scrollLeft <= 2); if (next) next.toggleAttribute('disabled', track.scrollLeft >= max); };
|
|
4826
|
+
on(prev, 'click', (e) => { e.preventDefault(); track.scrollBy({ left: -step(), behavior: 'smooth' }); });
|
|
4827
|
+
on(next, 'click', (e) => { e.preventDefault(); track.scrollBy({ left: step(), behavior: 'smooth' }); });
|
|
4828
|
+
on(track, 'scroll', sync, { passive: true });
|
|
4829
|
+
sync();
|
|
4830
|
+
});
|
|
4831
|
+
|
|
4832
|
+
// Filter sheet (phones): a button opens dialog[data-rv-sheet], delegated on the root so copies made after binding
|
|
4833
|
+
// (an inline card a section repeats) open it too; closing returns focus to that button.
|
|
4834
|
+
let sheetFrom = null;
|
|
4835
|
+
on(root, 'click', (e) => {
|
|
4836
|
+
const b = isEl(e.target) ? e.target.closest('[data-rv-sheet-open]') : null;
|
|
4837
|
+
if (!b) return;
|
|
4838
|
+
const d = root.querySelector('dialog[data-rv-sheet="' + b.getAttribute('data-rv-sheet-open') + '"]');
|
|
4839
|
+
if (!d || typeof d.showModal !== 'function') return;
|
|
4840
|
+
e.preventDefault(); sheetFrom = b; st.staged = st.star; syncStage(); closeMenus(); if (!d.open) d.showModal();
|
|
4841
|
+
});
|
|
4842
|
+
all('dialog[data-rv-sheet]').forEach((d) => {
|
|
4843
|
+
d.querySelectorAll('[data-rv-sheet-close]').forEach((c) => on(c, 'click', (e) => {
|
|
4844
|
+
e.preventDefault(); if (d.open) d.close();
|
|
4845
|
+
if (c.hasAttribute('data-rv-scroll-list') && list) { sheetFrom = null; list.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
|
|
4846
|
+
}));
|
|
4847
|
+
on(d, 'click', (e) => {
|
|
4848
|
+
if (e.target !== d || !d.hasAttribute('data-rv-backdrop-close')) return;
|
|
4849
|
+
const r = d.getBoundingClientRect();
|
|
4850
|
+
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) d.close();
|
|
4851
|
+
});
|
|
4852
|
+
on(d, 'close', () => { const opener = sheetFrom && sheetFrom.isConnected ? sheetFrom : null; sheetFrom = null; if (opener) opener.focus({ preventScroll: true }); });
|
|
4853
|
+
});
|
|
4854
|
+
|
|
4855
|
+
// Helpful: once per review (or per [data-rv-vote-scope]: a question, an answer), counted in place.
|
|
4856
|
+
all('[data-rv-vote]').forEach((b) => on(b, 'click', (e) => {
|
|
4857
|
+
e.preventDefault();
|
|
4858
|
+
const scope = b.closest('[data-rv-item], [data-rv-vote-scope]');
|
|
4859
|
+
if (!scope || scope.hasAttribute('data-rv-voted')) return;
|
|
4860
|
+
scope.setAttribute('data-rv-voted', b.getAttribute('data-rv-vote') || '');
|
|
4861
|
+
b.setAttribute('aria-pressed', 'true');
|
|
4862
|
+
const c = b.querySelector('[data-rv-vote-count]'); if (c) c.textContent = String((Number(c.textContent) || 0) + 1);
|
|
4863
|
+
scope.querySelectorAll('[data-rv-vote]').forEach((x) => x.setAttribute('disabled', ''));
|
|
4864
|
+
const detail = { source: 'gem-runtime', section, sectionId, review: scope.id, value: b.getAttribute('data-rv-vote') };
|
|
4865
|
+
api.emit('gem:review:vote', detail);
|
|
4866
|
+
if (typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:review:vote', { detail }));
|
|
4867
|
+
}));
|
|
4868
|
+
|
|
4869
|
+
// Lightbox. The set it walks: every [data-rv-zoom] of the section in page order (data-rv-lb-dedupe: one entry per
|
|
4870
|
+
// photo), the opener's [data-rv-group], the opener's review (data-rv-lb-scope="review"), or the opener's own
|
|
4871
|
+
// data-rv-media-list. Ends: hide the arrow (default), disable it, or wrap. The review the photo belongs to is copied
|
|
4872
|
+
// beside it from [data-rv-lb-part]; Escape closes (native dialog) and focus returns to the opener.
|
|
4873
|
+
const lb = root.querySelector('dialog[data-rv-lightbox]');
|
|
4874
|
+
const zooms = all('[data-rv-zoom]');
|
|
4875
|
+
const lbScoped = !!lb && lb.getAttribute('data-rv-lb-scope') === 'review';
|
|
4876
|
+
const lbEnds = lb ? lb.getAttribute('data-rv-lb-ends') || (lbScoped ? 'disable' : 'hide') : 'hide';
|
|
4877
|
+
const lbVotes = !lb || lb.getAttribute('data-rv-lb-votes') !== 'off';
|
|
4878
|
+
const entryOf = (z) => {
|
|
4879
|
+
const img = z.querySelector('img');
|
|
4880
|
+
return { el: z, src: z.getAttribute('data-rv-src') || '', kind: z.getAttribute('data-rv-kind') || '', poster: z.getAttribute('data-rv-poster') || '',
|
|
4881
|
+
alt: z.getAttribute('data-rv-alt') || '', thumb: z.getAttribute('data-rv-thumb') || (img && img.getAttribute('src')) || z.getAttribute('data-rv-src') || '',
|
|
4882
|
+
review: z.getAttribute('data-rv-review') || '' };
|
|
4883
|
+
};
|
|
4884
|
+
const lbAll = [];
|
|
4885
|
+
const seenSrc = new Set();
|
|
4886
|
+
zooms.forEach((z) => {
|
|
4887
|
+
const en = entryOf(z);
|
|
4888
|
+
if (lb && lb.hasAttribute('data-rv-lb-dedupe')) { if (seenSrc.has(en.src)) return; seenSrc.add(en.src); }
|
|
4889
|
+
lbAll.push(en);
|
|
4890
|
+
});
|
|
4891
|
+
const setFor = (z) => {
|
|
4892
|
+
const raw = z.getAttribute('data-rv-media-list');
|
|
4893
|
+
if (raw !== null) {
|
|
4894
|
+
let parsed = [];
|
|
4895
|
+
try { parsed = JSON.parse(raw || '[]'); } catch (err) { parsed = []; }
|
|
4896
|
+
return (Array.isArray(parsed) ? parsed : []).filter((m) => m && m.src).map((m) => ({ el: null, src: String(m.src), kind: m.kind || '', poster: m.poster || '', alt: m.alt || '', thumb: m.poster || String(m.src), review: z.getAttribute('data-rv-review') || '' }));
|
|
4897
|
+
}
|
|
4898
|
+
const g = z.getAttribute('data-rv-group');
|
|
4899
|
+
if (g) return lbAll.filter((x) => x.el.getAttribute('data-rv-group') === g);
|
|
4900
|
+
if (lbScoped) {
|
|
4901
|
+
const r = z.getAttribute('data-rv-review') || '';
|
|
4902
|
+
const own = lbAll.filter((x) => x.review === r && !x.el.closest('[data-rv-carousel]'));
|
|
4903
|
+
return own.length ? own : [entryOf(z)];
|
|
4904
|
+
}
|
|
4905
|
+
return lbAll;
|
|
4906
|
+
};
|
|
4907
|
+
const indexIn = (set, z) => {
|
|
4908
|
+
const i = set.findIndex((x) => x.el === z);
|
|
4909
|
+
if (i >= 0) return i;
|
|
4910
|
+
const src = z.getAttribute('data-rv-src');
|
|
4911
|
+
const j = set.findIndex((x) => x.src === src && x.review === (z.getAttribute('data-rv-review') || ''));
|
|
4912
|
+
return Math.max(0, j >= 0 ? j : set.findIndex((x) => x.src === src));
|
|
4913
|
+
};
|
|
4914
|
+
let lbSet = lbAll; let lbAt = 0; let lbFrom = null; let stripFor = null;
|
|
4915
|
+
const strip = lb ? lb.querySelector('[data-rv-lb-thumbs]') : null;
|
|
4916
|
+
const buildStrip = () => {
|
|
4917
|
+
if (!strip || stripFor === lbSet) return;
|
|
4918
|
+
stripFor = lbSet;
|
|
4919
|
+
strip.replaceChildren();
|
|
4920
|
+
if (lbSet.length > 1) lbSet.forEach((x, k) => {
|
|
4921
|
+
const b = document.createElement('button');
|
|
4922
|
+
b.type = 'button'; b.className = strip.getAttribute('data-rv-thumb-class') || ''; b.setAttribute('data-rv-lb-thumb', String(k));
|
|
4923
|
+
b.setAttribute('aria-label', (strip.getAttribute('data-rv-thumb-label') || 'Media {n} of {total}').replace('{n}', String(k + 1)).replace('{total}', String(lbSet.length)));
|
|
4924
|
+
const img = document.createElement('img'); img.loading = 'lazy'; img.decoding = 'async'; img.alt = ''; img.src = x.thumb;
|
|
4925
|
+
b.appendChild(img); strip.appendChild(b);
|
|
4926
|
+
});
|
|
4927
|
+
strip.hidden = lbSet.length <= 1;
|
|
4928
|
+
};
|
|
4929
|
+
const lbShow = (i) => {
|
|
4930
|
+
if (!lb) return;
|
|
4931
|
+
const n = lbSet.length;
|
|
4932
|
+
lbAt = lbEnds === 'wrap' && n ? ((i % n) + n) % n : Math.max(0, Math.min(n - 1, i));
|
|
4933
|
+
const x = lbSet[lbAt];
|
|
4934
|
+
lb.toggleAttribute('data-rv-lb-empty', !n);
|
|
4935
|
+
lb.toggleAttribute('data-rv-lb-multi', n > 1);
|
|
4936
|
+
const media = lb.querySelector('[data-rv-lb-media]');
|
|
4937
|
+
if (media) {
|
|
4938
|
+
media.replaceChildren();
|
|
4939
|
+
if (x && x.kind === 'video') { const v = document.createElement('video'); v.src = x.src; if (x.poster) v.setAttribute('poster', x.poster); v.controls = true; v.playsInline = true; v.autoplay = true; media.appendChild(v); }
|
|
4940
|
+
else if (x) { const img = document.createElement('img'); img.src = x.src; img.alt = x.alt; media.appendChild(img); }
|
|
4941
|
+
}
|
|
4942
|
+
const stage = lb.querySelector('[data-rv-lb-stage]');
|
|
4943
|
+
if (stage && x) {
|
|
4944
|
+
const img = x.el && x.el.querySelector('img');
|
|
4945
|
+
let bg = img ? img.currentSrc || img.src : '';
|
|
4946
|
+
if (!bg) { try { bg = new URL(x.src, window.location.href).href; } catch (err) { bg = x.src; } }
|
|
4947
|
+
stage.style.setProperty('--rv-lb-bg', 'url("' + String(bg).replace(/"/g, '%22') + '")');
|
|
4948
|
+
}
|
|
4949
|
+
const panel = lb.querySelector('[data-rv-lb-review]');
|
|
4950
|
+
if (panel) {
|
|
4951
|
+
panel.replaceChildren();
|
|
4952
|
+
// A review with no photos (an empty media list) still shows its review: the opener names it.
|
|
4953
|
+
const rid = x ? x.review : (lbFrom && lbFrom.getAttribute && lbFrom.getAttribute('data-rv-review')) || '';
|
|
4954
|
+
const item = rid ? items.find((it) => it.id === rid) : null;
|
|
4955
|
+
if (item) item.querySelectorAll('[data-rv-lb-part]').forEach((part) => {
|
|
4956
|
+
const copy = part.cloneNode(true);
|
|
4957
|
+
copy.removeAttribute('id'); copy.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
|
|
4958
|
+
copy.querySelectorAll('input, label[for], [data-rv-readmore]').forEach((el) => el.remove());
|
|
4959
|
+
copy.querySelectorAll('[data-rv-zoom]').forEach((t) => { t.removeAttribute('data-rv-zoom'); t.setAttribute('tabindex', '-1'); if (t.getAttribute('data-rv-src') === x.src) t.setAttribute('aria-current', 'true'); });
|
|
4960
|
+
copy.querySelectorAll('[data-rv-zoom-alias]').forEach((t) => { if (lbSet === lbAll && t.getAttribute('data-rv-zoom-alias') === String(lbAt)) t.setAttribute('aria-current', 'true'); });
|
|
4961
|
+
if (!lbVotes) copy.querySelectorAll('button').forEach((el) => { el.setAttribute('tabindex', '-1'); el.setAttribute('disabled', ''); });
|
|
4962
|
+
panel.appendChild(copy);
|
|
4963
|
+
});
|
|
4964
|
+
panel.hidden = !item;
|
|
4965
|
+
lb.toggleAttribute('data-rv-lb-has-review', !!item);
|
|
4966
|
+
}
|
|
4967
|
+
const peek = lb.querySelector('[data-rv-lb-peek]');
|
|
4968
|
+
if (peek) {
|
|
4969
|
+
peek.replaceChildren();
|
|
4970
|
+
const nx = lbSet[lbAt + 1];
|
|
4971
|
+
const url = nx ? nx.poster || (nx.kind === 'video' ? '' : nx.src) : '';
|
|
4972
|
+
if (url) { const img = document.createElement('img'); img.src = url; img.alt = ''; peek.appendChild(img); }
|
|
4973
|
+
peek.hidden = !nx;
|
|
4974
|
+
}
|
|
4975
|
+
const count = lb.querySelector('[data-rv-lb-count]'); if (count) count.textContent = n ? (lbAt + 1) + ' / ' + n : '';
|
|
4976
|
+
const pv = lb.querySelector('[data-rv-lb-prev]'); const nxb = lb.querySelector('[data-rv-lb-next]');
|
|
4977
|
+
[[pv, lbAt <= 0], [nxb, lbAt >= n - 1]].forEach(([b, atEnd]) => {
|
|
4978
|
+
if (!b) return;
|
|
4979
|
+
if (lbEnds === 'wrap') { b.hidden = n < 2; b.disabled = false; }
|
|
4980
|
+
else if (lbEnds === 'disable') { b.hidden = n < 2; b.disabled = atEnd; }
|
|
4981
|
+
else { b.hidden = atEnd; b.disabled = false; }
|
|
4982
|
+
});
|
|
4983
|
+
if (strip) Array.from(strip.children).forEach((t, k) => {
|
|
4984
|
+
if (k !== lbAt) { t.removeAttribute('aria-current'); return; }
|
|
4985
|
+
t.setAttribute('aria-current', 'true');
|
|
4986
|
+
if (strip.scrollTo) strip.scrollTo({ left: t.offsetLeft - (strip.clientWidth - t.offsetWidth) / 2, behavior: lb.open ? 'smooth' : 'auto' });
|
|
4987
|
+
});
|
|
4988
|
+
};
|
|
4989
|
+
const lbOpen = (e, set, at, opener) => {
|
|
4990
|
+
if (!lb || typeof lb.showModal !== 'function') return;
|
|
4991
|
+
e.preventDefault();
|
|
4992
|
+
closeMenus();
|
|
4993
|
+
lbFrom = opener; lbSet = set; buildStrip();
|
|
4994
|
+
lb.querySelectorAll('[data-rv-toggle]').forEach((t) => setToggle(toggleId(t), false));
|
|
4995
|
+
lbShow(at);
|
|
4996
|
+
if (!lb.open) lb.showModal();
|
|
4997
|
+
};
|
|
4998
|
+
zooms.forEach((z) => {
|
|
4999
|
+
const open = (e) => { const set = setFor(z); lbOpen(e, set, z.hasAttribute('data-rv-media-list') ? 0 : indexIn(set, z), z); };
|
|
5000
|
+
on(z, 'click', open);
|
|
5001
|
+
on(z, 'keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') open(e); });
|
|
5002
|
+
});
|
|
5003
|
+
all('[data-rv-zoom-alias]').forEach((z) => on(z, 'click', (e) => {
|
|
5004
|
+
if (!lb || !lbAll.length) return;
|
|
5005
|
+
e.stopPropagation();
|
|
5006
|
+
lbOpen(e, lbAll, Number(z.getAttribute('data-rv-zoom-alias')) || 0, z);
|
|
5007
|
+
}));
|
|
5008
|
+
if (lb) {
|
|
5009
|
+
on(lb.querySelector('[data-rv-lb-prev]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt - 1); });
|
|
5010
|
+
on(lb.querySelector('[data-rv-lb-next]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt + 1); });
|
|
5011
|
+
on(lb.querySelector('[data-rv-lb-close]'), 'click', (e) => { e.preventDefault(); lb.close(); });
|
|
5012
|
+
on(lb.querySelector('[data-rv-lb-peek]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt + 1); });
|
|
5013
|
+
on(strip, 'click', (e) => {
|
|
5014
|
+
const t = isEl(e.target) ? e.target.closest('[data-rv-lb-thumb]') : null;
|
|
5015
|
+
if (!t) return;
|
|
5016
|
+
e.preventDefault(); lbShow(Number(t.getAttribute('data-rv-lb-thumb')) || 0);
|
|
5017
|
+
});
|
|
5018
|
+
on(lb, 'keydown', (e) => { if (e.key === 'ArrowLeft') lbShow(lbAt - 1); if (e.key === 'ArrowRight') lbShow(lbAt + 1); });
|
|
5019
|
+
on(lb, 'click', (e) => { if (e.target === lb) lb.close(); });
|
|
5020
|
+
// Swipe on the photo (some widgets show no arrows on phones).
|
|
5021
|
+
const swipe = lb.querySelector('[data-rv-lb-stage]') || lb.querySelector('[data-rv-lb-media]');
|
|
5022
|
+
let sx = 0; let sy = 0; let tracking = false;
|
|
5023
|
+
on(swipe, 'touchstart', (e) => { const t = e.touches[0]; if (!t) return; sx = t.clientX; sy = t.clientY; tracking = true; }, { passive: true });
|
|
5024
|
+
on(swipe, 'touchend', (e) => {
|
|
5025
|
+
if (!tracking) return; tracking = false;
|
|
5026
|
+
const t = e.changedTouches[0]; if (!t) return;
|
|
5027
|
+
const dx = t.clientX - sx; const dy = t.clientY - sy;
|
|
5028
|
+
if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) lbShow(lbAt + (dx < 0 ? 1 : -1));
|
|
5029
|
+
});
|
|
5030
|
+
// Helpful from inside the lightbox: the copy's button presses the real one, then the copy redraws with the count.
|
|
5031
|
+
on(lb.querySelector('[data-rv-lb-review]'), 'click', (e) => {
|
|
5032
|
+
const v = lbVotes && isEl(e.target) ? e.target.closest('[data-rv-vote]') : null;
|
|
5033
|
+
if (!v) return;
|
|
5034
|
+
e.preventDefault();
|
|
5035
|
+
const x = lbSet[lbAt];
|
|
5036
|
+
const item = x && x.review ? items.find((it) => it.id === x.review) : null;
|
|
5037
|
+
const real = item && item.querySelector('[data-rv-vote="' + v.getAttribute('data-rv-vote') + '"]');
|
|
5038
|
+
if (real) real.click();
|
|
5039
|
+
lbShow(lbAt);
|
|
5040
|
+
});
|
|
5041
|
+
on(lb, 'close', () => { const vid = lb.querySelector('video'); if (vid) vid.pause(); if (lbFrom && lbFrom.focus) lbFrom.focus({ preventScroll: true }); });
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
render();
|
|
5045
|
+
});
|
|
5046
|
+
api.emit('gem:reviews:ready', { source: 'gem-runtime', count: boundReviews });`;
|
|
5047
|
+
}
|
|
5048
|
+
|
|
5049
|
+
// ../../packages/gem-framework/src/bundle-engine.ts
|
|
5050
|
+
function bundleEngineRuntimeScript() {
|
|
5051
|
+
return `let boundBundles = 0;
|
|
5052
|
+
const BB_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
|
|
5053
|
+
document.querySelectorAll('[data-bb-root]').forEach((root) => {
|
|
5054
|
+
if (!mark(root, 'bundle')) return;
|
|
5055
|
+
boundBundles += 1;
|
|
5056
|
+
const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };
|
|
5057
|
+
const isEl = (t) => t instanceof window.Element;
|
|
5058
|
+
const parse = (attr, fallback) => { try { const v = JSON.parse(root.getAttribute(attr) || 'null'); return v == null ? fallback : v; } catch (e) { return fallback; } };
|
|
5059
|
+
let rules = parse('data-bb-rules', {});
|
|
5060
|
+
const text = root.hasAttribute('data-bb-messages') ? parse('data-bb-messages', {}) : parse('data-bb-labels', {});
|
|
5061
|
+
const viMoney = root.getAttribute('data-bb-money') === 'vi';
|
|
5062
|
+
const currency = root.getAttribute('data-bb-currency') || (viMoney ? 'VND' : 'USD');
|
|
5063
|
+
let nf = null;
|
|
5064
|
+
if (!viMoney) { try { nf = new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: currency === 'VND' ? 0 : 2 }); } catch (e) { nf = null; } }
|
|
5065
|
+
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));
|
|
5066
|
+
// A strike price as stores show it: a whole number drops its cents ("$42", not "$42.00").
|
|
5067
|
+
const moneyShort = (n) => { const s = money(n); return Math.abs(n - Math.round(n)) < 0.005 ? s.replace(/[.,]00(?=\\D*$)/, '') : s; };
|
|
5068
|
+
const fill = (tpl, vars) => String(tpl || '').replace(/\\{(\\w+)\\}/g, (m, k) => (Object.hasOwn(vars, k) ? String(vars[k]) : m));
|
|
5069
|
+
// A sentence with {reward}: the reward is a <strong>, the rest plain text. Never innerHTML.
|
|
5070
|
+
const say = (el, tpl, vars) => {
|
|
5071
|
+
if (!el) return;
|
|
5072
|
+
const parts = String(tpl || '').split('{reward}');
|
|
5073
|
+
el.replaceChildren();
|
|
5074
|
+
parts.forEach((part, i) => {
|
|
5075
|
+
if (part) el.append(document.createTextNode(fill(part, vars)));
|
|
5076
|
+
if (i < parts.length - 1 && vars.reward) { const s = document.createElement('strong'); s.textContent = vars.reward; el.append(s); }
|
|
5077
|
+
});
|
|
5078
|
+
};
|
|
5079
|
+
let min = Math.max(1, Number(rules.min) || 1);
|
|
5080
|
+
let cap = Number(rules.max) > 0 ? Number(rules.max) : Infinity;
|
|
5081
|
+
const per = Number(rules.per) > 0 ? Number(rules.per) : Infinity;
|
|
5082
|
+
let tiers = Array.isArray(rules.tiers) ? rules.tiers : [];
|
|
5083
|
+
let goal = tiers.length ? tiers[tiers.length - 1] : null;
|
|
5084
|
+
// The catalog: one entry per item, plus one per extra variant of a card (<key>~<index>).
|
|
5085
|
+
const catalog = new Map();
|
|
5086
|
+
const cards = new Map();
|
|
5087
|
+
root.querySelectorAll('[data-bb-item]').forEach((el) => {
|
|
5088
|
+
const key = el.getAttribute('data-bb-item');
|
|
5089
|
+
const base = {
|
|
5090
|
+
el,
|
|
5091
|
+
variantId: el.getAttribute('data-bb-variant') || '',
|
|
5092
|
+
price: Number(el.getAttribute('data-bb-price')) || 0,
|
|
5093
|
+
compare: Number(el.getAttribute('data-bb-compare')) || 0,
|
|
5094
|
+
title: el.getAttribute('data-bb-title') || '',
|
|
5095
|
+
subtitle: el.getAttribute('data-bb-subtitle') || '',
|
|
5096
|
+
image: el.getAttribute('data-bb-image') || '',
|
|
5097
|
+
group: el.getAttribute('data-bb-group') || '',
|
|
5098
|
+
step: el.getAttribute('data-bb-step') || '',
|
|
5099
|
+
short: el.getAttribute('data-bb-short') || '',
|
|
5100
|
+
available: el.getAttribute('data-available') !== 'false',
|
|
5101
|
+
};
|
|
5102
|
+
catalog.set(key, base);
|
|
5103
|
+
let vs = [];
|
|
5104
|
+
try { vs = JSON.parse(el.getAttribute('data-bb-variants') || '[]'); } catch (e) { vs = []; }
|
|
5105
|
+
if (!Array.isArray(vs)) vs = [];
|
|
5106
|
+
vs.forEach((v, i) => {
|
|
5107
|
+
if (!i) return;
|
|
5108
|
+
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 || '' });
|
|
5109
|
+
});
|
|
5110
|
+
if (vs[0]) base.variantTitle = vs[0].title || '';
|
|
5111
|
+
cards.set(key, { el, variants: vs, gift: el.hasAttribute('data-bb-gift') });
|
|
5112
|
+
});
|
|
5113
|
+
const gifts = Array.from(cards.entries()).filter(([, c]) => c.gift).map(([k]) => k);
|
|
5114
|
+
// The variant a card shows now (data-bb-current), as the key an action picks.
|
|
5115
|
+
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; };
|
|
5116
|
+
let picks = [];
|
|
5117
|
+
let plan = 'one_time';
|
|
5118
|
+
const planOf = (el) => (el.type === 'checkbox' ? (el.checked ? 'subscribe' : 'one_time') : el.getAttribute('data-bb-plan') || 'one_time');
|
|
5119
|
+
const planInputs = Array.from(root.querySelectorAll('input[data-bb-plan]'));
|
|
5120
|
+
const checkedPlan = planInputs.find((el) => el.checked);
|
|
5121
|
+
if (checkedPlan) plan = planOf(checkedPlan);
|
|
5122
|
+
const tierFor = (n) => tiers.filter((t) => n >= t.min).pop() || null;
|
|
5123
|
+
const pctOf = (t, p) => (!t ? 0 : p === 'subscribe' ? Number(t.sub) || Number(t.pct) || 0 : Number(t.pct) || 0);
|
|
5124
|
+
// 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.
|
|
5125
|
+
const rewardOf = (t, p) => (!t ? '' : pctOf(t, p) + '% off');
|
|
5126
|
+
const qtyOf = (key) => picks.filter((p) => p === key).length;
|
|
5127
|
+
const canAdd = (key) => picks.length < cap && (rules.repeat !== false || !picks.includes(key)) && qtyOf(key) < per;
|
|
5128
|
+
|
|
5129
|
+
// Box sizes, when the root declares them (data-bb-sizes): choosing a size sets floor = ceiling = its item count. "trim":
|
|
5130
|
+
// a smaller box drops the LAST picks; "queue": they wait, in order, and come back when there is room again.
|
|
5131
|
+
const sizeMode = root.getAttribute('data-bb-sizes');
|
|
5132
|
+
const sizes = Array.isArray(rules.sizes) ? rules.sizes : [];
|
|
5133
|
+
let size = null;
|
|
5134
|
+
const waiting = [];
|
|
5135
|
+
const refill = () => { while (sizeMode === 'queue' && picks.length < cap && waiting.length) picks.push(waiting.shift()); };
|
|
5136
|
+
const applySize = (n) => {
|
|
5137
|
+
const s = sizes.find((x) => Number(x.items) === n);
|
|
5138
|
+
if (!s) return false;
|
|
5139
|
+
size = s; min = Number(s.items); cap = Number(s.items);
|
|
5140
|
+
goal = tierFor(min) || (tiers.length ? tiers[tiers.length - 1] : null);
|
|
5141
|
+
if (picks.length > cap) { const out = picks.splice(cap); if (sizeMode === 'queue') waiting.unshift(...out); }
|
|
5142
|
+
refill();
|
|
5143
|
+
return true;
|
|
5144
|
+
};
|
|
5145
|
+
if (sizeMode) { const s0 = sizes.find((s) => Number(s.items) === Number(rules.size)) || sizes[0] || null; if (s0) applySize(Number(s0.items)); }
|
|
5146
|
+
|
|
5147
|
+
// Kits (rules.kits): each keeps its own picks and steps; a kit's own rules replace the bundle's while it is chosen.
|
|
5148
|
+
// A kit with steps fills each step's slots in order and moves on by itself; optional steps open after the required ones.
|
|
5149
|
+
const kits = Array.isArray(rules.kits) && rules.kits.length ? rules.kits : null;
|
|
5150
|
+
const stateOf = new Map();
|
|
5151
|
+
(kits || []).forEach((k) => {
|
|
5152
|
+
const steps = Array.isArray(k.steps) ? k.steps : [];
|
|
5153
|
+
const slots = {};
|
|
5154
|
+
steps.forEach((s) => { slots[s.id] = Array.from({ length: Math.max(1, Number(s.pick) || 1) }, () => null); });
|
|
5155
|
+
stateOf.set(k.id, { kit: k, steps, fill: slots, picks: [], current: steps.length ? steps[0].id : null });
|
|
5156
|
+
});
|
|
5157
|
+
let st = kits ? stateOf.get(kits[0].id) : { kit: null, steps: [], fill: {}, picks, current: null };
|
|
5158
|
+
const useKit = (id) => {
|
|
5159
|
+
if (!kits) return;
|
|
5160
|
+
st = stateOf.get(id) || stateOf.get(kits[0].id);
|
|
5161
|
+
const k = st.kit;
|
|
5162
|
+
rules = { ...rules, min: k.min, max: k.max, tiers: k.tiers, repeat: k.repeat, price_mode: k.price_mode, fixed_price: k.fixed_price };
|
|
5163
|
+
min = Math.max(1, Number(k.min) || 1);
|
|
5164
|
+
cap = Number(k.max) > 0 ? Number(k.max) : Infinity;
|
|
5165
|
+
tiers = Array.isArray(k.tiers) ? k.tiers : [];
|
|
5166
|
+
goal = tiers.length ? tiers[tiers.length - 1] : null;
|
|
5167
|
+
picks = st.picks;
|
|
5168
|
+
};
|
|
5169
|
+
if (kits) useKit(kits[0].id);
|
|
5170
|
+
const stepsMode = () => st.steps.length > 0;
|
|
5171
|
+
const required = () => st.steps.filter((s) => !s.optional);
|
|
5172
|
+
const isFull = (s) => st.fill[s.id].every(Boolean);
|
|
5173
|
+
// picks (in step order) are rebuilt from the slots after every change: price and cart read picks.
|
|
5174
|
+
const syncPicks = () => { picks.length = 0; st.steps.forEach((s) => st.fill[s.id].forEach((k) => { if (k) picks.push(k); })); };
|
|
5175
|
+
const openRequired = () => required().find((s) => !isFull(s)) || null;
|
|
5176
|
+
const nextOpen = () => openRequired() || st.steps.find((s) => s.optional && !isFull(s)) || null;
|
|
5177
|
+
const doneCount = () => required().filter(isFull).length;
|
|
5178
|
+
const isComplete = () => (stepsMode() ? doneCount() === required().length : picks.length >= min);
|
|
5179
|
+
const reachable = (s) => { const open = openRequired(); return !open || st.steps.indexOf(s) <= st.steps.indexOf(open); };
|
|
5180
|
+
const slotRef = (idx) => {
|
|
5181
|
+
let n = 0;
|
|
5182
|
+
for (const s of st.steps) for (let j = 0; j < st.fill[s.id].length; j++) { if (n === idx) return [s, j]; n++; }
|
|
5183
|
+
return null;
|
|
5184
|
+
};
|
|
5185
|
+
// Into the card's step: its first empty slot; a full step swaps its last pick (a full optional step refuses).
|
|
5186
|
+
const addToStep = (key) => {
|
|
5187
|
+
const it = catalog.get(key);
|
|
5188
|
+
const step = (it && st.steps.find((s) => s.id === it.step)) || st.steps.find((s) => s.id === st.current);
|
|
5189
|
+
if (!step || (rules.repeat === false && picks.includes(key))) return false;
|
|
5190
|
+
const row = st.fill[step.id];
|
|
5191
|
+
let i = row.indexOf(null);
|
|
5192
|
+
if (i < 0) { if (step.optional) return false; i = row.length - 1; }
|
|
5193
|
+
row[i] = key;
|
|
5194
|
+
syncPicks();
|
|
5195
|
+
if (isFull(step)) { const n = nextOpen(); if (n) st.current = n.id; }
|
|
5196
|
+
return true;
|
|
5197
|
+
};
|
|
5198
|
+
// "Edit" on a required step empties the slot, closes the gap and goes back to that step; on an optional step it only removes.
|
|
5199
|
+
const editSlot = (idx) => {
|
|
5200
|
+
const ref = slotRef(idx);
|
|
5201
|
+
if (!ref) return;
|
|
5202
|
+
const [s, j] = ref;
|
|
5203
|
+
const row = st.fill[s.id];
|
|
5204
|
+
row.splice(j, 1); row.push(null);
|
|
5205
|
+
syncPicks();
|
|
5206
|
+
if (!s.optional) st.current = s.id;
|
|
5207
|
+
};
|
|
5208
|
+
const clearAll = () => {
|
|
5209
|
+
st.steps.forEach((s) => { st.fill[s.id] = st.fill[s.id].map(() => null); });
|
|
5210
|
+
picks.length = 0; waiting.length = 0;
|
|
5211
|
+
st.current = st.steps.length ? st.steps[0].id : null;
|
|
5212
|
+
};
|
|
5213
|
+
|
|
5214
|
+
// Prices. totals(): the bundle price at the tier the bundle WILL reach once it has its minimum (stores show the bundle
|
|
5215
|
+
// price from the first pick), plus a container's base price. totalsNow(): at the tier reached now, rounded to the cent
|
|
5216
|
+
// first so total + saved add up.
|
|
5217
|
+
let basePrice = 0;
|
|
5218
|
+
let cartHead = null;
|
|
5219
|
+
const sumOf = () => {
|
|
5220
|
+
let sum = 0; let compare = 0;
|
|
5221
|
+
picks.forEach((k) => { const it = catalog.get(k); if (!it) return; sum += it.price; compare += Math.max(it.compare, it.price); });
|
|
5222
|
+
return { sum, compare };
|
|
5223
|
+
};
|
|
5224
|
+
const totals = (p) => {
|
|
5225
|
+
const { sum, compare } = sumOf();
|
|
5226
|
+
if (rules.price_mode === 'fixed' && !basePrice) {
|
|
5227
|
+
// A box size with a fixed one-time price; or a kit with one price whatever is picked (no strike price then).
|
|
5228
|
+
if (size && Number(size.fixed) > 0) { if (p !== 'subscribe') return { sum, compare, total: Number(size.fixed) }; }
|
|
5229
|
+
else if (Number(rules.fixed_price) > 0) return { sum, compare: 0, total: Number(rules.fixed_price) };
|
|
5230
|
+
}
|
|
5231
|
+
const t = tierFor(Math.max(picks.length, min)) || goal;
|
|
5232
|
+
return { sum, compare, total: basePrice + sum * (1 - pctOf(t, p) / 100) };
|
|
5233
|
+
};
|
|
5234
|
+
const totalsNow = (p) => {
|
|
5235
|
+
const { sum, compare } = sumOf();
|
|
5236
|
+
return { sum, compare, total: Math.round(sum * (1 - pctOf(tierFor(picks.length), p) / 100) * 100 + 1e-6) / 100 };
|
|
5237
|
+
};
|
|
5238
|
+
// One item's price on its card at the bundle's current tier, counting at least one item.
|
|
5239
|
+
const unitNow = (it, p) => it.price * (1 - pctOf(tierFor(Math.max(picks.length, 1)), p) / 100);
|
|
5240
|
+
|
|
5241
|
+
const frequency = () => root.querySelector('[data-bb-frequency]');
|
|
5242
|
+
const planId = () => {
|
|
5243
|
+
if (plan !== 'subscribe') return '';
|
|
5244
|
+
const f = frequency();
|
|
5245
|
+
if (!f) return '';
|
|
5246
|
+
if (f.matches('select')) return f.value;
|
|
5247
|
+
const c = f.querySelector('input:checked');
|
|
5248
|
+
return c ? c.value : '';
|
|
5249
|
+
};
|
|
5250
|
+
const bundleName = () => {
|
|
5251
|
+
const n = (st.kit && st.kit.label) || (root.querySelector('[data-bb-name]') || {}).textContent || 'Bundle';
|
|
5252
|
+
return String(n).trim();
|
|
5253
|
+
};
|
|
5254
|
+
const toCart = () => {
|
|
5255
|
+
const counts = new Map();
|
|
5256
|
+
picks.concat(gifts).forEach((k) => { const it = catalog.get(k); if (it && it.variantId) counts.set(it.variantId, (counts.get(it.variantId) || 0) + 1); });
|
|
5257
|
+
const ruleKey = root.getAttribute('data-bb-discount-key') || '';
|
|
5258
|
+
const id = (root.getAttribute('data-gem-section-id') || 'bundle') + '-' + Date.now().toString(36);
|
|
5259
|
+
const props = [{ key: '_bundle_id', value: id }, { key: '_bundle', value: bundleName() }].concat(ruleKey ? [{ key: '_bundle_rule', value: ruleKey }] : []);
|
|
5260
|
+
const pid = planId();
|
|
5261
|
+
const lines = Array.from(counts.entries()).map(([variantId, quantity]) => ({ variantId, quantity, properties: props.slice(), ...(pid ? { sellingPlanId: pid } : {}) }));
|
|
5262
|
+
// A container line (a gift box) goes first and carries the section's own properties (lid, gift message).
|
|
5263
|
+
const head = cartHead ? cartHead() : null;
|
|
5264
|
+
if (head) {
|
|
5265
|
+
const hp = props.concat(Array.isArray(head.properties) ? head.properties : []);
|
|
5266
|
+
if (head.variantId) lines.unshift({ variantId: head.variantId, quantity: 1, properties: hp, ...(pid ? { sellingPlanId: pid } : {}) });
|
|
5267
|
+
else if (lines[0]) lines[0].properties = hp;
|
|
5268
|
+
}
|
|
5269
|
+
if (lines.length && typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:cart:add-many', { detail: { lines, after: 'drawer' } }));
|
|
5270
|
+
return lines.length;
|
|
5271
|
+
};
|
|
5272
|
+
|
|
5273
|
+
let busy = false;
|
|
5274
|
+
let wasComplete = false;
|
|
5275
|
+
const state = () => {
|
|
5276
|
+
const n = picks.length;
|
|
5277
|
+
const counts = {};
|
|
5278
|
+
picks.forEach((k) => { counts[k] = (counts[k] || 0) + 1; });
|
|
5279
|
+
return {
|
|
5280
|
+
picks, count: n, counts, min, cap, max: cap === Infinity ? null : cap, per, rules, text, tiers, goal, plan, size, sizes, waiting,
|
|
5281
|
+
catalog, cards, gifts, reached: tierFor(n), next: tiers.find((t) => n < t.min) || null, left: Math.max(0, min - n),
|
|
5282
|
+
kit: st.kit, kits, steps: st.steps, fill: st.fill, current: st.current, stepsMode: stepsMode(), done: doneCount(),
|
|
5283
|
+
required: required(), complete: isComplete(), busy, base: basePrice,
|
|
5284
|
+
};
|
|
5285
|
+
};
|
|
5286
|
+
const outs = () => root.querySelectorAll('[data-bb-out]');
|
|
5287
|
+
const emit = (action, extra) => {
|
|
5288
|
+
const n = picks.length;
|
|
5289
|
+
const complete = isComplete();
|
|
5290
|
+
root.setAttribute('data-bb-count', String(n));
|
|
5291
|
+
root.setAttribute('data-bb-state', n === 0 ? 'empty' : n >= cap ? 'full' : complete ? 'complete' : 'partial');
|
|
5292
|
+
if (outs().length) {
|
|
5293
|
+
const t = totals(plan); const reached = tierFor(n);
|
|
5294
|
+
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) : '' };
|
|
5295
|
+
outs().forEach((el) => { const k = el.getAttribute('data-bb-out'); if (Object.hasOwn(v, k)) el.textContent = v[k]; });
|
|
5296
|
+
}
|
|
5297
|
+
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 };
|
|
5298
|
+
if (typeof window.CustomEvent === 'function') {
|
|
5299
|
+
root.dispatchEvent(new window.CustomEvent('gem:bundle:change', { bubbles: true, detail }));
|
|
5300
|
+
if (complete && !wasComplete) root.dispatchEvent(new window.CustomEvent('gem:bundle:complete', { bubbles: true, detail }));
|
|
5301
|
+
}
|
|
5302
|
+
wasComplete = complete;
|
|
5303
|
+
};
|
|
5304
|
+
|
|
5305
|
+
// Actions: the handlers below call these, and so may a section's own controls (root.gemBundle.add(key)\u2026).
|
|
5306
|
+
const add = (key) => {
|
|
5307
|
+
const before = st.current;
|
|
5308
|
+
if (root.hasAttribute('data-bb-add-toggle') && picks.includes(key)) { picks.splice(picks.indexOf(key), 1); emit('remove', { key }); return true; }
|
|
5309
|
+
if (stepsMode()) { if (!addToStep(key)) return false; }
|
|
5310
|
+
else { if (!canAdd(key)) return false; picks.push(key); }
|
|
5311
|
+
emit('add', { key, from: before });
|
|
5312
|
+
return true;
|
|
5313
|
+
};
|
|
5314
|
+
const removeAt = (idx) => { if (stepsMode()) editSlot(idx); else { picks.splice(idx, 1); refill(); } emit('remove', { index: idx }); };
|
|
5315
|
+
const dec = (key) => { const at = picks.lastIndexOf(key); if (at < 0) return false; picks.splice(at, 1); refill(); emit('dec', { key }); return true; };
|
|
5316
|
+
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 }); };
|
|
5317
|
+
const clear = () => { clearAll(); emit('clear'); };
|
|
5318
|
+
// A typed quantity: exactly k picks of that item, within the bundle's ceiling, the item's own and no-repeat.
|
|
5319
|
+
const setQty = (key, want) => {
|
|
5320
|
+
if (!catalog.has(key)) return;
|
|
5321
|
+
const has = qtyOf(key);
|
|
5322
|
+
const room = cap === Infinity ? Infinity : cap - (picks.length - has);
|
|
5323
|
+
const k = Math.max(0, Math.min(Math.floor(Number(want)) || 0, room, per, rules.repeat === false ? 1 : Infinity));
|
|
5324
|
+
for (let i = picks.length - 1; i >= 0; i--) if (picks[i] === key) picks.splice(i, 1);
|
|
5325
|
+
for (let i = 0; i < k; i++) picks.push(key);
|
|
5326
|
+
emit('qty', { key });
|
|
5327
|
+
};
|
|
5328
|
+
const setPlan = (p) => { plan = p === 'subscribe' ? 'subscribe' : 'one_time'; emit('plan'); };
|
|
5329
|
+
const setSize = (n) => {
|
|
5330
|
+
if (!sizeMode || !applySize(Number(n))) return false;
|
|
5331
|
+
// A box size may name its default selling plan.
|
|
5332
|
+
const f = frequency();
|
|
5333
|
+
if (f && f.matches('select') && size && size.plan && Array.from(f.options).some((o) => o.value === size.plan)) f.value = size.plan;
|
|
5334
|
+
emit('size');
|
|
5335
|
+
return true;
|
|
5336
|
+
};
|
|
5337
|
+
// An item's variant radio (a pack size): the whole row changes variant and price, its count stays.
|
|
5338
|
+
const applyVariant = (radio) => {
|
|
5339
|
+
const it = catalog.get(radio.getAttribute('data-bb-size'));
|
|
5340
|
+
if (!it) return false;
|
|
5341
|
+
it.variantId = radio.getAttribute('data-bb-variant') || '';
|
|
5342
|
+
it.price = Number(radio.getAttribute('data-bb-price')) || 0;
|
|
5343
|
+
it.compare = Number(radio.getAttribute('data-bb-compare')) || 0;
|
|
5344
|
+
it.el.setAttribute('data-bb-variant', it.variantId);
|
|
5345
|
+
it.el.setAttribute('data-bb-price', String(it.price));
|
|
5346
|
+
it.el.setAttribute('data-bb-compare', it.compare ? String(it.compare) : '');
|
|
5347
|
+
return true;
|
|
5348
|
+
};
|
|
5349
|
+
const selectKit = (id) => { if (!kits) return; useKit(id); emit('kit'); };
|
|
5350
|
+
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; };
|
|
5351
|
+
const finish = (ok) => {
|
|
5352
|
+
busy = false;
|
|
5353
|
+
root.querySelectorAll('[data-bb-cta]').forEach((c) => { c.removeAttribute('data-state'); c.removeAttribute('aria-busy'); });
|
|
5354
|
+
if (ok) clearAll();
|
|
5355
|
+
emit(ok ? 'added' : 'add-failed');
|
|
5356
|
+
};
|
|
5357
|
+
const checkout = () => {
|
|
5358
|
+
if (!isComplete() || busy || !picks.length) return false;
|
|
5359
|
+
if (!toCart()) return false;
|
|
5360
|
+
// With gemsite's cart bridge: wait for its answer before clearing (a failure keeps the bundle to try again).
|
|
5361
|
+
if (window.__gemCartAddBridge) {
|
|
5362
|
+
busy = true;
|
|
5363
|
+
const box = st.kit ? root.querySelector('[data-bb-kit-box="' + st.kit.id + '"]') : null;
|
|
5364
|
+
(box || root).querySelectorAll('[data-bb-cta]').forEach((c) => { c.setAttribute('data-state', 'loading'); c.setAttribute('aria-busy', 'true'); });
|
|
5365
|
+
emit('checkout');
|
|
5366
|
+
} else finish(true);
|
|
5367
|
+
return true;
|
|
5368
|
+
};
|
|
5369
|
+
|
|
5370
|
+
on(root, 'click', (event) => {
|
|
5371
|
+
const t = isEl(event.target) ? event.target : null;
|
|
5372
|
+
if (!t) return;
|
|
5373
|
+
const addEl = t.closest('[data-bb-add], [data-bb-inc]');
|
|
5374
|
+
if (addEl) { add(currentKey(addEl.getAttribute('data-bb-add') || addEl.getAttribute('data-bb-inc'))); return; }
|
|
5375
|
+
const decEl = t.closest('[data-bb-dec], [data-bb-minus]');
|
|
5376
|
+
if (decEl) { dec(currentKey(decEl.getAttribute('data-bb-dec') || decEl.getAttribute('data-bb-minus'))); return; }
|
|
5377
|
+
const un = t.closest('[data-bb-uncheck]');
|
|
5378
|
+
if (un) { uncheck(currentKey(un.getAttribute('data-bb-uncheck'))); return; }
|
|
5379
|
+
const rm = t.closest('[data-bb-remove]');
|
|
5380
|
+
if (rm) { removeAt(Number(rm.getAttribute('data-bb-remove')) || 0); return; }
|
|
5381
|
+
if (t.closest('[data-bb-clear]')) { clear(); return; }
|
|
5382
|
+
const nav = t.closest('[data-bb-step-nav]');
|
|
5383
|
+
if (nav) { if (!nav.disabled) goStep(nav.getAttribute('data-bb-step-nav')); return; }
|
|
5384
|
+
const tab = t.closest('[data-bb-kit-tab]');
|
|
5385
|
+
if (tab) { selectKit(tab.getAttribute('data-bb-kit-tab')); return; }
|
|
5386
|
+
if (t.closest('[data-bb-skip]')) { checkout(); return; }
|
|
5387
|
+
const cta = t.closest('[data-bb-cta]');
|
|
5388
|
+
if (cta) { event.preventDefault(); if (cta.getAttribute('aria-disabled') !== 'true') checkout(); }
|
|
5389
|
+
});
|
|
5390
|
+
on(root, 'change', (event) => {
|
|
5391
|
+
const t = isEl(event.target) ? event.target : null;
|
|
5392
|
+
if (!t) return;
|
|
5393
|
+
const p = t.closest('input[data-bb-plan]');
|
|
5394
|
+
if (p) { if (p.type === 'checkbox' || p.checked) setPlan(planOf(p)); return; }
|
|
5395
|
+
const s = t.closest('input[data-bb-size]');
|
|
5396
|
+
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; }
|
|
5397
|
+
const q = t.closest('input[data-bb-count]');
|
|
5398
|
+
if (q) setQty(q.getAttribute('data-bb-count'), q.value);
|
|
5399
|
+
});
|
|
5400
|
+
// A typed quantity applies as soon as it is a number (an emptied box waits for "change" to go back to 0).
|
|
5401
|
+
on(root, 'input', (event) => {
|
|
5402
|
+
const q = isEl(event.target) ? event.target.closest('input[data-bb-count]') : null;
|
|
5403
|
+
if (q && q.value !== '' && Number.isFinite(Number(q.value))) setQty(q.getAttribute('data-bb-count'), q.value);
|
|
5404
|
+
});
|
|
5405
|
+
on(window, 'gem:cart:added', () => { if (busy) finish(true); });
|
|
5406
|
+
on(window, 'gem:cart:add-failed', () => { if (busy) finish(false); });
|
|
5407
|
+
|
|
5408
|
+
// A page brought back from history restores its form: read back the variant radios and typed quantities in view.
|
|
5409
|
+
root.querySelectorAll('input[data-bb-size][data-bb-variant]').forEach((r) => { if (r.checked) applyVariant(r); });
|
|
5410
|
+
root.querySelectorAll('input[data-bb-count]').forEach((inp) => {
|
|
5411
|
+
const v = Math.floor(Number(inp.value)) || 0;
|
|
5412
|
+
const key = inp.getAttribute('data-bb-count');
|
|
5413
|
+
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); }
|
|
5414
|
+
});
|
|
5415
|
+
|
|
5416
|
+
root.gemBundle = {
|
|
5417
|
+
state, money, moneyShort, fill, say, words: BB_WORDS, tierFor, pctOf, rewardOf, canAdd, qtyOf, currentKey, totals, totalsNow, unitNow,
|
|
5418
|
+
reachable, isFull, slotRef, required, add, removeAt, dec, uncheck, clear, setQty, setPlan, setSize, selectKit, goStep, checkout,
|
|
5419
|
+
setBase: (n) => { basePrice = Number(n) || 0; emit('base'); },
|
|
5420
|
+
setCartHead: (fn) => { cartHead = typeof fn === 'function' ? fn : null; },
|
|
5421
|
+
refresh: () => emit('refresh'),
|
|
5422
|
+
};
|
|
5423
|
+
root.setAttribute('data-bb-ready', '');
|
|
5424
|
+
emit('init');
|
|
5425
|
+
});
|
|
5426
|
+
api.emit('gem:bundle:ready', { source: 'gem-runtime', count: boundBundles });`;
|
|
5427
|
+
}
|
|
5428
|
+
|
|
4387
5429
|
// ../../packages/gem-framework/src/runtime.ts
|
|
4388
5430
|
var COUNTDOWN_END_RUNTIME_LINES = [
|
|
4389
5431
|
" const COUNTDOWN_ISO = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/i;",
|
|
@@ -4576,6 +5618,16 @@ var offerEnginePluginRegistrationLines = [
|
|
|
4576
5618
|
" api.emit('gem:offer:ready', { source: 'gem-runtime' });",
|
|
4577
5619
|
" });"
|
|
4578
5620
|
];
|
|
5621
|
+
var reviewsPluginRegistrationLines = [
|
|
5622
|
+
" registerPlugin('reviews', ({ api }) => {",
|
|
5623
|
+
" " + reviewsEngineRuntimeScript(),
|
|
5624
|
+
" });"
|
|
5625
|
+
];
|
|
5626
|
+
var bundlePluginRegistrationLines = [
|
|
5627
|
+
" registerPlugin('bundle', ({ api }) => {",
|
|
5628
|
+
" " + bundleEngineRuntimeScript(),
|
|
5629
|
+
" });"
|
|
5630
|
+
];
|
|
4579
5631
|
|
|
4580
5632
|
// ../../packages/gem-framework/src/validation.ts
|
|
4581
5633
|
var validationRulesByCode = new Map(gemFrameworkValidationRules.map((rule) => [rule.code, rule]));
|
|
@@ -4673,7 +5725,7 @@ function validateGemFrameworkCss(css, artifactPath, options = {}) {
|
|
|
4673
5725
|
if (!TOKENIZED_PROPERTIES.includes(property)) continue;
|
|
4674
5726
|
if (isAllowedUntokenizedValue(property, value)) continue;
|
|
4675
5727
|
if (!value.includes("var(--gem-") && hasRawVisualValue(value)) {
|
|
4676
|
-
issues.push(ruleIssue("untokenized_visual_value", `${property}: ${value.trim()} should prefer semantic --gem-* tokens
|
|
5728
|
+
issues.push(ruleIssue("untokenized_visual_value", `${property}: ${value.trim()} should prefer semantic --gem-* tokens.${scrimHint(value)}`, artifactPath, {
|
|
4677
5729
|
property,
|
|
4678
5730
|
value: value.trim()
|
|
4679
5731
|
}));
|
|
@@ -4681,6 +5733,12 @@ function validateGemFrameworkCss(css, artifactPath, options = {}) {
|
|
|
4681
5733
|
}
|
|
4682
5734
|
return result(issues);
|
|
4683
5735
|
}
|
|
5736
|
+
function scrimHint(value) {
|
|
5737
|
+
const m = value.match(/rgba?\(\s*0[\s,]+0[\s,]+0\s*(?:,|\/)\s*([0-9.]+)(%?)\s*\)/i);
|
|
5738
|
+
if (!m) return "";
|
|
5739
|
+
const pct = Math.round(m[2] ? Number(m[1]) : Number(m[1]) * 100);
|
|
5740
|
+
return ` A dark overlay reads the scrim token: color-mix(in srgb, var(--gem-color-scrim, #000) ${pct}%, transparent).`;
|
|
5741
|
+
}
|
|
4684
5742
|
function validateGemFrameworkMarkup(markup, artifactPath, options = {}) {
|
|
4685
5743
|
const issues = validateSharedSafety(markup, artifactPath);
|
|
4686
5744
|
if (/\bgs-/.test(markup)) {
|