@gemboss/gem-framework 0.5.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +63 -1
  2. package/dist/SOURCE.json +2 -2
  3. package/dist/gem-framework.css +206 -64
  4. package/dist/gem-runtime.js +158 -12
  5. package/dist/index.js +747 -112
  6. package/dist/plugins/before_after.d.ts +9 -0
  7. package/dist/plugins/before_after.js +37 -0
  8. package/dist/plugins/carousel.d.ts +9 -0
  9. package/dist/plugins/carousel.js +49 -0
  10. package/dist/plugins/cart-drawer.d.ts +9 -0
  11. package/dist/plugins/cart-drawer.js +69 -0
  12. package/dist/plugins/context-adapter.d.ts +9 -0
  13. package/dist/plugins/context-adapter.js +17 -0
  14. package/dist/plugins/countdown.d.ts +9 -0
  15. package/dist/plugins/countdown.js +164 -0
  16. package/dist/plugins/disclosure.d.ts +9 -0
  17. package/dist/plugins/disclosure.js +40 -0
  18. package/dist/plugins/hero-slideshow.d.ts +9 -0
  19. package/dist/plugins/hero-slideshow.js +57 -0
  20. package/dist/plugins/localization.d.ts +9 -0
  21. package/dist/plugins/localization.js +37 -0
  22. package/dist/plugins/media-gallery.d.ts +9 -0
  23. package/dist/plugins/media-gallery.js +57 -0
  24. package/dist/plugins/mobile-menu.d.ts +9 -0
  25. package/dist/plugins/mobile-menu.js +51 -0
  26. package/dist/plugins/offer-engine.d.ts +9 -0
  27. package/dist/plugins/offer-engine.js +57 -0
  28. package/dist/plugins/predictive-search.d.ts +9 -0
  29. package/dist/plugins/predictive-search.js +77 -0
  30. package/dist/plugins/promo-popup.d.ts +9 -0
  31. package/dist/plugins/promo-popup.js +117 -0
  32. package/dist/plugins/quantity-input.d.ts +9 -0
  33. package/dist/plugins/quantity-input.js +44 -0
  34. package/dist/plugins/quick-view.d.ts +9 -0
  35. package/dist/plugins/quick-view.js +69 -0
  36. package/dist/plugins/recently-viewed.d.ts +9 -0
  37. package/dist/plugins/recently-viewed.js +71 -0
  38. package/dist/plugins/reveal-on-scroll.d.ts +9 -0
  39. package/dist/plugins/reveal-on-scroll.js +37 -0
  40. package/dist/plugins/runtime-slot-loader.d.ts +9 -0
  41. package/dist/plugins/runtime-slot-loader.js +29 -0
  42. package/dist/plugins/seasonal-effects.d.ts +9 -0
  43. package/dist/plugins/seasonal-effects.js +17 -0
  44. package/dist/plugins/sticky-atc.d.ts +9 -0
  45. package/dist/plugins/sticky-atc.js +43 -0
  46. package/dist/plugins/tabs.d.ts +9 -0
  47. package/dist/plugins/tabs.js +50 -0
  48. package/dist/plugins/variant-selects.d.ts +9 -0
  49. package/dist/plugins/variant-selects.js +70 -0
  50. package/dist/popup-shell.css +91 -0
  51. package/dist/primitives.css +1430 -0
  52. package/dist/runtime-core.d.ts +51 -0
  53. package/dist/runtime-core.js +176 -0
  54. package/dist/section-gate.js +432 -21
  55. package/dist/sections.css +1116 -0
  56. package/dist/tokens.css +67 -0
  57. package/package.json +21 -1
@@ -549,35 +549,46 @@
549
549
  registerPlugin('promo-popup', ({ api }) => {
550
550
  const popups = document.querySelectorAll('[data-gem-promo-popup]');
551
551
  let bound = 0;
552
+ const shown = [];
552
553
  const storagePrefix = 'gem:promo-popup-dismissed:';
553
554
  const now = () => Date.now ? Date.now() : new Date().getTime();
554
- const readDismissedUntil = (key) => {
555
- try { return Number(target.localStorage && target.localStorage.getItem(storagePrefix + key) || '0'); }
555
+ const readDismissal = (key) => {
556
+ try { const value = Number(target.localStorage && target.localStorage.getItem(storagePrefix + key) || '0'); return Number.isFinite(value) && value > 0 ? value : 0; }
556
557
  catch { return 0; }
557
558
  };
558
- const writeDismissedUntil = (key, timestamp) => {
559
+ const writeDismissedAt = (key, timestamp) => {
559
560
  try { if (target.localStorage) target.localStorage.setItem(storagePrefix + key, String(timestamp)); }
560
561
  catch { /* storage can be unavailable in private contexts */ }
561
562
  };
563
+ const isDismissed = (key, windowMs) => {
564
+ const stored = readDismissal(key);
565
+ if (!stored) return false;
566
+ const at = now();
567
+ if (stored > at) { if (stored - windowMs <= at) writeDismissedAt(key, stored - windowMs); return true; }
568
+ return at - stored < windowMs;
569
+ };
562
570
  popups.forEach((popup, index) => {
563
571
  if (!mark(popup, 'promo-popup')) return;
564
572
  const key = popup.id || popup.getAttribute && popup.getAttribute('data-gem-section-library-id') || String(index);
565
573
  const delaySeconds = Number(popup.getAttribute && (popup.getAttribute('data-gem-promo-delay') || '0'));
566
574
  const frequencyDays = Number(popup.getAttribute && (popup.getAttribute('data-gem-promo-frequency-days') || '7'));
575
+ const windowMs = (Number.isFinite(frequencyDays) ? Math.max(1, frequencyDays) : 7) * 86400000;
567
576
  const dialog = popup.querySelector && popup.querySelector('[data-gem-promo-popup-dialog]');
568
577
  const overlay = popup.querySelector && popup.querySelector('[data-gem-promo-popup-overlay]');
569
- const open = (reason = 'timer') => {
570
- if (readDismissedUntil(key) > now()) { popup.dataset.gemPromoPopupState = 'dismissed'; return false; }
578
+ const show = (reason) => {
571
579
  popup.hidden = false;
572
580
  if (dialog) dialog.hidden = false;
573
581
  if (overlay) overlay.hidden = false;
574
582
  popup.dataset.gemPromoPopupState = 'open';
575
583
  api.emit('gem:promo-popup:open', { source: 'gem-runtime', id: popup.id || undefined, reason });
584
+ };
585
+ const open = (reason = 'timer') => {
586
+ if (isDismissed(key, windowMs)) { popup.dataset.gemPromoPopupState = 'dismissed'; return false; }
587
+ show(reason);
576
588
  return true;
577
589
  };
578
590
  const close = (reason = 'dismiss') => {
579
- const days = Number.isFinite(frequencyDays) ? Math.max(1, frequencyDays) : 7;
580
- writeDismissedUntil(key, now() + days * 86400000);
591
+ writeDismissedAt(key, now());
581
592
  if (dialog) dialog.hidden = true;
582
593
  if (overlay) overlay.hidden = true;
583
594
  popup.dataset.gemPromoPopupState = 'closed';
@@ -590,10 +601,50 @@
590
601
  if (canListen(overlay)) overlay.addEventListener('click', () => close('overlay'));
591
602
  popup.dataset.gemPromoPopupState = 'ready';
592
603
  const delay = Math.max(0, Number.isFinite(delaySeconds) ? delaySeconds : 0) * 1000;
593
- target.setTimeout ? target.setTimeout(() => open('timer'), delay) : open('timer');
604
+ const trigger = String(popup.getAttribute && popup.getAttribute('data-gem-promo-trigger') || 'delay').trim();
605
+ const rawPercent = String(popup.getAttribute && popup.getAttribute('data-gem-promo-scroll-percent') || '').trim();
606
+ const parsedPercent = rawPercent ? Number(rawPercent) : NaN;
607
+ const scrollPercent = Number.isFinite(parsedPercent) ? Math.min(90, Math.max(10, parsedPercent)) : 50;
608
+ const canHover = () => { try { return Boolean(target.matchMedia && target.matchMedia('(hover: hover) and (pointer: fine)').matches); } catch { return false; } };
609
+ const armed = trigger === 'exit_intent' && canHover() ? 'exit_intent' : trigger === 'scroll' ? 'scroll' : 'delay';
610
+ if (popup.setAttribute) popup.setAttribute('data-gem-promo-armed', armed);
611
+ let opened = false;
612
+ const disarm = [];
613
+ const openOnce = (reason) => { if (opened) return; opened = true; disarm.splice(0).forEach((off) => off()); open(reason); };
614
+ shown.push((reason) => { opened = true; disarm.splice(0).forEach((off) => off()); if (popup.dataset.gemPromoPopupState !== 'open') show(reason); });
615
+ if (armed === 'exit_intent') {
616
+ const onMouseOut = (event) => { if (event.relatedTarget || event.clientY >= 10) return; openOnce('exit_intent'); };
617
+ if (canListen(document)) {
618
+ document.addEventListener('mouseout', onMouseOut);
619
+ disarm.push(() => document.removeEventListener && document.removeEventListener('mouseout', onMouseOut));
620
+ }
621
+ } else if (armed === 'scroll') {
622
+ const room = () => Number(document.documentElement && document.documentElement.scrollHeight || 0) - Number(target.innerHeight || 0);
623
+ const check = () => { const r = room(); if (r > 0 && (Number(target.scrollY || target.pageYOffset || 0) / r) * 100 >= scrollPercent) openOnce('scroll'); };
624
+ let ticking = false;
625
+ const onScroll = () => {
626
+ if (ticking) return;
627
+ ticking = true;
628
+ const frame = target.requestAnimationFrame ? (fn) => target.requestAnimationFrame(fn) : (fn) => target.setTimeout && target.setTimeout(fn, 16);
629
+ frame(() => { ticking = false; check(); });
630
+ };
631
+ if (canListen(target)) {
632
+ target.addEventListener('scroll', onScroll, { passive: true });
633
+ target.addEventListener('resize', check);
634
+ target.addEventListener('load', check);
635
+ disarm.push(() => { if (!target.removeEventListener) return; target.removeEventListener('scroll', onScroll); target.removeEventListener('resize', check); target.removeEventListener('load', check); });
636
+ }
637
+ if (target.setTimeout) {
638
+ const fallback = target.setTimeout(() => { if (room() <= 0) openOnce('timer'); }, delay);
639
+ disarm.push(() => target.clearTimeout && target.clearTimeout(fallback));
640
+ }
641
+ check();
642
+ } else {
643
+ target.setTimeout ? target.setTimeout(() => openOnce('timer'), delay) : openOnce('timer');
644
+ }
594
645
  bound += 1;
595
646
  });
596
- api.promoPopup = { open: () => popups.forEach((popup) => { popup.hidden = false; popup.dataset.gemPromoPopupState = 'open'; }) };
647
+ api.promoPopup = { open: () => shown.forEach((show) => show('api')) };
597
648
  api.emit('gem:promo-popup:ready', { source: 'gem-runtime', count: bound });
598
649
  });
599
650
  registerPlugin('sticky-atc', ({ api }) => {
@@ -607,7 +658,10 @@
607
658
  };
608
659
  bars.forEach((bar) => {
609
660
  if (!mark(bar, 'sticky-atc')) return;
610
- const threshold = Number(bar.getAttribute && (bar.getAttribute('data-gem-sticky-threshold') || '240'));
661
+ const cssThreshold = () => { try { return target.getComputedStyle ? Number(target.getComputedStyle(bar).getPropertyValue('--gem-sticky-atc-threshold')) : 0; } catch { return 0; } };
662
+ const threshold = bar.hasAttribute && bar.hasAttribute('data-gem-sticky-atc-threshold')
663
+ ? (Number(bar.getAttribute('data-gem-sticky-atc-threshold')) || cssThreshold() || 360)
664
+ : Number(bar.getAttribute && (bar.getAttribute('data-gem-sticky-threshold') || '240'));
611
665
  const update = () => {
612
666
  const scrollY = Number(target.scrollY || target.pageYOffset || 0);
613
667
  setState(bar, scrollY >= threshold ? 'visible' : 'hidden');
@@ -662,12 +716,86 @@
662
716
  const targetNode = timer.querySelector && timer.querySelector(selector);
663
717
  if (targetNode) targetNode.textContent = pad(value);
664
718
  };
719
+ const clockText = (s) => {
720
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
721
+ return (h ? h + ':' + pad(m) : String(m)) + ':' + pad(sec);
722
+ };
723
+ const bindDuration = (timer, total) => {
724
+ const scope = (timer.closest && timer.closest('[data-gem-section-id]')) || document;
725
+ const mode = String(timer.getAttribute('data-gem-countdown-start') || 'mount').trim() || 'mount';
726
+ const stopEvent = String(timer.getAttribute('data-gem-countdown-stop') || '').trim();
727
+ let left = total * 1000;
728
+ let endAt = 0;
729
+ let handle = null;
730
+ let state = 'idle';
731
+ let started = false;
732
+ let observer = null;
733
+ const release = () => { if (observer) { observer.disconnect(); observer = null; } };
734
+ const detail = () => ({ source: 'gem-runtime', id: timer.id || undefined, mode: 'duration', duration: total, remainingSeconds: Math.max(0, Math.ceil(left / 1000)) });
735
+ const render = () => {
736
+ const s = Math.max(0, Math.ceil(left / 1000));
737
+ timer.dataset.gemCountdownState = state;
738
+ timer.dataset.gemCountdownRemainingSeconds = String(s);
739
+ write(timer, '[data-gem-countdown-days]', Math.floor(s / 86400));
740
+ write(timer, '[data-gem-countdown-hours]', Math.floor((s % 86400) / 3600));
741
+ write(timer, '[data-gem-countdown-minutes]', Math.floor((s % 3600) / 60));
742
+ write(timer, '[data-gem-countdown-seconds]', s % 60);
743
+ const readout = timer.querySelector && timer.querySelector('[data-gem-countdown-clock]');
744
+ if (readout) readout.textContent = clockText(s);
745
+ };
746
+ const halt = () => { if (handle !== null && target.clearInterval) target.clearInterval(handle); handle = null; };
747
+ const finish = () => {
748
+ halt();
749
+ release();
750
+ left = 0;
751
+ state = 'complete';
752
+ render();
753
+ timer.setAttribute('data-gem-countdown-ended', 'true');
754
+ if (timer.dispatchEvent && timer.isConnected !== false) {
755
+ timer.dispatchEvent(new CustomEvent('gem:countdown:complete', { bubbles: true, detail: detail() }));
756
+ for (const callback of api.listeners['gem:countdown:complete'] || []) callback(detail());
757
+ } else api.emit('gem:countdown:complete', detail());
758
+ };
759
+ const tick = () => { left = endAt - Date.now(); if (left <= 0) finish(); else render(); };
760
+ const run = () => {
761
+ if (state === 'running' || state === 'complete' || state === 'stopped') return;
762
+ state = 'running';
763
+ endAt = Date.now() + left;
764
+ render();
765
+ if (target.setInterval) handle = target.setInterval(tick, 250);
766
+ if (!started) { started = true; api.emit('gem:countdown:start', detail()); }
767
+ };
768
+ const pause = (next) => {
769
+ if (state === 'running') { left = Math.max(0, endAt - Date.now()); halt(); }
770
+ if (state === 'complete' || state === 'stopped') return;
771
+ state = next;
772
+ if (next === 'stopped') release();
773
+ render();
774
+ };
775
+ render();
776
+ if (stopEvent && canListen(scope)) scope.addEventListener(stopEvent, () => pause('stopped'));
777
+ if (mode === 'visible' && target.IntersectionObserver) {
778
+ observer = new target.IntersectionObserver((entries) => {
779
+ for (const entry of entries) entry.isIntersecting ? run() : pause('paused');
780
+ });
781
+ observer.observe(timer);
782
+ } else if (mode === 'mount' || mode === 'visible') run();
783
+ else if (canListen(scope)) scope.addEventListener(mode, run);
784
+ };
665
785
  timers.forEach((timer) => {
666
786
  if (!mark(timer, 'countdown')) return;
787
+ const duration = Number(timer.getAttribute && timer.getAttribute('data-gem-countdown-duration'));
788
+ if (Number.isFinite(duration) && duration > 0) {
789
+ bindDuration(timer, Math.min(Math.round(duration), 86400));
790
+ bound += 1;
791
+ return;
792
+ }
667
793
  const targetTime = countdownEnd(timer.getAttribute && timer.getAttribute('data-gem-countdown-target'));
668
794
  const section = timer.closest ? timer.closest('[data-gem-section-id]') : null;
669
795
  const band = section && section.hasAttribute && section.hasAttribute('data-gem-countdown-end') ? section : null;
796
+ const hasSecondsCell = Boolean(timer.querySelector && (timer.querySelector('[data-gem-cd-secs]') || timer.querySelector('[data-gem-countdown-seconds]')));
670
797
  let handle = null;
798
+ let announced = '';
671
799
  const update = () => {
672
800
  if (!Number.isFinite(targetTime)) {
673
801
  timer.dataset.gemCountdownState = 'invalid';
@@ -678,19 +806,37 @@
678
806
  const days = Math.floor(remainingSeconds / 86400);
679
807
  const hours = Math.floor((remainingSeconds % 86400) / 3600);
680
808
  const minutes = Math.floor((remainingSeconds % 3600) / 60);
809
+ const seconds = remainingSeconds % 60;
681
810
  timer.dataset.gemCountdownState = ms > 0 ? 'running' : 'complete';
682
811
  timer.dataset.gemCountdownRemainingSeconds = String(remainingSeconds);
683
812
  write(timer, '[data-gem-countdown-days]', days);
684
813
  write(timer, '[data-gem-countdown-hours]', hours);
685
814
  write(timer, '[data-gem-countdown-minutes]', minutes);
815
+ write(timer, '[data-gem-countdown-seconds]', seconds);
816
+ write(timer, '[data-gem-cd-days]', days);
817
+ write(timer, '[data-gem-cd-hours]', hours);
818
+ write(timer, '[data-gem-cd-mins]', minutes);
819
+ write(timer, '[data-gem-cd-secs]', seconds);
686
820
  if (ms <= 0) {
687
821
  if (band) band.setAttribute('data-gem-countdown-ended', 'true');
822
+ if (timer.setAttribute && !(timer.hasAttribute && timer.hasAttribute('data-gem-section-id'))) timer.setAttribute('data-gem-countdown-ended', 'true');
688
823
  if (handle !== null && target.clearInterval) { target.clearInterval(handle); handle = null; }
689
824
  }
690
- api.emit(ms > 0 ? 'gem:countdown:start' : 'gem:countdown:complete', { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });
825
+ const event = ms > 0 ? 'gem:countdown:start' : 'gem:countdown:complete';
826
+ if (event === announced && (event === 'gem:countdown:complete' || hasSecondsCell)) return;
827
+ announced = event;
828
+ api.emit(event, { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });
829
+ };
830
+ const onTheMinute = () => {
831
+ const ms = targetTime - Date.now();
832
+ if (!(ms > 0) || !target.setTimeout) return;
833
+ target.setTimeout(() => { update(); onTheMinute(); }, Math.min((ms % 60000) + 5, ms));
691
834
  };
692
835
  update();
693
- if (target.setInterval && Number.isFinite(targetTime) && targetTime - Date.now() > 0) handle = target.setInterval(update, 60000);
836
+ if (Number.isFinite(targetTime) && targetTime - Date.now() > 0) {
837
+ if (!hasSecondsCell) onTheMinute();
838
+ else if (target.setInterval) handle = target.setInterval(update, 1000);
839
+ }
694
840
  const left = targetTime - Date.now();
695
841
  if (target.setTimeout && Number.isFinite(left) && left > 0 && left < 2147483647) target.setTimeout(update, left);
696
842
  bound += 1;