// ========================= // App // ========================= document.addEventListener('DOMContentLoaded', () => { const app = { body: document.body, loader: document.querySelector('.page-loader'), startRevealAnimations: null }; initLoadingState(app); initMenu(app); initHeaderState(); initAccordion(); initModal(app); initReviewSliders(); initSceneSlider(); initVideoChapters(); initVideoChapterScroll(); initTextReveal(); initRevealObservers(app); initActiveNav(); initFaqImageModal(); initImageDragScroll() }); // ========================= // Loading // ========================= function initLoadingState(app) { app.body.classList.add('is-loading'); window.addEventListener('load', () => { setTimeout(() => { app.loader?.classList.add('is-hide'); app.body.classList.remove('is-loading'); app.startRevealAnimations?.(); }, 600); }); } // ========================= // Menu // ========================= function initMenu(app) { const menuBtn = document.querySelector('.menu-btn'); const mobileMenu = document.querySelector('.mobile-menu'); const tabletMenu = document.querySelector('.tablet-menu'); const productDropdown = document.querySelector('.product-dropdown'); const closeMenu = () => { app.body.classList.remove('menu-open'); mobileMenu?.setAttribute('aria-hidden', 'true'); tabletMenu?.setAttribute('aria-hidden', 'true'); }; menuBtn?.addEventListener('click', () => { app.body.classList.toggle('menu-open'); const isOpen = app.body.classList.contains('menu-open'); const ariaValue = isOpen ? 'false' : 'true'; mobileMenu?.setAttribute('aria-hidden', ariaValue); tabletMenu?.setAttribute('aria-hidden', ariaValue); }); document.querySelectorAll('.mobile-menu a, .tablet-menu a').forEach((link) => { link.addEventListener('click', closeMenu); }); productDropdown?.addEventListener('mouseenter', closeMenu); } // ========================= // Header / Product Tabs // ========================= function initHeaderState() { const header = document.querySelector('.site-header'); const productTabs = document.querySelector('.product-tabs'); const hero = document.querySelector('.hero'); const channelSection = document.querySelector('#channel'); if (!hero) return; let lastScrollY = window.scrollY; const updateHeaderState = () => { const currentScrollY = window.scrollY; const isScrollingUp = currentScrollY < lastScrollY; const passedHero = currentScrollY >= hero.offsetTop; header?.classList.toggle('is-scrolled', passedHero); productTabs?.classList.toggle('is-compact', passedHero); if (channelSection && productTabs) { const rect = channelSection.getBoundingClientRect(); const passedChannelHalf = rect.top + rect.height / 2 <= window.innerHeight / 2; productTabs.classList.toggle('is-hidden', passedChannelHalf && !isScrollingUp); } lastScrollY = currentScrollY; }; window.addEventListener('scroll', updateHeaderState); window.addEventListener('resize', updateHeaderState); updateHeaderState(); } // ========================= // Accordion // ========================= function initAccordion() { document.querySelectorAll('.accordion').forEach((item) => { const btn = item.querySelector('button'); const icon = item.querySelector('.accordion-icon'); btn?.addEventListener('click', () => { const isOpen = item.classList.toggle('is-open'); if (!icon) return; icon.src = isOpen ? icon.dataset.open : icon.dataset.close; }); }); } function initFaqImageModal() { const modal = document.querySelector('[data-faq-image-modal]'); const openBtns = document.querySelectorAll( '[data-faq-image-open], .faq-answer__img > img' ); const closeBtns = document.querySelectorAll('[data-faq-image-close]'); if (!modal || !openBtns.length) return; const openModal = () => { modal.classList.add('is-open'); modal.setAttribute('aria-hidden', 'false'); document.body.style.overflow = 'hidden'; }; const closeModal = () => { modal.classList.remove('is-open'); modal.setAttribute('aria-hidden', 'true'); document.body.style.overflow = ''; }; openBtns.forEach((btn) => { btn.addEventListener('click', openModal); }); closeBtns.forEach((btn) => { btn.addEventListener('click', closeModal); }); } function initImageDragScroll() { const viewport = document.querySelector('.faq-image-modal__viewport'); if (!viewport) return; let isDown = false; let startX = 0; let scrollLeft = 0; viewport.addEventListener('mousedown', (e) => { isDown = true; viewport.classList.add('is-dragging'); startX = e.pageX; scrollLeft = viewport.scrollLeft; }); window.addEventListener('mouseup', () => { isDown = false; viewport.classList.remove('is-dragging'); }); window.addEventListener('mousemove', (e) => { if (!isDown) return; e.preventDefault(); const walk = e.pageX - startX; viewport.scrollLeft = scrollLeft - walk; }); viewport.addEventListener('mouseleave', () => { isDown = false; viewport.classList.remove('is-dragging'); }); } // ========================= // Modal // ========================= function initModal(app) { const openModal = (id) => { const modal = document.getElementById(id); if (!modal) return; modal.classList.add('is-open'); modal.setAttribute('aria-hidden', 'false'); app.body.style.overflow = 'hidden'; }; const closeModal = (modal) => { if (!modal) return; modal.classList.remove('is-open'); modal.setAttribute('aria-hidden', 'true'); app.body.style.overflow = ''; }; document.querySelectorAll('[data-modal-open]').forEach((btn) => { btn.addEventListener('click', () => { openModal(btn.dataset.modalOpen); }); }); document.querySelectorAll('[data-modal-close]').forEach((btn) => { btn.addEventListener('click', () => { closeModal(btn.closest('.modal')); }); }); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; document.querySelectorAll('.modal.is-open').forEach(closeModal); }); } // ========================= // Review Slider // ========================= function initReviewSliders() { document.querySelectorAll('[data-review-slider]').forEach(initReviewSlider); } function initReviewSlider(slider) { const els = getReviewSliderElements(slider); if (!els.track || !els.viewport || !els.cards.length || !els.controls) return; const state = { currentIndex: 0, resizeTimer: null, startX: 0, currentX: 0, isDragging: false }; const update = (nextIndex) => updateReviewSlider(els, state, nextIndex); els.prev?.addEventListener('click', () => update(state.currentIndex - 1)); els.next?.addEventListener('click', () => update(state.currentIndex + 1)); bindReviewSliderSwipe(els, state, update); bindReviewSliderResize(els, state, update); updateReviewControls(els); syncReviewCardHeight(els.cards); update(state.currentIndex); } function getReviewSliderElements(slider) { return { slider, track: slider.querySelector('.review-track'), viewport: slider.querySelector('.review-viewport'), cards: [...slider.querySelectorAll('.review-card')], prev: slider.querySelector('.prev'), next: slider.querySelector('.next'), controls: slider.querySelector('.review-controls'), currentText: slider.querySelector('.review-count__current'), totalText: slider.querySelector('.review-count__total') }; } function isTouchDevice() { return 'ontouchstart' in window || navigator.maxTouchPoints > 0; } function getReviewVisibleCount() { return window.innerWidth <= 600 ? 1 : 2; } function getReviewMaxIndex(cards) { return Math.max(0, cards.length - 1); } function getReviewMoveX(track, cards) { const gap = parseFloat(getComputedStyle(track).gap) || 0; return cards[0].offsetWidth + gap; } function syncReviewCardHeight(cards) { cards.forEach((card) => { card.style.height = 'auto'; }); const maxHeight = cards.reduce((height, card) => { return Math.max(height, card.offsetHeight); }, 0); cards.forEach((card) => { card.style.height = `${maxHeight}px`; }); } function updateReviewControls(els) { els.controls.style.display = els.cards.length <= getReviewVisibleCount() ? 'none' : ''; if (els.totalText) { els.totalText.textContent = els.cards.length; } } function updateReviewSlider(els, state, nextIndex) { const maxIndex = getReviewMaxIndex(els.cards); state.currentIndex = clamp(nextIndex, 0, maxIndex); els.cards.forEach((card) => { card.hidden = false; }); els.track.style.transition = 'transform .45s ease'; els.track.style.transform = `translateX(${-state.currentIndex * getReviewMoveX(els.track, els.cards)}px)`; if (els.currentText) { els.currentText.textContent = state.currentIndex + 1; } if (els.prev) els.prev.disabled = state.currentIndex === 0; if (els.next) els.next.disabled = state.currentIndex === maxIndex; } function bindReviewSliderSwipe(els, state, update) { els.viewport.addEventListener('touchstart', (e) => { if (!isTouchDevice()) return; state.startX = e.touches[0].clientX; state.currentX = state.startX; state.isDragging = true; els.track.style.transition = 'none'; }); els.viewport.addEventListener('touchmove', (e) => { if (!state.isDragging || !isTouchDevice()) return; state.currentX = e.touches[0].clientX; const diffX = state.currentX - state.startX; const baseX = -state.currentIndex * getReviewMoveX(els.track, els.cards); els.track.style.transform = `translateX(${baseX + diffX}px)`; }); els.viewport.addEventListener('touchend', () => { if (!state.isDragging || !isTouchDevice()) return; const diffX = state.currentX - state.startX; const threshold = 50; state.isDragging = false; if (diffX < -threshold) { update(state.currentIndex + 1); } else if (diffX > threshold) { update(state.currentIndex - 1); } else { update(state.currentIndex); } }); } function bindReviewSliderResize(els, state, update) { window.addEventListener('resize', () => { clearTimeout(state.resizeTimer); state.resizeTimer = setTimeout(() => { state.currentIndex = Math.min(state.currentIndex, getReviewMaxIndex(els.cards)); updateReviewControls(els); syncReviewCardHeight(els.cards); update(state.currentIndex); }, 150); }); } // ========================= // Scene Slider // ========================= function initSceneSlider() { const slider = document.querySelector('[data-scene-slider]'); if (!slider) return; const viewport = slider.querySelector('.nh-scene__viewport'); const track = slider.querySelector('.nh-scene__track'); const cards = [...slider.querySelectorAll('.nh-scene-card')]; const prev = slider.querySelector('[data-scene-prev]'); const next = slider.querySelector('[data-scene-next]'); const current = slider.querySelector('[data-scene-current]'); const total = slider.querySelector('[data-scene-total]'); if (!viewport || !track || !cards.length) return; let currentIndex = 0; let currentTranslate = 0; let startX = 0; let startY = 0; let startTranslate = 0; let isDragging = false; let isHorizontalDrag = false; let resizeTimer = null; const isMobile = () => window.innerWidth <= 820; const maxIndex = () => cards.length - 1; const getGap = () => parseFloat(getComputedStyle(track).gap) || 0; const syncTrackPadding = () => { if (!isMobile()) { track.style.removeProperty('--scene-track-pad'); return; } const pad = Math.max(0, (viewport.clientWidth - cards[0].offsetWidth) / 2); track.style.setProperty('--scene-track-pad', `${pad}px`); }; const getTargetTranslate = (index) => { const card = cards[index]; if (!card) return 0; if (isMobile()) { const cardCenter = card.offsetLeft + card.offsetWidth / 2; const viewportCenter = viewport.clientWidth / 2; return viewportCenter - cardCenter; } const maxMove = Math.max(0, track.scrollWidth - viewport.clientWidth); return -Math.min(card.offsetLeft, maxMove); }; const minTranslate = () => getTargetTranslate(maxIndex()); const clampTranslate = (value) => { return Math.max(minTranslate(), Math.min(0, value)); }; const getIndexByTranslate = (translate) => { let nearestIndex = 0; let nearestDistance = Infinity; cards.forEach((_, index) => { const target = getTargetTranslate(index); const distance = Math.abs(translate - target); if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; } }); return nearestIndex; }; const updateControls = () => { if (current) current.textContent = currentIndex + 1; if (total) total.textContent = cards.length; if (prev) prev.disabled = currentIndex <= 0; if (next) next.disabled = currentIndex >= maxIndex(); }; const setTranslate = (value, transition = false) => { currentTranslate = clampTranslate(value); track.style.transition = transition ? 'transform .45s ease' : 'none'; track.style.transform = `translate3d(${currentTranslate}px, 0, 0)`; }; const render = (transition = true) => { syncTrackPadding(); const target = getTargetTranslate(currentIndex); setTranslate(target, transition); updateControls(); }; const goToIndex = (index, transition = true) => { currentIndex = Math.max(0, Math.min(index, maxIndex())); render(transition); }; const dragTo = (clientX) => { const diffX = clientX - startX; setTranslate(startTranslate + diffX, false); }; const endDrag = () => { if (!isDragging) return; isDragging = false; isHorizontalDrag = false; viewport.classList.remove('is-dragging'); currentIndex = getIndexByTranslate(currentTranslate); setTranslate(currentTranslate, false); updateControls(); }; prev?.addEventListener('click', () => { goToIndex(currentIndex - 1); }); next?.addEventListener('click', () => { goToIndex(currentIndex + 1); }); viewport.addEventListener( 'touchstart', (e) => { startX = e.touches[0].clientX; startY = e.touches[0].clientY; startTranslate = currentTranslate; isDragging = true; isHorizontalDrag = false; track.style.transition = 'none'; }, { passive: true } ); viewport.addEventListener( 'touchmove', (e) => { if (!isDragging) return; const diffX = e.touches[0].clientX - startX; const diffY = e.touches[0].clientY - startY; if (!isHorizontalDrag) { isHorizontalDrag = Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 8; } if (!isHorizontalDrag) return; e.preventDefault(); dragTo(e.touches[0].clientX); }, { passive: false } ); viewport.addEventListener('touchend', endDrag); viewport.addEventListener('touchcancel', endDrag); viewport.addEventListener('mousedown', (e) => { if (e.button !== 0) return; e.preventDefault(); startX = e.clientX; startY = e.clientY; startTranslate = currentTranslate; isDragging = true; isHorizontalDrag = true; track.style.transition = 'none'; viewport.classList.add('is-dragging'); }); window.addEventListener('mousemove', (e) => { if (!isDragging) return; e.preventDefault(); dragTo(e.clientX); }); window.addEventListener('mouseup', endDrag); viewport.addEventListener('mouseleave', endDrag); track.querySelectorAll('img').forEach((img) => { img.addEventListener('dragstart', (e) => e.preventDefault()); }); window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { syncTrackPadding(); setTranslate(currentTranslate, false); updateControls(); }, 150); }); render(false); } // ========================= // YouTube Chapters // ========================= function initVideoChapters() { const section = document.querySelector('[data-video-chapters]'); if (!section) return; const videoTarget = section.querySelector('[data-youtube-id]'); const buttons = [...section.querySelectorAll('[data-start][data-end]')]; if (!videoTarget || !buttons.length) return; const videoId = videoTarget.dataset.youtubeId; let player = null; let activeIndex = 0; let progressTimer = null; const loadYouTubeAPI = () => { if (window.YT?.Player) { createPlayer(); return; } const existingScript = document.querySelector('script[src="https://www.youtube.com/iframe_api"]'); if (!existingScript) { const tag = document.createElement('script'); tag.src = 'https://www.youtube.com/iframe_api'; document.body.appendChild(tag); } const previousCallback = window.onYouTubeIframeAPIReady; window.onYouTubeIframeAPIReady = () => { if (typeof previousCallback === 'function') previousCallback(); createPlayer(); }; }; const createPlayer = () => { if (player) return; player = new YT.Player(videoTarget.id, { videoId, playerVars: { rel: 0, modestbranding: 1, playsinline: 1 }, events: { onReady: () => { setActiveChapter(0, false); startProgressLoop(); }, onStateChange: (event) => { if (event.data === YT.PlayerState.PLAYING) { startProgressLoop(); } } } }); }; const getChapter = (chapterIndex) => { const btn = buttons[chapterIndex]; return { btn, start: Number(btn.dataset.start), end: Number(btn.dataset.end) }; }; const setActiveChapter = (chapterIndex, shouldPlay = true) => { activeIndex = clamp(chapterIndex, 0, buttons.length - 1); buttons.forEach((button, index) => { button.classList.toggle('is-active', index === activeIndex); button.style.setProperty('--progress', '0deg'); }); const { start } = getChapter(activeIndex); if (player?.seekTo) { player.seekTo(start, true); if (shouldPlay) player.playVideo(); } }; const updateProgress = () => { if (!player?.getCurrentTime) return; const currentTime = player.getCurrentTime(); const matchedIndex = buttons.findIndex((button) => { const start = Number(button.dataset.start); const end = Number(button.dataset.end); return currentTime >= start && currentTime < end; }); if (matchedIndex !== -1 && matchedIndex !== activeIndex) { activeIndex = matchedIndex; buttons.forEach((button, index) => { button.classList.toggle('is-active', index === activeIndex); button.style.setProperty('--progress', '0deg'); }); } const { btn, start, end } = getChapter(activeIndex); if (currentTime >= end) { const nextIndex = activeIndex >= buttons.length - 1 ? 0 : activeIndex + 1; setActiveChapter(nextIndex, true); return; } const progress = (currentTime - start) / (end - start); btn.style.setProperty('--progress', `${clamp(progress, 0, 1) * 360}deg`); }; const startProgressLoop = () => { clearInterval(progressTimer); progressTimer = setInterval(updateProgress, 250); }; buttons.forEach((button, index) => { button.addEventListener('click', () => { setActiveChapter(index, true); startProgressLoop(); }); }); loadYouTubeAPI(); } function initVideoChapterScroll() { const section = document.querySelector('[data-video-chapters]'); if (!section) return; const scrollArea = section.querySelector('.nh-video__buttons'); const arrow = section.querySelector('[data-video-scroll-arrow]'); if (!scrollArea || !arrow) return; const mobileMedia = window.matchMedia('(max-width: 1080px)'); const state = { destination: 'bottom', resizeTimer: null, isProgrammaticScrolling: false }; const getMaxScroll = () => { return Math.max( 0, scrollArea.scrollHeight - scrollArea.clientHeight ); }; const resetToTop = () => { if (!mobileMedia.matches) return; scrollArea.scrollTop = 0; state.destination = 'bottom'; state.isProgrammaticScrolling = false; arrow.classList.remove('is-up', 'is-hidden'); arrow.setAttribute( 'aria-label', '移至最後一個影片章節' ); }; const updateArrowState = () => { if (!mobileMedia.matches) { arrow.classList.remove('is-up', 'is-hidden'); return; } const maxScroll = getMaxScroll(); if (maxScroll <= 1) { arrow.classList.add('is-hidden'); return; } const tolerance = 4; const isAtTop = scrollArea.scrollTop <= tolerance; const isAtBottom = scrollArea.scrollTop >= maxScroll - tolerance; if (isAtTop) { state.destination = 'bottom'; arrow.classList.remove('is-up', 'is-hidden'); arrow.setAttribute( 'aria-label', '移至最後一個影片章節' ); return; } if (isAtBottom) { state.destination = 'top'; arrow.classList.add('is-up'); arrow.classList.remove('is-hidden'); arrow.setAttribute( 'aria-label', '移至第一個影片章節' ); return; } arrow.classList.add('is-hidden'); }; const scrollToDestination = () => { if (!mobileMedia.matches) return; const shouldGoToBottom = state.destination === 'bottom'; state.isProgrammaticScrolling = true; scrollArea.scrollTo({ top: shouldGoToBottom ? getMaxScroll() : 0, behavior: 'smooth' }); }; const handleScroll = () => { updateArrowState(); const maxScroll = getMaxScroll(); const tolerance = 4; const reachedTop = scrollArea.scrollTop <= tolerance; const reachedBottom = scrollArea.scrollTop >= maxScroll - tolerance; if (reachedTop || reachedBottom) { state.isProgrammaticScrolling = false; } }; arrow.addEventListener('click', scrollToDestination); scrollArea.addEventListener('scroll', handleScroll, { passive: true }); mobileMedia.addEventListener('change', () => { if (mobileMedia.matches) { requestAnimationFrame(resetToTop); } else { scrollArea.scrollTop = 0; arrow.classList.remove('is-up', 'is-hidden'); } }); window.addEventListener('resize', () => { clearTimeout(state.resizeTimer); state.resizeTimer = setTimeout(() => { if (!mobileMedia.matches) return; updateArrowState(); }, 150); }); window.addEventListener('pageshow', () => { requestAnimationFrame(() => { requestAnimationFrame(resetToTop); }); }); requestAnimationFrame(() => { requestAnimationFrame(resetToTop); }); } // ========================= // Text Reveal // ========================= function initTextReveal() { document.querySelectorAll('.js-text-reveal').forEach(splitRevealText); } function splitRevealText(el) { const lines = el.querySelectorAll('.reveal-line'); let index = 0; if (lines.length) { lines.forEach((line, lineIndex) => { const text = line.textContent.trim(); line.innerHTML = [...text].map((char) => { return `${char}`; }).join(''); }); } else { const inner = el.querySelector('.text-reveal__inner') || el; const text = inner.textContent.trim(); inner.innerHTML = [...text].map((char) => { return `${char}`; }).join(''); } el.classList.add('is-ready'); } // ========================= // Reveal Observers // ========================= function initRevealObservers(app) { const revealObserver = createRevealObserver('is-active', 0.4); const upgradeDecoObserver = createRevealObserver('is-active', 0.35); const reviewHexObserver = createSingleTargetObserver({ target: document.querySelector('.js-review-hex'), className: 'is-active', threshold: 0.35 }); const footerObserver = createSingleTargetObserver({ target: document.querySelector('.site-footer'), className: 'is-active', threshold: 0.2 }); const sectionBgObserver = createSingleTargetObserver({ target: document.querySelector('.section-bg'), className: 'is-active', threshold: 0.2 }); app.startRevealAnimations = () => { document.querySelectorAll('.js-text-reveal, .js-underline-reveal').forEach((el) => { revealObserver.observe(el); }); document.querySelectorAll('.js-upgrade-deco').forEach((block) => { upgradeDecoObserver.observe(block); }); const reviewSection = document.querySelector('.reviews'); const reviewHex = document.querySelector('.js-review-hex'); const footer = document.querySelector('.site-footer'); const sectionBg = document.querySelector('.section-bg'); if (reviewSection && reviewHex) { reviewHexObserver.observe(reviewSection); } if (footer) { footerObserver.observe(footer); } if (sectionBg) { sectionBgObserver.observe(sectionBg); } }; } function createRevealObserver(className, threshold) { return new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; entry.target.classList.add(className); observer.unobserve(entry.target); }); }, { threshold }); } function createSingleTargetObserver({ target, className, threshold }) { return new IntersectionObserver(([entry], observer) => { if (!entry.isIntersecting) return; target?.classList.add(className); observer.unobserve(entry.target); }, { threshold }); } // ========================= // Active Nav // ========================= function initActiveNav() { const navLinks = document.querySelectorAll( '.desktop-nav a, .tablet-menu__nav a, .mobile-menu__nav a' ); const sections = [ '#feature', '#recommend', '#review', '#faq', '#channel' ] .map((selector) => document.querySelector(selector)) .filter(Boolean); let isNavClickScrolling = false; let navClickTimer = null; const updateActiveNav = () => { if (isNavClickScrolling) return; const offset = window.innerHeight * 0.35; let currentId = sections[0]?.id; sections.forEach((section) => { const rect = section.getBoundingClientRect(); if (rect.top <= offset) { currentId = section.id; } }); setActiveById(navLinks, currentId); }; navLinks.forEach((link) => { link.addEventListener('click', () => { const targetId = link.getAttribute('href')?.replace('#', ''); const targetSection = document.getElementById(targetId); if (!targetId || !targetSection) return; isNavClickScrolling = true; setActiveById(navLinks, targetId); clearTimeout(navClickTimer); navClickTimer = setTimeout(() => { isNavClickScrolling = false; updateActiveNav(); }, 900); }); }); window.addEventListener('scroll', updateActiveNav); window.addEventListener('resize', updateActiveNav); window.addEventListener('load', updateActiveNav); updateActiveNav(); } function setActiveById(navLinks, id) { navLinks.forEach((link) => { link.classList.toggle( 'active', link.getAttribute('href') === `#${id}` ); }); } function setClickedNavActive(navLinks, activeLink) { const id = activeLink.getAttribute('href')?.replace('#', ''); navLinks.forEach((link) => { link.classList.toggle( 'active', link.getAttribute('href') === `#${id}` ); }); } // ========================= // Utilities // ========================= function clamp(value, min, max) { return Math.max(min, Math.min(value, max)); }