336 lines
10 KiB
JavaScript
336 lines
10 KiB
JavaScript
/**
|
|
* Home hero carousel — autoplay + dot navigation + mobile swipe + layout sync
|
|
*
|
|
* Layout contract (extensible per banner):
|
|
* data-hero-layout-reference — slide that defines carousel height
|
|
* data-hero-layout-anchor — element whose bottom anchors dot position
|
|
* --hero-coach-dots-gap — gap between anchor bottom and dots top (default 35px)
|
|
* Mobile: .is-banner-active fixes dots to viewport bottom while banner is visible
|
|
*/
|
|
(function () {
|
|
var AUTOPLAY_MS = 5000;
|
|
var SWIPE_THRESHOLD = 48;
|
|
var MOBILE_MQ = '(max-width: 991.98px)';
|
|
var DESKTOP_MQ = '(min-width: 992px)';
|
|
|
|
function readPx(styles, name, fallback) {
|
|
var value = parseFloat(styles.getPropertyValue(name));
|
|
return Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function getLayoutScope(root) {
|
|
return root.closest('#homepage') || root;
|
|
}
|
|
|
|
function clearMeasuredLayout(scope, root) {
|
|
[
|
|
'--hero-reference-content-bottom',
|
|
'--hero-reference-viewport-height',
|
|
'--hero-reference-dots-top',
|
|
'--hero-mobile-dots-reserve',
|
|
'--hero-dots-y',
|
|
].forEach(function (name) {
|
|
scope.style.removeProperty(name);
|
|
});
|
|
|
|
if (root) {
|
|
var dots = root.querySelector('.home-hero-carousel__dots');
|
|
if (dots) {
|
|
dots.removeAttribute('data-hero-dots-positioned');
|
|
}
|
|
}
|
|
}
|
|
|
|
function syncHeroLayout(root) {
|
|
var inner = root.querySelector('.home-hero-carousel__inner');
|
|
var viewport = root.querySelector('.home-hero-carousel__viewport');
|
|
if (!inner || !viewport) return;
|
|
|
|
var referenceSlide = root.querySelector('[data-hero-layout-reference]')
|
|
|| root.querySelector('[data-home-hero-slide]');
|
|
if (!referenceSlide) return;
|
|
|
|
var anchor = referenceSlide.querySelector('[data-hero-layout-anchor]')
|
|
|| referenceSlide.querySelector('.home-hero-carousel__ai-coach')
|
|
|| referenceSlide.querySelector('.home-hero-carousel__actions :last-child');
|
|
if (!anchor) return;
|
|
|
|
var scope = getLayoutScope(root);
|
|
var styles = getComputedStyle(scope);
|
|
var coachDotsGap = readPx(styles, '--hero-coach-dots-gap', 35);
|
|
var dotsHeight = readPx(styles, '--hero-dots-height', 10);
|
|
var innerPaddingBottom = readPx(styles, '--hero-inner-padding-bottom', 40);
|
|
var isDesktop = window.matchMedia(DESKTOP_MQ).matches;
|
|
|
|
if (isDesktop) {
|
|
var innerRect = inner.getBoundingClientRect();
|
|
var viewportRect = viewport.getBoundingClientRect();
|
|
var anchorRect = anchor.getBoundingClientRect();
|
|
|
|
var contentBottom = Math.ceil(anchorRect.bottom - viewportRect.top);
|
|
var viewportHeight = contentBottom + coachDotsGap;
|
|
var dotsTop = Math.ceil(anchorRect.bottom - innerRect.top + coachDotsGap);
|
|
var dotsBottomY = dotsTop + dotsHeight + innerPaddingBottom;
|
|
|
|
scope.style.setProperty('--hero-reference-content-bottom', contentBottom + 'px');
|
|
scope.style.setProperty('--hero-reference-viewport-height', viewportHeight + 'px');
|
|
scope.style.setProperty('--hero-reference-dots-top', dotsTop + 'px');
|
|
scope.style.setProperty('--hero-dots-y', dotsBottomY + 'px');
|
|
root.setAttribute('data-hero-layout-synced', 'desktop');
|
|
|
|
var dots = root.querySelector('.home-hero-carousel__dots');
|
|
if (dots) {
|
|
dots.setAttribute('data-hero-dots-positioned', 'true');
|
|
}
|
|
root.classList.remove('is-banner-active');
|
|
return;
|
|
}
|
|
|
|
clearMeasuredLayout(scope, root);
|
|
|
|
var viewportRect = viewport.getBoundingClientRect();
|
|
var slideGrid = referenceSlide.querySelector('.home-hero-carousel__slide-grid');
|
|
var visual = referenceSlide.querySelector('.home-hero-carousel__visual');
|
|
var mobileFixedDotsBottom = readPx(styles, '--hero-mobile-fixed-dots-bottom', 16);
|
|
var imageScale = readPx(styles, '--hero-visual-image-scale', 1.06);
|
|
|
|
if (!slideGrid) return;
|
|
|
|
var visualBottom = visual
|
|
? visual.getBoundingClientRect().bottom - viewportRect.top
|
|
: slideGrid.getBoundingClientRect().bottom - viewportRect.top;
|
|
|
|
if (visual) {
|
|
var img = visual.querySelector('img');
|
|
if (img && imageScale > 1) {
|
|
var imgHeight = img.getBoundingClientRect().height;
|
|
visualBottom += Math.ceil((imgHeight * imageScale - imgHeight) / 2);
|
|
}
|
|
}
|
|
|
|
var viewportHeight = Math.ceil(visualBottom);
|
|
var dotsReserve = dotsHeight + mobileFixedDotsBottom;
|
|
var homepageRect = scope.getBoundingClientRect();
|
|
var dotsBottomY = Math.ceil(
|
|
viewportRect.top - homepageRect.top + viewportHeight + dotsReserve + innerPaddingBottom
|
|
);
|
|
|
|
scope.style.setProperty('--hero-reference-viewport-height', viewportHeight + 'px');
|
|
scope.style.setProperty('--hero-mobile-dots-reserve', dotsReserve + 'px');
|
|
scope.style.setProperty('--hero-dots-y', dotsBottomY + 'px');
|
|
|
|
root.setAttribute('data-hero-layout-synced', 'mobile');
|
|
}
|
|
|
|
function bindMobileFixedDots(root) {
|
|
if (typeof IntersectionObserver === 'undefined') return;
|
|
|
|
var mobileMq = window.matchMedia(MOBILE_MQ);
|
|
var observer = new IntersectionObserver(
|
|
function (entries) {
|
|
if (!mobileMq.matches) {
|
|
root.classList.remove('is-banner-active');
|
|
return;
|
|
}
|
|
root.classList.toggle('is-banner-active', entries[0].isIntersecting);
|
|
},
|
|
{
|
|
threshold: 0,
|
|
rootMargin: '0px 0px -8% 0px',
|
|
}
|
|
);
|
|
|
|
observer.observe(root);
|
|
|
|
mobileMq.addEventListener('change', function () {
|
|
if (!mobileMq.matches) {
|
|
root.classList.remove('is-banner-active');
|
|
}
|
|
});
|
|
}
|
|
|
|
function bindHeroLayoutSync(root) {
|
|
var sync = function () {
|
|
syncHeroLayout(root);
|
|
};
|
|
|
|
sync();
|
|
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
var observer = new ResizeObserver(sync);
|
|
observer.observe(root);
|
|
var inner = root.querySelector('.home-hero-carousel__inner');
|
|
var referenceSlide = root.querySelector('[data-hero-layout-reference]')
|
|
|| root.querySelector('[data-home-hero-slide]');
|
|
if (inner) observer.observe(inner);
|
|
if (referenceSlide) observer.observe(referenceSlide);
|
|
var visual = referenceSlide.querySelector('.home-hero-carousel__visual');
|
|
if (visual) observer.observe(visual);
|
|
}
|
|
|
|
var resizeTimer;
|
|
window.addEventListener('resize', function () {
|
|
window.clearTimeout(resizeTimer);
|
|
resizeTimer = window.setTimeout(sync, 100);
|
|
});
|
|
|
|
if (document.fonts && document.fonts.ready) {
|
|
document.fonts.ready.then(sync);
|
|
}
|
|
}
|
|
|
|
function initHomeHeroCarousel(root) {
|
|
if (root.getAttribute('data-carousel-ready') === 'true') return;
|
|
root.setAttribute('data-carousel-ready', 'true');
|
|
|
|
var slides = root.querySelectorAll('[data-home-hero-slide]');
|
|
var dots = root.querySelectorAll('[data-home-hero-dot]');
|
|
var viewport = root.querySelector('.home-hero-carousel__viewport');
|
|
if (!slides.length) return;
|
|
|
|
var index = 0;
|
|
var timer = null;
|
|
var touchStartX = 0;
|
|
var touchStartY = 0;
|
|
var touchTracking = false;
|
|
|
|
function setSlide(nextIndex) {
|
|
index = (nextIndex + slides.length) % slides.length;
|
|
|
|
slides.forEach(function (slide, i) {
|
|
var active = i === index;
|
|
slide.classList.toggle('is-active', active);
|
|
slide.setAttribute('aria-hidden', active ? 'false' : 'true');
|
|
});
|
|
|
|
dots.forEach(function (dot, i) {
|
|
var active = i === index;
|
|
dot.classList.toggle('is-active', active);
|
|
dot.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
});
|
|
}
|
|
|
|
function startAutoplay() {
|
|
stopAutoplay();
|
|
timer = window.setInterval(function () {
|
|
setSlide(index + 1);
|
|
}, AUTOPLAY_MS);
|
|
}
|
|
|
|
function stopAutoplay() {
|
|
if (timer) {
|
|
window.clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
}
|
|
|
|
function isMobileSwipeEnabled() {
|
|
return window.matchMedia(MOBILE_MQ).matches;
|
|
}
|
|
|
|
function bindTouchSwipe() {
|
|
if (!viewport || viewport.getAttribute('data-swipe-ready') === 'true') return;
|
|
viewport.setAttribute('data-swipe-ready', 'true');
|
|
|
|
viewport.addEventListener(
|
|
'touchstart',
|
|
function (e) {
|
|
if (!isMobileSwipeEnabled() || !e.touches.length) return;
|
|
touchStartX = e.touches[0].clientX;
|
|
touchStartY = e.touches[0].clientY;
|
|
touchTracking = true;
|
|
stopAutoplay();
|
|
},
|
|
{ passive: true }
|
|
);
|
|
|
|
viewport.addEventListener(
|
|
'touchend',
|
|
function (e) {
|
|
if (!touchTracking) return;
|
|
touchTracking = false;
|
|
|
|
if (!isMobileSwipeEnabled()) {
|
|
startAutoplay();
|
|
return;
|
|
}
|
|
|
|
var touch = e.changedTouches[0];
|
|
if (!touch) {
|
|
startAutoplay();
|
|
return;
|
|
}
|
|
|
|
var dx = touch.clientX - touchStartX;
|
|
var dy = touch.clientY - touchStartY;
|
|
|
|
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) {
|
|
startAutoplay();
|
|
return;
|
|
}
|
|
|
|
if (dx < 0) {
|
|
setSlide(index + 1);
|
|
} else {
|
|
setSlide(index - 1);
|
|
}
|
|
startAutoplay();
|
|
},
|
|
{ passive: true }
|
|
);
|
|
|
|
viewport.addEventListener(
|
|
'touchcancel',
|
|
function () {
|
|
touchTracking = false;
|
|
startAutoplay();
|
|
},
|
|
{ passive: true }
|
|
);
|
|
}
|
|
|
|
dots.forEach(function (dot) {
|
|
dot.addEventListener('click', function () {
|
|
var target = Number(dot.getAttribute('data-home-hero-dot'));
|
|
if (Number.isNaN(target)) return;
|
|
setSlide(target);
|
|
startAutoplay();
|
|
});
|
|
});
|
|
|
|
root.addEventListener('mouseenter', stopAutoplay);
|
|
root.addEventListener('mouseleave', startAutoplay);
|
|
root.addEventListener('focusin', stopAutoplay);
|
|
root.addEventListener('focusout', function (e) {
|
|
if (!root.contains(e.relatedTarget)) startAutoplay();
|
|
});
|
|
|
|
bindTouchSwipe();
|
|
bindHeroLayoutSync(root);
|
|
bindMobileFixedDots(root);
|
|
setSlide(0);
|
|
startAutoplay();
|
|
}
|
|
|
|
function boot() {
|
|
document.querySelectorAll('[data-home-hero-carousel]').forEach(initHomeHeroCarousel);
|
|
}
|
|
|
|
window.AifoHomeHeroCarouselInit = initHomeHeroCarousel;
|
|
window.AifoHomeHeroCarouselSyncLayout = function (root) {
|
|
if (root) {
|
|
syncHeroLayout(root);
|
|
return;
|
|
}
|
|
document.querySelectorAll('[data-home-hero-carousel]').forEach(syncHeroLayout);
|
|
};
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', boot);
|
|
} else {
|
|
boot();
|
|
}
|
|
|
|
document.addEventListener('aifo:home-hero-carousel-reload', boot);
|
|
})();
|