@gemboss/gem-framework 0.18.11 → 0.18.15

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.
@@ -1,5 +1,5 @@
1
1
  (() => {
2
- const config = {"planId":"gem-framework-package","base":"gem-native","dawnAdapter":false,"plugins":[{"key":"cart-drawer","loadStrategy":"interaction","dependsOn":[]},{"key":"mobile-menu","loadStrategy":"interaction","dependsOn":[]},{"key":"quantity-input","loadStrategy":"interaction","dependsOn":[]},{"key":"variant-selects","loadStrategy":"interaction","dependsOn":[]},{"key":"predictive-search","loadStrategy":"critical","dependsOn":[]},{"key":"localization","loadStrategy":"interaction","dependsOn":[]},{"key":"disclosure","loadStrategy":"interaction","dependsOn":[]},{"key":"tabs","loadStrategy":"interaction","dependsOn":[]},{"key":"media-gallery","loadStrategy":"viewport","dependsOn":[]},{"key":"carousel","loadStrategy":"viewport","dependsOn":[]},{"key":"hero-slideshow","loadStrategy":"idle","dependsOn":[]},{"key":"quick-view","loadStrategy":"interaction","dependsOn":[]},{"key":"promo-popup","loadStrategy":"idle","dependsOn":[]},{"key":"before_after","loadStrategy":"viewport","dependsOn":[]},{"key":"runtime-slot-loader","loadStrategy":"idle","dependsOn":[]},{"key":"sticky-atc","loadStrategy":"viewport","dependsOn":[]},{"key":"recently-viewed","loadStrategy":"idle","dependsOn":[]},{"key":"countdown","loadStrategy":"viewport","dependsOn":[]},{"key":"reveal-on-scroll","loadStrategy":"viewport","dependsOn":[]},{"key":"img-in","loadStrategy":"critical","dependsOn":[]},{"key":"seasonal-effects","loadStrategy":"idle","dependsOn":[]},{"key":"context-adapter","loadStrategy":"idle","dependsOn":[]},{"key":"offer-engine","loadStrategy":"interaction","dependsOn":[]}]};
2
+ const config = {"planId":"gem-framework-package","base":"gem-native","dawnAdapter":false,"plugins":[{"key":"cart-drawer","loadStrategy":"interaction","dependsOn":[]},{"key":"mobile-menu","loadStrategy":"interaction","dependsOn":[]},{"key":"quantity-input","loadStrategy":"interaction","dependsOn":[]},{"key":"variant-selects","loadStrategy":"interaction","dependsOn":[]},{"key":"predictive-search","loadStrategy":"critical","dependsOn":[]},{"key":"localization","loadStrategy":"interaction","dependsOn":[]},{"key":"disclosure","loadStrategy":"interaction","dependsOn":[]},{"key":"tabs","loadStrategy":"interaction","dependsOn":[]},{"key":"media-gallery","loadStrategy":"viewport","dependsOn":[]},{"key":"carousel","loadStrategy":"viewport","dependsOn":[]},{"key":"hero-slideshow","loadStrategy":"idle","dependsOn":[]},{"key":"quick-view","loadStrategy":"interaction","dependsOn":[]},{"key":"promo-popup","loadStrategy":"idle","dependsOn":[]},{"key":"before_after","loadStrategy":"viewport","dependsOn":[]},{"key":"runtime-slot-loader","loadStrategy":"idle","dependsOn":[]},{"key":"sticky-atc","loadStrategy":"viewport","dependsOn":[]},{"key":"recently-viewed","loadStrategy":"idle","dependsOn":[]},{"key":"countdown","loadStrategy":"viewport","dependsOn":[]},{"key":"reveal-on-scroll","loadStrategy":"viewport","dependsOn":[]},{"key":"img-in","loadStrategy":"critical","dependsOn":[]},{"key":"seasonal-effects","loadStrategy":"idle","dependsOn":[]},{"key":"context-adapter","loadStrategy":"idle","dependsOn":[]},{"key":"offer-engine","loadStrategy":"interaction","dependsOn":[]},{"key":"reviews","loadStrategy":"idle","dependsOn":[]}]};
3
3
  const target = window;
4
4
  const existing = target.GemTheme || {};
5
5
  const listeners = existing.listeners || {};
@@ -1054,6 +1054,660 @@
1054
1054
  })();
1055
1055
  api.emit('gem:offer:ready', { source: 'gem-runtime' });
1056
1056
  });
1057
+ registerPlugin('reviews', ({ api }) => {
1058
+ let boundReviews = 0;
1059
+ document.querySelectorAll('[data-rv-root]').forEach((root) => {
1060
+ if (!mark(root, 'reviews')) return;
1061
+ boundReviews += 1;
1062
+ const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };
1063
+ const all = (sel) => Array.from(root.querySelectorAll(sel));
1064
+ const isEl = (t) => t instanceof window.Element;
1065
+ const list = root.querySelector('[data-rv-list]');
1066
+ const items = list ? Array.from(list.querySelectorAll('[data-rv-item]')) : [];
1067
+ let pageSize = Math.max(1, parseInt((list && list.getAttribute('data-rv-page-size')) || '5', 10) || 5);
1068
+ const moreSize = () => Math.max(1, parseInt((list && list.getAttribute('data-rv-more-size')) || '', 10) || pageSize);
1069
+ const collapsedN = Math.max(0, parseInt((list && list.getAttribute('data-rv-collapsed')) || '0', 10) || 0);
1070
+ const st = { sort: '', star: 0, media: false, topic: '', q: '', country: '', page: 1, shown: pageSize, collapsed: collapsedN > 0, staged: 0 };
1071
+ const section = root.getAttribute('data-gem-section-library-id') || undefined;
1072
+ const sectionId = root.getAttribute('data-gem-section-id') || root.id || undefined;
1073
+ const pagesNavs = all('[data-rv-pages]');
1074
+ const pagesMq = pagesNavs.length && window.matchMedia ? window.matchMedia(pagesNavs[0].getAttribute('data-rv-pages-media') || 'all') : null;
1075
+ const usesPagesNow = () => pagesNavs.length > 0 && (!pagesMq || pagesMq.matches);
1076
+ const prevPages = all('[data-rv-prev]');
1077
+ const nextPages = all('[data-rv-next]');
1078
+ const none = root.querySelector('[data-rv-none]');
1079
+ const more = root.querySelector('[data-rv-more]');
1080
+ const expand = root.querySelector('[data-rv-expand]');
1081
+ const num = (el, a) => Number(el.getAttribute(a) || 0);
1082
+ const isOn = (el, a) => el.getAttribute(a) === '1';
1083
+ const byOrder = (a, b) => num(a, 'data-rv-order') - num(b, 'data-rv-order');
1084
+ const newest = (a, b) => (a.hasAttribute('data-rv-recent') ? num(a, 'data-rv-recent') - num(b, 'data-rv-recent') : byOrder(a, b));
1085
+ const leastHelpful = (a, b) => num(a, 'data-rv-helpful') - num(b, 'data-rv-helpful') || byOrder(a, b);
1086
+ const SORTS = {
1087
+ relevant: byOrder,
1088
+ featured: byOrder,
1089
+ recent: newest,
1090
+ oldest: (a, b) => (a.hasAttribute('data-rv-recent') ? num(b, 'data-rv-recent') - num(a, 'data-rv-recent') : byOrder(b, a)),
1091
+ verified: (a, b) => isOn(b, 'data-rv-verified') - isOn(a, 'data-rv-verified') || byOrder(a, b),
1092
+ highest: (a, b) => num(b, 'data-rv-rating') - num(a, 'data-rv-rating') || byOrder(a, b),
1093
+ lowest: (a, b) => num(a, 'data-rv-rating') - num(b, 'data-rv-rating') || byOrder(a, b),
1094
+ helpful: (a, b) => num(b, 'data-rv-helpful') - num(a, 'data-rv-helpful') || byOrder(a, b),
1095
+ leasthelpful: leastHelpful,
1096
+ unhelpful: leastHelpful,
1097
+ media: (a, b) => isOn(b, 'data-rv-media') - isOn(a, 'data-rv-media') || byOrder(a, b),
1098
+ 'media-only': newest,
1099
+ videos: (a, b) => isOn(b, 'data-rv-video') - isOn(a, 'data-rv-video') || newest(a, b),
1100
+ };
1101
+ const matches = (it) => {
1102
+ if (st.star && num(it, 'data-rv-rating') !== st.star) return false;
1103
+ if ((st.media || st.sort === 'media-only') && !isOn(it, 'data-rv-media')) return false;
1104
+ if (st.country && it.getAttribute('data-rv-country-code') !== st.country) return false;
1105
+ if (st.topic) {
1106
+ const tags = (it.getAttribute('data-rv-topics') || '').toLowerCase().split('|').filter(Boolean);
1107
+ if (tags.length ? !tags.includes(st.topic) : !(it.textContent || '').toLowerCase().includes(st.topic)) return false;
1108
+ }
1109
+ if (st.q && !(it.textContent || '').toLowerCase().includes(st.q)) return false;
1110
+ return true;
1111
+ };
1112
+ const filterKeys = ['star', 'media', 'topic', 'q', 'country'];
1113
+ const only = (k) => filterKeys.every((x) => x === k || !st[x]);
1114
+ // Counts as written ("5284") unless the list names a locale: data-rv-locale="en-US" → 5,284; "auto" → the page's lang.
1115
+ const locale = list ? list.getAttribute('data-rv-locale') : null;
1116
+ const fmtCount = (n) => {
1117
+ const v = Number(n) || 0;
1118
+ if (!locale) return String(v);
1119
+ try { return v.toLocaleString(locale === 'auto' ? (document.documentElement && document.documentElement.lang) || undefined : locale); } catch (err) { return String(v); }
1120
+ };
1121
+
1122
+ // Read more: the button opens the body of its scope; short text hides the button. Measured after every draw, on
1123
+ // resize and once the theme's fonts are in, because a review on a later page (or under a filter) measures 0.
1124
+ const moreScope = (b) => b.closest('[data-rv-more-scope]') || b.closest('[data-rv-item], [data-rv-scope]') || root;
1125
+ const syncReadMore = () => all('[data-rv-readmore]').forEach((b) => {
1126
+ const it = b.closest('[data-rv-item]');
1127
+ if ((it && it.hidden) || b.hasAttribute('data-rv-keep')) return;
1128
+ const body = moreScope(b).querySelector('[data-rv-body]');
1129
+ if (!body || !body.getClientRects().length) return;
1130
+ b.hidden = !body.hasAttribute('data-rv-open') && body.scrollHeight <= body.clientHeight + 2;
1131
+ });
1132
+
1133
+ // Search (and, with data-rv-mark-topic, topic) words marked in [data-rv-hl]; the original nodes come back on every draw.
1134
+ const hlOrig = new Map();
1135
+ const markClass = (list && list.getAttribute('data-rv-mark-class')) || '';
1136
+ const highlight = () => {
1137
+ const terms = [st.q, list && list.hasAttribute('data-rv-mark-topic') ? st.topic : ''].filter((x) => x && x.length > 1);
1138
+ const re = terms.length ? new RegExp('(' + terms.map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'gi') : null;
1139
+ all('[data-rv-hl]').forEach((el) => {
1140
+ if (!hlOrig.has(el)) { if (!re) return; hlOrig.set(el, Array.from(el.childNodes).map((n) => n.cloneNode(true))); }
1141
+ else el.replaceChildren(...hlOrig.get(el).map((n) => n.cloneNode(true)));
1142
+ if (!re) return;
1143
+ const walker = document.createTreeWalker(el, 4);
1144
+ const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
1145
+ nodes.forEach((n) => {
1146
+ const parts = (n.nodeValue || '').split(re);
1147
+ if (parts.length < 2) return;
1148
+ const frag = document.createDocumentFragment();
1149
+ 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); });
1150
+ n.replaceWith(frag);
1151
+ });
1152
+ });
1153
+ };
1154
+
1155
+ const drawPages = (nav, pages) => {
1156
+ const cur = st.page;
1157
+ const win = Number(nav.getAttribute('data-rv-page-window') || 0);
1158
+ let seq;
1159
+ if (win > 0) {
1160
+ // A window of N numbers around the current page, no … (Yotpo: 1 2 3 4 5).
1161
+ const start = Math.max(1, Math.min(cur - Math.floor(win / 2), pages - win + 1));
1162
+ seq = Array.from({ length: Math.min(win, pages) }, (_, k) => start + k);
1163
+ } else {
1164
+ // 1 … n around the current page (Judge.me: 1 2 3 4 … 18).
1165
+ const want = new Set([1, pages, cur - 1, cur, cur + 1].filter((p) => p >= 1 && p <= pages));
1166
+ if (cur <= 3) [2, 3, 4].forEach((p) => { if (p <= pages) want.add(p); });
1167
+ seq = Array.from(want).sort((a, b) => a - b);
1168
+ }
1169
+ const tpl = nav.querySelector('[data-rv-page-tpl]');
1170
+ const anchor = nextPages.find((b) => nav.contains(b)) || null;
1171
+ nav.querySelectorAll('[data-rv-page]:not([data-rv-page-tpl]), [data-rv-gap]').forEach((e) => e.remove());
1172
+ let last = 0;
1173
+ seq.forEach((p) => {
1174
+ if (!win && p - last > 1) {
1175
+ const g = document.createElement('span');
1176
+ g.setAttribute('data-rv-gap', ''); g.setAttribute('aria-hidden', 'true');
1177
+ g.className = (tpl && tpl.getAttribute('data-rv-gap-class')) || ''; g.textContent = '…';
1178
+ nav.insertBefore(g, anchor);
1179
+ }
1180
+ const b = document.createElement('button');
1181
+ b.type = 'button'; b.className = tpl ? tpl.className : ''; b.textContent = String(p);
1182
+ b.setAttribute('data-rv-page', String(p)); b.setAttribute('aria-label', 'Page ' + p);
1183
+ if (p === cur) b.setAttribute('aria-current', 'page');
1184
+ nav.insertBefore(b, anchor);
1185
+ last = p;
1186
+ });
1187
+ };
1188
+
1189
+ // Active-filter chips: one per filter in force, a click removes that filter.
1190
+ const chipsBox = root.querySelector('[data-rv-chips]');
1191
+ const chipTpl = root.querySelector('[data-rv-chip-tpl]');
1192
+ const starText = (v) => { const e = root.querySelector('[data-rv-star="' + v + '"][data-rv-star-text]'); return e ? e.getAttribute('data-rv-star-text') || '' : ''; };
1193
+ 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; };
1194
+ const drawChips = () => {
1195
+ if (!chipsBox || !chipTpl) return;
1196
+ chipsBox.replaceChildren();
1197
+ const add = (kind, label, star) => {
1198
+ const b = chipTpl.cloneNode(true);
1199
+ b.removeAttribute('data-rv-chip-tpl'); b.hidden = false;
1200
+ const t = b.querySelector('[data-rv-chip-text]'); if (t) t.textContent = label;
1201
+ const s = b.querySelector('[data-rv-chip-star]'); if (s) s.hidden = !star;
1202
+ const v = b.querySelector('[data-rv-chip-value]'); if (v) v.textContent = star || '';
1203
+ b.setAttribute('data-rv-chip', kind);
1204
+ b.setAttribute('aria-label', ((b.getAttribute('data-rv-remove') || 'Remove') + ' ' + label + ' ' + (star || '')).trim());
1205
+ chipsBox.appendChild(b);
1206
+ };
1207
+ if (st.star) add('star', chipsBox.getAttribute('data-rv-chip-rating') || '', starText(st.star) || String(st.star));
1208
+ if (st.media) add('media', chipsBox.getAttribute('data-rv-chip-media') || '', '');
1209
+ if (st.country) add('country', countryText(st.country), '');
1210
+ chipsBox.hidden = !chipsBox.childElementCount;
1211
+ };
1212
+
1213
+ const render = (focusFirstNew, scroll) => {
1214
+ if (!list) return;
1215
+ const sorted = st.sort && SORTS[st.sort] ? items.slice().sort(SORTS[st.sort]) : items;
1216
+ if (st.sort) sorted.forEach((it) => list.appendChild(it));
1217
+ const hit = sorted.filter(matches);
1218
+ const hitSet = new Set(hit);
1219
+ const total = hit.length;
1220
+ const usesPages = !st.collapsed && usesPagesNow();
1221
+ const pages = Math.max(1, Math.ceil(total / pageSize));
1222
+ if (st.page > pages) st.page = pages;
1223
+ const from = usesPages ? (st.page - 1) * pageSize : 0;
1224
+ const to = st.collapsed ? collapsedN : usesPages ? from + pageSize : st.shown;
1225
+ let firstNew = null;
1226
+ items.forEach((it) => { if (!hitSet.has(it)) it.hidden = true; });
1227
+ hit.forEach((it, i) => {
1228
+ const show = i >= from && i < to;
1229
+ if (show && it.hidden && !firstNew && focusFirstNew) firstNew = it;
1230
+ it.hidden = !show;
1231
+ });
1232
+ if (none) none.hidden = total > 0;
1233
+ if (more) more.hidden = st.collapsed || usesPages || to >= total;
1234
+ if (expand) expand.hidden = !st.collapsed || total <= collapsedN;
1235
+ if (list.hasAttribute('data-rv-collapsed')) root.toggleAttribute('data-rv-expanded', !st.collapsed);
1236
+ const filtered = filterKeys.some((k) => !!st[k]);
1237
+ // Found: the store's own count when nothing filters; the star row's (or topic chip's) count when only that filters
1238
+ // (the breakdown of the WHOLE store, as review widgets show it); otherwise what matched on the page.
1239
+ const starEl = st.star ? root.querySelector('[data-rv-star="' + st.star + '"][data-rv-star-count]') : null;
1240
+ const topicEl = st.topic ? all('[data-rv-topic][data-rv-topic-count]').find((x) => (x.getAttribute('data-rv-topic') || '').toLowerCase() === st.topic) : null;
1241
+ const found = !filtered ? Number(list.getAttribute('data-rv-total') || total)
1242
+ : starEl && only('star') ? Number(starEl.getAttribute('data-rv-star-count'))
1243
+ : topicEl && only('topic') ? Number(topicEl.getAttribute('data-rv-topic-count'))
1244
+ : total;
1245
+ const shown = Math.max(0, Math.min(to, total) - from);
1246
+ root.toggleAttribute('data-rv-filtered', filtered);
1247
+ root.setAttribute('data-rv-f-star', String(st.star || ''));
1248
+ root.setAttribute('data-rv-f-topic', st.topic);
1249
+ root.setAttribute('data-rv-f-country', st.country);
1250
+ root.toggleAttribute('data-rv-f-media', st.media);
1251
+ all('[data-rv-star-value]').forEach((e) => (e.querySelector('.gem-stars') || e).style.setProperty('--gem-stars-value', String(st.star || 0)));
1252
+ all('[data-rv-star-text]:not([data-rv-star])').forEach((e) => { e.textContent = String(st.star || ''); });
1253
+ all('[data-rv-star-label]').forEach((e) => { e.textContent = st.star ? starText(st.star) : ''; });
1254
+ all('[data-rv-country-label]').forEach((e) => { e.textContent = st.country ? countryText(st.country) : ''; });
1255
+ 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; });
1256
+ 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)); });
1257
+ all('[data-rv-status]').forEach((s) => {
1258
+ const tpl = (found === 1 && s.getAttribute('data-rv-status-one')) || s.getAttribute('data-rv-status-template') || '';
1259
+ s.textContent = tpl && filtered ? tpl.replace('{count}', fmtCount(found)).replace('{shown}', String(shown)).replace('{total}', String(total)) : '';
1260
+ });
1261
+ all('[data-rv-hl-topic]').forEach((m) => m.toggleAttribute('data-rv-hl-on', !!st.topic && (m.getAttribute('data-rv-hl-topic') || '').toLowerCase() === st.topic));
1262
+ highlight();
1263
+ drawChips();
1264
+ pagesNavs.forEach((nav) => {
1265
+ if (usesPages && nav.querySelector('[data-rv-page-tpl]')) drawPages(nav, pages);
1266
+ nav.hidden = !usesPages || pages <= 1;
1267
+ });
1268
+ 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)); });
1269
+ prevPages.forEach((b) => { b.toggleAttribute('disabled', st.page <= 1); b.setAttribute('aria-disabled', String(st.page <= 1)); });
1270
+ nextPages.forEach((b) => { b.toggleAttribute('disabled', st.page >= pages); b.setAttribute('aria-disabled', String(st.page >= pages)); });
1271
+ syncReadMore();
1272
+ if (firstNew) {
1273
+ const f = firstNew.querySelector('h3, [tabindex], a, button') || firstNew;
1274
+ if (f === firstNew && !f.hasAttribute('tabindex')) f.setAttribute('tabindex', '-1');
1275
+ f.focus({ preventScroll: false });
1276
+ }
1277
+ if (scroll) { const t = root.querySelector('[data-rv-scroll-to]') || list; if (t.scrollIntoView) t.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
1278
+ const detail = { source: 'gem-runtime', section, sectionId, total, found, shown, page: st.page, pages, filtered, star: st.star, sort: st.sort };
1279
+ if (typeof window.CustomEvent === 'function') root.dispatchEvent(new window.CustomEvent('gem:reviews:render', { detail }));
1280
+ api.emit('gem:reviews:render', detail);
1281
+ };
1282
+ const reset = () => { st.page = 1; st.shown = pageSize; };
1283
+
1284
+ // Menus: one open at a time; Escape, a click outside, or choosing an item closes it.
1285
+ const menus = all('[data-rv-menu]');
1286
+ const panelOf = (b) => root.querySelector('[data-rv-menu-panel="' + b.getAttribute('data-rv-menu') + '"]');
1287
+ const closeMenus = (except) => menus.forEach((b) => {
1288
+ if (b === except) return;
1289
+ b.setAttribute('aria-expanded', 'false');
1290
+ const p = panelOf(b); if (p) p.hidden = true;
1291
+ });
1292
+ // A choice made inside a menu panel closes it, and focus goes back to the menu's button (the section behaviors left
1293
+ // 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
1294
+ // that media query matches; sort and Show N always close.
1295
+ const closeAfterChoice = (el, always) => {
1296
+ const p = el.closest('[data-rv-menu-panel]');
1297
+ if (!p) { if (always) closeMenus(); return; }
1298
+ const keep = p.getAttribute('data-rv-menu-keep');
1299
+ if (!always && keep && window.matchMedia && window.matchMedia(keep).matches) return;
1300
+ const owner = menus.find((b) => panelOf(b) === p);
1301
+ closeMenus();
1302
+ if (owner && p.contains(document.activeElement)) owner.focus({ preventScroll: true });
1303
+ };
1304
+ menus.forEach((b) => on(b, 'click', (e) => {
1305
+ e.preventDefault();
1306
+ const open = b.getAttribute('aria-expanded') !== 'true';
1307
+ closeMenus(b);
1308
+ b.setAttribute('aria-expanded', String(open));
1309
+ const p = panelOf(b);
1310
+ 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 }); } }
1311
+ }));
1312
+ if (menus.length) {
1313
+ on(window, 'keydown', (e) => {
1314
+ if (e.key !== 'Escape') return;
1315
+ const openMenu = menus.find((x) => x.getAttribute('aria-expanded') === 'true');
1316
+ if (openMenu) { closeMenus(); openMenu.focus(); }
1317
+ });
1318
+ on(window, 'click', (e) => {
1319
+ if (!(e.target instanceof window.Node)) return;
1320
+ if (menus.some((b) => b.contains(e.target)) || all('[data-rv-menu-panel]').some((p) => p.contains(e.target))) return;
1321
+ closeMenus();
1322
+ });
1323
+ }
1324
+
1325
+ // Every filter control reads its state from st (buttons, menu items, chips, Clear).
1326
+ const syncStars = () => all('[data-rv-star]').forEach((x) => {
1327
+ const yes = Number(x.getAttribute('data-rv-star')) === st.star;
1328
+ if (x.hasAttribute('aria-checked')) x.setAttribute('aria-checked', String(yes));
1329
+ else if (x.hasAttribute('aria-current')) x.setAttribute('aria-current', String(yes && st.star > 0));
1330
+ else x.setAttribute('aria-pressed', String(yes && st.star > 0));
1331
+ });
1332
+ const syncControls = () => {
1333
+ syncStars();
1334
+ all('[data-rv-media-filter]').forEach((x) => { if (x.matches('input')) x.checked = st.media; else x.setAttribute('aria-pressed', String(st.media)); });
1335
+ all('[data-rv-country]').forEach((x) => x.setAttribute('aria-checked', String((x.getAttribute('data-rv-country') || '') === st.country)));
1336
+ all('[data-rv-topic]').forEach((x) => x.setAttribute('aria-pressed', String(!!st.topic && (x.getAttribute('data-rv-topic') || '').toLowerCase() === st.topic)));
1337
+ };
1338
+
1339
+ // Toggles: an inline panel / region / collapse opened by its button, not closed by a click outside. Closing a region
1340
+ // that has data-rv-region-clear drops the filters inside it (closing the filter row = no star filter; closing the
1341
+ // search box = no search), as Judge.me does.
1342
+ const PANEL_ATTRS = ['data-rv-toggle-panel', 'data-rv-region', 'data-rv-collapse-panel'];
1343
+ 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));
1344
+ const toggleId = (t) => (t.hasAttribute('data-rv-toggle') ? t.getAttribute('data-rv-toggle') : t.getAttribute('data-rv-collapse')) || '';
1345
+ const setToggle = (id, open) => {
1346
+ const ps = togglePanels(id);
1347
+ if (!ps.length) return;
1348
+ const was = ps.some((p) => !p.hidden);
1349
+ ps.forEach((p) => { p.hidden = !open; });
1350
+ all('[data-rv-toggle], [data-rv-collapse]').forEach((t) => { if (toggleId(t) === id) t.setAttribute('aria-expanded', String(open)); });
1351
+ const r = !open && was ? ps.find((p) => p.hasAttribute('data-rv-region-clear')) : null;
1352
+ if (!r) return;
1353
+ let changed = false;
1354
+ if (r.querySelector('[data-rv-star]') && st.star) { st.star = 0; changed = true; syncControls(); }
1355
+ r.querySelectorAll('input[data-rv-search]').forEach((x) => { if (x.value || st.q) { x.value = ''; st.q = ''; changed = true; } });
1356
+ if (changed) { reset(); render(); }
1357
+ };
1358
+ all('[data-rv-toggle], [data-rv-collapse]').forEach((t) => on(t, 'click', (e) => {
1359
+ e.preventDefault();
1360
+ const ps = togglePanels(toggleId(t));
1361
+ setToggle(toggleId(t), ps.length ? ps.every((p) => p.hidden) : t.getAttribute('aria-expanded') !== 'true');
1362
+ }));
1363
+
1364
+ const setSort = (key, label) => {
1365
+ st.sort = key; reset();
1366
+ all('[data-rv-sort-option]').forEach((o) => o.setAttribute('aria-checked', String(o.getAttribute('data-rv-sort-option') === key)));
1367
+ all('select[data-rv-sort]').forEach((s) => { if (s.value !== key) s.value = key; });
1368
+ if (label) {
1369
+ all('[data-rv-sort-label]').forEach((l) => { l.textContent = label; });
1370
+ all('[data-rv-menu][data-rv-sort-aria]').forEach((b) => b.setAttribute('aria-label', (b.getAttribute('data-rv-sort-aria') || '').replace('{label}', label)));
1371
+ }
1372
+ render();
1373
+ };
1374
+ all('select[data-rv-sort]').forEach((s) => on(s, 'change', () => setSort(s.value, '')));
1375
+ all('[data-rv-sort-option]').forEach((o) => on(o, 'click', (e) => {
1376
+ e.preventDefault();
1377
+ setSort(o.getAttribute('data-rv-sort-option') || '', (o.getAttribute('data-rv-sort-text') || o.textContent || '').trim());
1378
+ closeAfterChoice(o, true);
1379
+ }));
1380
+ const starKeep = root.getAttribute('data-rv-star-keep');
1381
+ all('[data-rv-star]').forEach((b) => on(b, 'click', (e) => {
1382
+ e.preventDefault();
1383
+ const v = Number(b.getAttribute('data-rv-star')) || 0;
1384
+ const keep = starKeep !== null && (starKeep !== 'menu' || !!b.closest('[data-rv-menu-panel]'));
1385
+ st.star = keep ? v : st.star === v ? 0 : v; reset();
1386
+ syncControls();
1387
+ if (b.hasAttribute('data-rv-open-region')) setToggle(b.getAttribute('data-rv-open-region') || '', true);
1388
+ closeAfterChoice(b);
1389
+ render();
1390
+ }));
1391
+ all('[data-rv-media-filter]').forEach((m) => on(m, m.matches('input') ? 'change' : 'click', (e) => {
1392
+ if (!m.matches('input')) e.preventDefault();
1393
+ st.media = m.matches('input') ? m.checked : !st.media; reset();
1394
+ syncControls();
1395
+ render();
1396
+ }));
1397
+ all('[data-rv-country]').forEach((c) => on(c, 'click', (e) => {
1398
+ e.preventDefault();
1399
+ st.country = c.getAttribute('data-rv-country') || ''; reset();
1400
+ syncControls();
1401
+ closeAfterChoice(c);
1402
+ render();
1403
+ }));
1404
+ all('[data-rv-topic]').forEach((t) => on(t, 'click', (e) => {
1405
+ e.preventDefault();
1406
+ const v = (t.getAttribute('data-rv-topic') || '').toLowerCase();
1407
+ st.topic = st.topic === v ? '' : v; reset();
1408
+ syncControls();
1409
+ render();
1410
+ }));
1411
+ all('input[data-rv-search]').forEach((s) => {
1412
+ let wait = 0;
1413
+ const apply = () => { st.q = s.value.trim().toLowerCase(); reset(); render(); };
1414
+ on(s, 'input', () => { window.clearTimeout(wait); wait = window.setTimeout(apply, 180); });
1415
+ on(s.form || s, 'submit', (e) => { e.preventDefault(); apply(); });
1416
+ });
1417
+ // Clear: data-rv-clear="star" | "topic" drops that filter only (the "× Clear" beside a label); empty drops them all.
1418
+ all('[data-rv-clear]').forEach((c) => on(c, 'click', (e) => {
1419
+ e.preventDefault();
1420
+ const scope = c.getAttribute('data-rv-clear') || '';
1421
+ if (!scope || scope === 'star') st.star = 0;
1422
+ if (!scope || scope === 'topic') st.topic = '';
1423
+ if (!scope) { st.media = false; st.q = ''; st.country = ''; all('input[data-rv-search]').forEach((x) => { x.value = ''; }); }
1424
+ reset();
1425
+ syncControls();
1426
+ render();
1427
+ }));
1428
+ on(chipsBox, 'click', (e) => {
1429
+ const b = isEl(e.target) ? e.target.closest('[data-rv-chip]') : null;
1430
+ if (!b) return;
1431
+ e.preventDefault();
1432
+ const k = b.getAttribute('data-rv-chip');
1433
+ if (k === 'star') st.star = 0;
1434
+ if (k === 'media') st.media = false;
1435
+ if (k === 'country') st.country = '';
1436
+ reset(); syncControls(); render();
1437
+ });
1438
+ // A filter sheet with Apply: its star chips only stage a choice, Apply filters, Reset un-stages.
1439
+ const syncStage = () => all('[data-rv-stage]').forEach((c) => c.setAttribute('aria-pressed', String(Number(c.getAttribute('data-rv-stage')) === st.staged)));
1440
+ all('[data-rv-stage]').forEach((c) => on(c, 'click', (e) => { e.preventDefault(); st.staged = Number(c.getAttribute('data-rv-stage')) || 0; syncStage(); }));
1441
+ all('[data-rv-reset]').forEach((c) => on(c, 'click', (e) => { e.preventDefault(); st.staged = 0; syncStage(); }));
1442
+ all('[data-rv-apply]').forEach((c) => on(c, 'click', (e) => {
1443
+ e.preventDefault();
1444
+ st.star = st.staged; reset(); syncControls(); render();
1445
+ const d = c.closest('dialog'); if (d && d.open) d.close();
1446
+ }));
1447
+
1448
+ on(more, 'click', (e) => { e.preventDefault(); st.shown += moreSize(); render(true); });
1449
+ on(expand, 'click', (e) => { e.preventDefault(); st.collapsed = false; st.page = 1; render(); });
1450
+ pagesNavs.forEach((nav) => on(nav, 'click', (e) => {
1451
+ const b = isEl(e.target) ? e.target.closest('[data-rv-page]') : null;
1452
+ if (!b || b.hasAttribute('data-rv-page-tpl')) return;
1453
+ e.preventDefault(); st.page = Number(b.getAttribute('data-rv-page')) || 1; render(false, true);
1454
+ }));
1455
+ prevPages.forEach((b) => on(b, 'click', (e) => { e.preventDefault(); if (st.page > 1) { st.page -= 1; render(false, true); } }));
1456
+ nextPages.forEach((b) => on(b, 'click', (e) => { e.preventDefault(); st.page += 1; render(false, true); }));
1457
+ all('[data-rv-per-page-option]').forEach((o) => on(o, 'click', (e) => {
1458
+ e.preventDefault();
1459
+ pageSize = Math.max(1, Number(o.getAttribute('data-rv-per-page-option')) || pageSize); reset();
1460
+ all('[data-rv-per-page-option]').forEach((x) => x.setAttribute('aria-checked', String(x === o)));
1461
+ all('[data-rv-per-page-label]').forEach((l) => { l.textContent = String(pageSize); });
1462
+ closeAfterChoice(o, true);
1463
+ render();
1464
+ }));
1465
+ if (pagesMq) on(pagesMq, 'change', () => { reset(); render(); });
1466
+
1467
+ all('[data-rv-readmore]').forEach((b) => on(b, 'click', (e) => {
1468
+ e.preventDefault();
1469
+ const body = moreScope(b).querySelector('[data-rv-body]');
1470
+ const open = b.getAttribute('aria-expanded') !== 'true';
1471
+ b.setAttribute('aria-expanded', String(open));
1472
+ if (body) body.toggleAttribute('data-rv-open', open);
1473
+ const less = b.getAttribute('data-rv-less'); const moreText = b.getAttribute('data-rv-more-text');
1474
+ if (less && moreText) b.textContent = open ? less : moreText;
1475
+ }));
1476
+ let resizeWait = 0;
1477
+ on(window, 'resize', () => { window.clearTimeout(resizeWait); resizeWait = window.setTimeout(syncReadMore, 150); });
1478
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(syncReadMore).catch(() => {});
1479
+ on(root, 'gem:reviews:refresh', () => render());
1480
+
1481
+ // Tracks (a strip of photos or cards): back / forward one card; the buttons switch off at either end. A looping
1482
+ // track (data-rv-loop) is the section's own carousel and is left to it.
1483
+ all('[data-rv-track]').forEach((track) => {
1484
+ if (track.hasAttribute('data-rv-loop')) return;
1485
+ const scope = track.closest('[data-rv-carousel]') || root;
1486
+ const prev = scope.querySelector('[data-rv-slide-prev]'); const next = scope.querySelector('[data-rv-slide-next]');
1487
+ const step = () => { const c = track.firstElementChild; const gap = parseFloat(window.getComputedStyle(track).columnGap || '0') || 0; return c ? c.getBoundingClientRect().width + gap : track.clientWidth; };
1488
+ 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); };
1489
+ on(prev, 'click', (e) => { e.preventDefault(); track.scrollBy({ left: -step(), behavior: 'smooth' }); });
1490
+ on(next, 'click', (e) => { e.preventDefault(); track.scrollBy({ left: step(), behavior: 'smooth' }); });
1491
+ on(track, 'scroll', sync, { passive: true });
1492
+ sync();
1493
+ });
1494
+
1495
+ // Filter sheet (phones): a button opens dialog[data-rv-sheet], delegated on the root so copies made after binding
1496
+ // (an inline card a section repeats) open it too; closing returns focus to that button.
1497
+ let sheetFrom = null;
1498
+ on(root, 'click', (e) => {
1499
+ const b = isEl(e.target) ? e.target.closest('[data-rv-sheet-open]') : null;
1500
+ if (!b) return;
1501
+ const d = root.querySelector('dialog[data-rv-sheet="' + b.getAttribute('data-rv-sheet-open') + '"]');
1502
+ if (!d || typeof d.showModal !== 'function') return;
1503
+ e.preventDefault(); sheetFrom = b; st.staged = st.star; syncStage(); closeMenus(); if (!d.open) d.showModal();
1504
+ });
1505
+ all('dialog[data-rv-sheet]').forEach((d) => {
1506
+ d.querySelectorAll('[data-rv-sheet-close]').forEach((c) => on(c, 'click', (e) => {
1507
+ e.preventDefault(); if (d.open) d.close();
1508
+ if (c.hasAttribute('data-rv-scroll-list') && list) { sheetFrom = null; list.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
1509
+ }));
1510
+ on(d, 'click', (e) => {
1511
+ if (e.target !== d || !d.hasAttribute('data-rv-backdrop-close')) return;
1512
+ const r = d.getBoundingClientRect();
1513
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) d.close();
1514
+ });
1515
+ on(d, 'close', () => { const opener = sheetFrom && sheetFrom.isConnected ? sheetFrom : null; sheetFrom = null; if (opener) opener.focus({ preventScroll: true }); });
1516
+ });
1517
+
1518
+ // Helpful: once per review (or per [data-rv-vote-scope]: a question, an answer), counted in place.
1519
+ all('[data-rv-vote]').forEach((b) => on(b, 'click', (e) => {
1520
+ e.preventDefault();
1521
+ const scope = b.closest('[data-rv-item], [data-rv-vote-scope]');
1522
+ if (!scope || scope.hasAttribute('data-rv-voted')) return;
1523
+ scope.setAttribute('data-rv-voted', b.getAttribute('data-rv-vote') || '');
1524
+ b.setAttribute('aria-pressed', 'true');
1525
+ const c = b.querySelector('[data-rv-vote-count]'); if (c) c.textContent = String((Number(c.textContent) || 0) + 1);
1526
+ scope.querySelectorAll('[data-rv-vote]').forEach((x) => x.setAttribute('disabled', ''));
1527
+ const detail = { source: 'gem-runtime', section, sectionId, review: scope.id, value: b.getAttribute('data-rv-vote') };
1528
+ api.emit('gem:review:vote', detail);
1529
+ if (typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:review:vote', { detail }));
1530
+ }));
1531
+
1532
+ // Lightbox. The set it walks: every [data-rv-zoom] of the section in page order (data-rv-lb-dedupe: one entry per
1533
+ // photo), the opener's [data-rv-group], the opener's review (data-rv-lb-scope="review"), or the opener's own
1534
+ // data-rv-media-list. Ends: hide the arrow (default), disable it, or wrap. The review the photo belongs to is copied
1535
+ // beside it from [data-rv-lb-part]; Escape closes (native dialog) and focus returns to the opener.
1536
+ const lb = root.querySelector('dialog[data-rv-lightbox]');
1537
+ const zooms = all('[data-rv-zoom]');
1538
+ const lbScoped = !!lb && lb.getAttribute('data-rv-lb-scope') === 'review';
1539
+ const lbEnds = lb ? lb.getAttribute('data-rv-lb-ends') || (lbScoped ? 'disable' : 'hide') : 'hide';
1540
+ const lbVotes = !lb || lb.getAttribute('data-rv-lb-votes') !== 'off';
1541
+ const entryOf = (z) => {
1542
+ const img = z.querySelector('img');
1543
+ return { el: z, src: z.getAttribute('data-rv-src') || '', kind: z.getAttribute('data-rv-kind') || '', poster: z.getAttribute('data-rv-poster') || '',
1544
+ alt: z.getAttribute('data-rv-alt') || '', thumb: z.getAttribute('data-rv-thumb') || (img && img.getAttribute('src')) || z.getAttribute('data-rv-src') || '',
1545
+ review: z.getAttribute('data-rv-review') || '' };
1546
+ };
1547
+ const lbAll = [];
1548
+ const seenSrc = new Set();
1549
+ zooms.forEach((z) => {
1550
+ const en = entryOf(z);
1551
+ if (lb && lb.hasAttribute('data-rv-lb-dedupe')) { if (seenSrc.has(en.src)) return; seenSrc.add(en.src); }
1552
+ lbAll.push(en);
1553
+ });
1554
+ const setFor = (z) => {
1555
+ const raw = z.getAttribute('data-rv-media-list');
1556
+ if (raw !== null) {
1557
+ let parsed = [];
1558
+ try { parsed = JSON.parse(raw || '[]'); } catch (err) { parsed = []; }
1559
+ 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') || '' }));
1560
+ }
1561
+ const g = z.getAttribute('data-rv-group');
1562
+ if (g) return lbAll.filter((x) => x.el.getAttribute('data-rv-group') === g);
1563
+ if (lbScoped) {
1564
+ const r = z.getAttribute('data-rv-review') || '';
1565
+ const own = lbAll.filter((x) => x.review === r && !x.el.closest('[data-rv-carousel]'));
1566
+ return own.length ? own : [entryOf(z)];
1567
+ }
1568
+ return lbAll;
1569
+ };
1570
+ const indexIn = (set, z) => {
1571
+ const i = set.findIndex((x) => x.el === z);
1572
+ if (i >= 0) return i;
1573
+ const src = z.getAttribute('data-rv-src');
1574
+ const j = set.findIndex((x) => x.src === src && x.review === (z.getAttribute('data-rv-review') || ''));
1575
+ return Math.max(0, j >= 0 ? j : set.findIndex((x) => x.src === src));
1576
+ };
1577
+ let lbSet = lbAll; let lbAt = 0; let lbFrom = null; let stripFor = null;
1578
+ const strip = lb ? lb.querySelector('[data-rv-lb-thumbs]') : null;
1579
+ const buildStrip = () => {
1580
+ if (!strip || stripFor === lbSet) return;
1581
+ stripFor = lbSet;
1582
+ strip.replaceChildren();
1583
+ if (lbSet.length > 1) lbSet.forEach((x, k) => {
1584
+ const b = document.createElement('button');
1585
+ b.type = 'button'; b.className = strip.getAttribute('data-rv-thumb-class') || ''; b.setAttribute('data-rv-lb-thumb', String(k));
1586
+ b.setAttribute('aria-label', (strip.getAttribute('data-rv-thumb-label') || 'Media {n} of {total}').replace('{n}', String(k + 1)).replace('{total}', String(lbSet.length)));
1587
+ const img = document.createElement('img'); img.src = x.thumb; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
1588
+ b.appendChild(img); strip.appendChild(b);
1589
+ });
1590
+ strip.hidden = lbSet.length <= 1;
1591
+ };
1592
+ const lbShow = (i) => {
1593
+ if (!lb) return;
1594
+ const n = lbSet.length;
1595
+ lbAt = lbEnds === 'wrap' && n ? ((i % n) + n) % n : Math.max(0, Math.min(n - 1, i));
1596
+ const x = lbSet[lbAt];
1597
+ lb.toggleAttribute('data-rv-lb-empty', !n);
1598
+ lb.toggleAttribute('data-rv-lb-multi', n > 1);
1599
+ const media = lb.querySelector('[data-rv-lb-media]');
1600
+ if (media) {
1601
+ media.replaceChildren();
1602
+ 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); }
1603
+ else if (x) { const img = document.createElement('img'); img.src = x.src; img.alt = x.alt; media.appendChild(img); }
1604
+ }
1605
+ const stage = lb.querySelector('[data-rv-lb-stage]');
1606
+ if (stage && x) {
1607
+ const img = x.el && x.el.querySelector('img');
1608
+ let bg = img ? img.currentSrc || img.src : '';
1609
+ if (!bg) { try { bg = new URL(x.src, window.location.href).href; } catch (err) { bg = x.src; } }
1610
+ stage.style.setProperty('--rv-lb-bg', 'url("' + String(bg).replace(/"/g, '%22') + '")');
1611
+ }
1612
+ const panel = lb.querySelector('[data-rv-lb-review]');
1613
+ if (panel) {
1614
+ panel.replaceChildren();
1615
+ // A review with no photos (an empty media list) still shows its review: the opener names it.
1616
+ const rid = x ? x.review : (lbFrom && lbFrom.getAttribute && lbFrom.getAttribute('data-rv-review')) || '';
1617
+ const item = rid ? items.find((it) => it.id === rid) : null;
1618
+ if (item) item.querySelectorAll('[data-rv-lb-part]').forEach((part) => {
1619
+ const copy = part.cloneNode(true);
1620
+ copy.removeAttribute('id'); copy.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
1621
+ copy.querySelectorAll('input, label[for], [data-rv-readmore]').forEach((el) => el.remove());
1622
+ 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'); });
1623
+ copy.querySelectorAll('[data-rv-zoom-alias]').forEach((t) => { if (lbSet === lbAll && t.getAttribute('data-rv-zoom-alias') === String(lbAt)) t.setAttribute('aria-current', 'true'); });
1624
+ if (!lbVotes) copy.querySelectorAll('button').forEach((el) => { el.setAttribute('tabindex', '-1'); el.setAttribute('disabled', ''); });
1625
+ panel.appendChild(copy);
1626
+ });
1627
+ panel.hidden = !item;
1628
+ lb.toggleAttribute('data-rv-lb-has-review', !!item);
1629
+ }
1630
+ const peek = lb.querySelector('[data-rv-lb-peek]');
1631
+ if (peek) {
1632
+ peek.replaceChildren();
1633
+ const nx = lbSet[lbAt + 1];
1634
+ const url = nx ? nx.poster || (nx.kind === 'video' ? '' : nx.src) : '';
1635
+ if (url) { const img = document.createElement('img'); img.src = url; img.alt = ''; peek.appendChild(img); }
1636
+ peek.hidden = !nx;
1637
+ }
1638
+ const count = lb.querySelector('[data-rv-lb-count]'); if (count) count.textContent = n ? (lbAt + 1) + ' / ' + n : '';
1639
+ const pv = lb.querySelector('[data-rv-lb-prev]'); const nxb = lb.querySelector('[data-rv-lb-next]');
1640
+ [[pv, lbAt <= 0], [nxb, lbAt >= n - 1]].forEach(([b, atEnd]) => {
1641
+ if (!b) return;
1642
+ if (lbEnds === 'wrap') { b.hidden = n < 2; b.disabled = false; }
1643
+ else if (lbEnds === 'disable') { b.hidden = n < 2; b.disabled = atEnd; }
1644
+ else { b.hidden = atEnd; b.disabled = false; }
1645
+ });
1646
+ if (strip) Array.from(strip.children).forEach((t, k) => {
1647
+ if (k !== lbAt) { t.removeAttribute('aria-current'); return; }
1648
+ t.setAttribute('aria-current', 'true');
1649
+ if (strip.scrollTo) strip.scrollTo({ left: t.offsetLeft - (strip.clientWidth - t.offsetWidth) / 2, behavior: lb.open ? 'smooth' : 'auto' });
1650
+ });
1651
+ };
1652
+ const lbOpen = (e, set, at, opener) => {
1653
+ if (!lb || typeof lb.showModal !== 'function') return;
1654
+ e.preventDefault();
1655
+ closeMenus();
1656
+ lbFrom = opener; lbSet = set; buildStrip();
1657
+ lb.querySelectorAll('[data-rv-toggle]').forEach((t) => setToggle(toggleId(t), false));
1658
+ lbShow(at);
1659
+ if (!lb.open) lb.showModal();
1660
+ };
1661
+ zooms.forEach((z) => {
1662
+ const open = (e) => { const set = setFor(z); lbOpen(e, set, z.hasAttribute('data-rv-media-list') ? 0 : indexIn(set, z), z); };
1663
+ on(z, 'click', open);
1664
+ on(z, 'keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') open(e); });
1665
+ });
1666
+ all('[data-rv-zoom-alias]').forEach((z) => on(z, 'click', (e) => {
1667
+ if (!lb || !lbAll.length) return;
1668
+ e.stopPropagation();
1669
+ lbOpen(e, lbAll, Number(z.getAttribute('data-rv-zoom-alias')) || 0, z);
1670
+ }));
1671
+ if (lb) {
1672
+ on(lb.querySelector('[data-rv-lb-prev]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt - 1); });
1673
+ on(lb.querySelector('[data-rv-lb-next]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt + 1); });
1674
+ on(lb.querySelector('[data-rv-lb-close]'), 'click', (e) => { e.preventDefault(); lb.close(); });
1675
+ on(lb.querySelector('[data-rv-lb-peek]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt + 1); });
1676
+ on(strip, 'click', (e) => {
1677
+ const t = isEl(e.target) ? e.target.closest('[data-rv-lb-thumb]') : null;
1678
+ if (!t) return;
1679
+ e.preventDefault(); lbShow(Number(t.getAttribute('data-rv-lb-thumb')) || 0);
1680
+ });
1681
+ on(lb, 'keydown', (e) => { if (e.key === 'ArrowLeft') lbShow(lbAt - 1); if (e.key === 'ArrowRight') lbShow(lbAt + 1); });
1682
+ on(lb, 'click', (e) => { if (e.target === lb) lb.close(); });
1683
+ // Swipe on the photo (some widgets show no arrows on phones).
1684
+ const swipe = lb.querySelector('[data-rv-lb-stage]') || lb.querySelector('[data-rv-lb-media]');
1685
+ let sx = 0; let sy = 0; let tracking = false;
1686
+ on(swipe, 'touchstart', (e) => { const t = e.touches[0]; if (!t) return; sx = t.clientX; sy = t.clientY; tracking = true; }, { passive: true });
1687
+ on(swipe, 'touchend', (e) => {
1688
+ if (!tracking) return; tracking = false;
1689
+ const t = e.changedTouches[0]; if (!t) return;
1690
+ const dx = t.clientX - sx; const dy = t.clientY - sy;
1691
+ if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) lbShow(lbAt + (dx < 0 ? 1 : -1));
1692
+ });
1693
+ // Helpful from inside the lightbox: the copy's button presses the real one, then the copy redraws with the count.
1694
+ on(lb.querySelector('[data-rv-lb-review]'), 'click', (e) => {
1695
+ const v = lbVotes && isEl(e.target) ? e.target.closest('[data-rv-vote]') : null;
1696
+ if (!v) return;
1697
+ e.preventDefault();
1698
+ const x = lbSet[lbAt];
1699
+ const item = x && x.review ? items.find((it) => it.id === x.review) : null;
1700
+ const real = item && item.querySelector('[data-rv-vote="' + v.getAttribute('data-rv-vote') + '"]');
1701
+ if (real) real.click();
1702
+ lbShow(lbAt);
1703
+ });
1704
+ on(lb, 'close', () => { const vid = lb.querySelector('video'); if (vid) vid.pause(); if (lbFrom && lbFrom.focus) lbFrom.focus({ preventScroll: true }); });
1705
+ }
1706
+
1707
+ render();
1708
+ });
1709
+ api.emit('gem:reviews:ready', { source: 'gem-runtime', count: boundReviews });
1710
+ });
1057
1711
  registerNoopPlugin("seasonal-effects", "gem:seasonal:ready");
1058
1712
  registerNoopPlugin("context-adapter", "gem:context:ready");
1059
1713
  const mark = (element, key) => {