/* ======================================= loading =========================================*/ const loadingHandler = () => { const removeLoading = (callback = () => { }) => { $(".loading").fadeOut(function () { $(this).remove(); callback(); }); }; const loadFunc = (callback) => { $("html, body").imagesLoaded(() => { removeLoading(() => { callback(); $(window).trigger("resize"); $(window).trigger("scroll"); }); }); }; return { loadFunc, }; }; $(() => { headerHandler(() => { const loading = loadingHandler(); loading.loadFunc(mainScript); }); }); /* Header include ----------------------------------------*/ const setStepnavActive = () => { const $header = $('#myheader'); const $items = $header.find('.stepnav .item'); if (!$items.length) { return; } const page = $('body').data('p'); const $active = page ? $items.filter(`[data-p="${page}"]`).first() : $(); $items.removeClass('is-active'); if ($active.length) { $active.addClass('is-active'); } }; const updateStepnavHeight = () => { const $stepnav = $('#myheader .stepnav-wrap'); if (!$stepnav.length) { return; } const stepnavH = `${$stepnav.outerHeight()}px`; $stepnav.css('--stepnav-h', stepnavH); document.documentElement.style.setProperty('--stepnav-h', stepnavH); }; const isMobileStepnav = () => { return window.matchMedia('(max-width: 767px)').matches; }; const isPreorderStackedLayout = () => { return window.matchMedia('(max-width: 1023px)').matches; }; const isPreorderHeaderFixedLayout = () => { return window.matchMedia('(min-width: 768px)').matches; }; const stepnavScrollHandler = () => { const $nav = $('#myheader .stepnav'); if (!$nav.length) { return; } if (!$nav.find('.stepnav-arrow--prev').length) { $nav.prepend(''); } if (!$nav.find('.stepnav-arrow--next').length) { $nav.append(''); } const $list = $nav.find('.list'); const $prev = $nav.find('.stepnav-arrow--prev'); const $next = $nav.find('.stepnav-arrow--next'); if (!$list.length) { return; } const getMaxScroll = () => { const el = $list.get(0); return Math.max(0, el.scrollWidth - el.clientWidth); }; const updateArrowState = () => { const maxScroll = getMaxScroll(); const scrollLeft = $list.scrollLeft(); const isScrollable = !isMobileStepnav() && maxScroll > 2; $nav.toggleClass('is-scrollable', isScrollable); $nav.toggleClass('has-prev', isScrollable && scrollLeft > 2); $nav.toggleClass('has-next', isScrollable && scrollLeft < maxScroll - 2); $prev.prop('disabled', !isScrollable || scrollLeft <= 2); $next.prop('disabled', !isScrollable || scrollLeft >= maxScroll - 2); }; const moveStepnav = (direction) => { const distance = Math.max(180, Math.round($list.outerWidth() * 0.45)); const maxScroll = getMaxScroll(); const target = Math.max(0, Math.min(maxScroll, $list.scrollLeft() + distance * direction)); $list.stop().animate({ scrollLeft: target }, 280, updateArrowState); }; $prev.off('click.stepnavScroll').on('click.stepnavScroll', () => { moveStepnav(-1); }); $next.off('click.stepnavScroll').on('click.stepnavScroll', () => { moveStepnav(1); }); $list.off('scroll.stepnavScroll').on('scroll.stepnavScroll', updateArrowState); $(window).off('resize.stepnavScroll').on('resize.stepnavScroll', updateArrowState); updateArrowState(); setTimeout(updateArrowState, 120); }; const stepnavMoreHandler = () => { const $header = $('#myheader'); const $more = $header.find('.stepnav-more'); const $toggle = $header.find('[data-p="more"] .stepnav-more-toggle'); const $dim = $more.find('.stepnav-more-dim'); if (!$more.length || !$toggle.length) { return; } updateStepnavHeight(); $(window).off('resize.stepnavMore').on('resize.stepnavMore', () => { updateStepnavHeight(); if (!isMobileStepnav()) { closeMoreMenu(); } }); const closeMoreMenu = () => { $more.removeClass('is-open').attr('aria-hidden', 'true'); $header.find('[data-p="more"]').removeClass('is-open'); $toggle.attr('aria-expanded', 'false'); $('body').removeClass('is-stepnav-more-open'); }; const openMoreMenu = () => { if (!isMobileStepnav()) { return; } updateStepnavHeight(); $more.addClass('is-open').attr('aria-hidden', 'false'); $header.find('[data-p="more"]').addClass('is-open'); $toggle.attr('aria-expanded', 'true'); $('body').addClass('is-stepnav-more-open'); }; closeMoreMenu(); $toggle.off('click.stepnavMore').on('click.stepnavMore', (e) => { e.preventDefault(); if (!isMobileStepnav()) { return; } if ($more.hasClass('is-open')) { closeMoreMenu(); } else { openMoreMenu(); } }); $dim.off('click.stepnavMore').on('click.stepnavMore', () => { if (!isMobileStepnav()) { return; } closeMoreMenu(); }); $more.find('.stepnav-more-list a').off('click.stepnavMore').on('click.stepnavMore', () => { closeMoreMenu(); }); $(document).off('keydown.stepnavMore').on('keydown.stepnavMore', (e) => { if (e.key === 'Escape' && $more.hasClass('is-open')) { closeMoreMenu(); } }); }; const headerHandler = (callback = () => { }) => { const $header = $('#myheader'); if (!$header.length) { callback(); return; } // Backend version: header markup is rendered by ASPX / Master Page. // Do not ajax-load cdn/include/header.html, but keep the required initialization flow. setStepnavActive(); stepnavMoreHandler(); stepnavScrollHandler(); callback(); }; /* ======================================= function =========================================*/ /* Promo 優惠橫幅輪播 ----------------------------------------*/ const promoSliderHandler = () => { const $promo = $('.promo'); const $slider = $promo.find('.slider'); const $bar = $promo.find('.bar'); const $barItems = $promo.find('.bar-item'); const PROMO_ANIM_MS = 280; let isDetailExpanded = false; if (!$slider.length) { return; } const refreshSlider = () => { if ($slider.hasClass('slick-initialized')) { $slider.slick('setPosition'); } }; let promoRefreshFrame = null; const queueSliderRefresh = () => { if (promoRefreshFrame !== null) { return; } promoRefreshFrame = requestAnimationFrame(() => { promoRefreshFrame = null; refreshSlider(); }); }; const animateDetail = ($detail, isOpening, onComplete = () => {}) => { const method = isOpening ? 'slideDown' : 'slideUp'; // Slick 的 adaptiveHeight 必須跟著內文高度逐幀更新, // 否則 PC 版會先露出一小段,等動畫結束後才突然撐開。 $detail.stop(true, false)[method]({ duration: 280, easing: 'swing', progress: queueSliderRefresh, complete: () => { refreshSlider(); onComplete(); }, }); }; const setItemDetailState = ($item, isOpen, animate = false, onComplete = () => {}) => { const $detail = $item.find('.detail'); $item.toggleClass('is-open', isOpen); $item.find('.btn').attr('aria-expanded', isOpen ? 'true' : 'false'); if (animate) { animateDetail($detail, isOpen, onComplete); return; } $detail.stop(true, true)[isOpen ? 'show' : 'hide'](); }; const normalizeSlideIndex = (index, slideCount) => { return ((Number(index) % slideCount) + slideCount) % slideCount; }; const syncExpandedSlides = (indexes, $animatedItem = $()) => { const slick = $slider.hasClass('slick-initialized') ? $slider.slick('getSlick') : null; isDetailExpanded = true; if (!slick) { $promo.find('.item').each(function () { const $item = $(this); setItemDetailState($item, true, $animatedItem.length > 0 && $item.is($animatedItem)); }); queueSliderRefresh(); return; } const expandedIndexes = indexes.map((index) => normalizeSlideIndex(index, slick.slideCount)); $promo.find('.slick-slide .item').each(function () { const $item = $(this); const slideIndex = normalizeSlideIndex( $item.closest('.slick-slide').attr('data-slick-index'), slick.slideCount ); const shouldOpen = expandedIndexes.includes(slideIndex); setItemDetailState( $item, shouldOpen, shouldOpen && $animatedItem.length > 0 && $item.is($animatedItem) ); }); queueSliderRefresh(); }; const collapseAll = (animateCurrent = true, resumeAutoplay = true) => { let $currentItem = $slider.find('.slick-current .item').first(); if (!$currentItem.length) { $currentItem = $promo.find('.item.is-open:visible').first(); } const shouldAnimateCurrent = animateCurrent && $currentItem.length > 0; const resumeSlider = () => { // 輪播複本可能在快速滑動時保留行內 display,收合完成後統一清乾淨。 $promo.find('.item:not(.is-open) .detail').stop(true, true).hide(); refreshSlider(); if (resumeAutoplay && $slider.hasClass('slick-initialized')) { $slider.slick('slickPlay'); } }; isDetailExpanded = false; $promo.find('.item').each(function () { const $item = $(this); const isAnimatedItem = shouldAnimateCurrent && $item.is($currentItem); setItemDetailState( $item, false, isAnimatedItem, isAnimatedItem ? resumeSlider : undefined ); }); queueSliderRefresh(); if (!shouldAnimateCurrent) { resumeSlider(); } }; const expandItem = ($item) => { // 展開狀態屬於整個 promo,而不是單一 slide。 // 同步同一則的無限輪播複本,左右滑動時便不會切到收合版本。 const slideIndex = $item.closest('.slick-slide').attr('data-slick-index') || 0; syncExpandedSlides([slideIndex], $item); if ($slider.hasClass('slick-initialized')) { $slider.slick('slickPause'); } }; const setActiveBar = (index) => { $barItems.removeClass('is-active').eq(index).addClass('is-active'); }; const showPromoBody = (animate = true) => { if (!$promo.hasClass('is-collapsed')) { return; } $promo.removeClass('is-collapsed is-promo-settled'); $(window).trigger('scroll.preorderSticky'); const onShown = () => { $promo.addClass('is-promo-settled'); refreshSlider(); $(window).trigger('resize.preorderSticky'); $(window).trigger('resize.buyLayout'); }; if (animate) { $slider.stop(true, true).slideDown(PROMO_ANIM_MS, onShown); $bar.stop(true, true).slideDown(PROMO_ANIM_MS); } else { $slider.show(); $bar.show(); onShown(); } if ($slider.hasClass('slick-initialized') && !isDetailExpanded) { $slider.slick('slickPlay'); } }; const hidePromoBody = (animate = true) => { if ($promo.hasClass('is-collapsed')) { return; } $promo.addClass('is-collapsed').removeClass('is-promo-settled'); collapseAll(false, false); $(window).trigger('scroll.preorderSticky'); const onHidden = () => { $promo.addClass('is-promo-settled'); refreshSlider(); $(window).trigger('resize.preorderSticky'); $(window).trigger('resize.buyLayout'); }; if (animate) { $bar.stop(true, true).slideUp(PROMO_ANIM_MS); $slider.stop(true, true).slideUp(PROMO_ANIM_MS, onHidden); } else { $bar.hide(); $slider.hide(); onHidden(); } if ($slider.hasClass('slick-initialized')) { $slider.slick('slickPause'); } }; if ($slider.children().length > 1) { $slider.slick({ slidesToShow: 1, slidesToScroll: 1, arrows: false, dots: false, infinite: true, autoplay: true, autoplaySpeed: 4000, speed: 400, adaptiveHeight: true, pauseOnHover: true, pauseOnFocus: true, }); $slider.on('beforeChange', (event, slick, currentSlide, nextSlide) => { if (!isDetailExpanded) { return; } // 若使用者在展開動畫尚未結束前滑動,先完成所有 slide 的狀態, // 避免目前項目與下一項目的高度不同步而跳動。 syncExpandedSlides([currentSlide, nextSlide]); $slider.slick('slickPause'); }); $slider.on('afterChange', (event, slick, currentSlide) => { setActiveBar(currentSlide); if (isDetailExpanded) { syncExpandedSlides([currentSlide]); $slider.slick('slickPause'); } }); $slider.on('init setPosition', () => { measurePromoCollapse(); updatePromoCollapse(); }); $barItems.on('click', function () { const index = Number($(this).data('index')); $slider.slick('slickGoTo', index); setActiveBar(index); }); } $promo.on('click', '.btn', function (e) { e.preventDefault(); const $item = $(this).closest('.item'); if (isDetailExpanded) { collapseAll(true, true); } else { showPromoBody(); expandItem($item); } }); // 往下捲動時自動收起(參考三星官網 buy 頁) let promoCollapseStart = 0; const measurePromoCollapse = () => { const wasCollapsed = $promo.hasClass('is-collapsed'); if (wasCollapsed) { $slider.show(); $bar.show(); } promoCollapseStart = $promo.offset().top; if (wasCollapsed) { $slider.hide(); $bar.hide(); } }; const updatePromoCollapse = () => { if (isPreorderStackedLayout()) { return; } const scrollTop = $(window).scrollTop(); const headerH = $('#myheader').outerHeight() || 0; const shouldCollapse = scrollTop > promoCollapseStart - headerH + 8; if (shouldCollapse) { hidePromoBody(); return; } showPromoBody(); }; const resetPromoForMobile = () => { $promo.removeClass('is-collapsed'); $slider.show(); $bar.show(); }; if (isPreorderStackedLayout()) { resetPromoForMobile(); } else { measurePromoCollapse(); updatePromoCollapse(); if (!$promo.hasClass('is-collapsed')) { $promo.addClass('is-promo-settled'); } } $(window).on('scroll.promoCollapse', updatePromoCollapse); $(window).on('resize.promoCollapse', () => { if (isPreorderStackedLayout()) { resetPromoForMobile(); return; } measurePromoCollapse(); updatePromoCollapse(); }); }; /* KV 輪播 ----------------------------------------*/ const kvSliderHandler = () => { const $slider = $('.kv .slider'); if (!$slider.length || $slider.children().length < 2) { return; } $slider.slick({ slidesToShow: 1, slidesToScroll: 1, arrows: true, dots: true, infinite: true, autoplay: true, autoplaySpeed: 5000, speed: 500, fade: true, cssEase: 'linear', pauseOnHover: true, pauseOnFocus: true, }); }; /* 預購頁浮動:pagehead / timebar ----------------------------------------*/ const preorderStickyHandler = () => { const $header = $('#myheader'); const $pagehead = $('#myheader .pagehead'); const $pageTimebars = $('.page-preorder .timebar'); if (!$pageTimebars.length) { return; } const $headerPh = $(''); const $pageheadPh = $(''); const timebarPhMap = new WeakMap(); $header.after($headerPh); if ($pagehead.length) { $pagehead.after($pageheadPh); } $pageTimebars.each(function () { const $ph = $(''); $(this).after($ph); timebarPhMap.set(this, $ph); }); const getActiveTimebar = () => { if (isPreorderStackedLayout()) { const $mo = $('.step1 .timebar:not(._pc)').first(); return $mo.length ? $mo : $pageTimebars.first(); } const $pc = $('.step1 .timebar._pc').first(); return $pc.length ? $pc : $pageTimebars.first(); }; const getTimebarPh = ($timebar) => { return timebarPhMap.get($timebar[0]) || $(); }; const clearInactiveTimebars = ($activeTimebar) => { $pageTimebars.not($activeTimebar).each(function () { const $tb = $(this); const $ph = getTimebarPh($tb); $tb.removeClass('is-fixed is-scrolled is-enter'); $tb.css('top', ''); $ph.hide(); }); }; const isStep1PcTimebar = ($timebar) => { return !isPreorderStackedLayout() && $timebar.closest('.step1').length && $timebar.hasClass('_pc'); }; let pageheadStart = 0; let timebarStart = 0; const isPromoCollapsed = () => { return !isPreorderStackedLayout() && $('.step1 .promo').hasClass('is-collapsed'); }; const measureStarts = () => { const $timebar = getActiveTimebar(); const $timebarPh = getTimebarPh($timebar); const headerWasFixed = $header.hasClass('is-fixed'); const pageWasFixed = $pagehead.hasClass('is-fixed'); const timeWasFixed = $timebar.hasClass('is-fixed'); const timeWasScrolled = $timebar.hasClass('is-scrolled'); if (headerWasFixed) { $header.removeClass('is-fixed'); $headerPh.hide(); } if (pageWasFixed) { $pagehead.removeClass('is-fixed'); $pageheadPh.hide(); } if (timeWasFixed) { $timebar.removeClass('is-fixed is-enter'); $timebar.css('top', ''); $timebarPh.hide(); } if (timeWasScrolled) { $timebar.removeClass('is-scrolled'); } if ($pagehead.length) { pageheadStart = $pagehead.offset().top; } timebarStart = $timebar.offset().top; if (headerWasFixed) { $header.addClass('is-fixed'); $headerPh.show().height($header.outerHeight()); } if (pageWasFixed) { $pagehead.addClass('is-fixed'); $pageheadPh.show().height($pagehead.outerHeight()); } if (timeWasFixed) { $timebar.addClass('is-fixed'); $timebarPh.show().height($timebar.outerHeight()); } if (timeWasScrolled) { $timebar.addClass('is-scrolled'); } }; const updateSticky = () => { const $timebar = getActiveTimebar(); const $timebarPh = getTimebarPh($timebar); const scrollTop = $(window).scrollTop(); const timebarH = $timebar.outerHeight(); const isPc = !isPreorderStackedLayout(); const isHeaderFixedLayout = isPreorderHeaderFixedLayout(); clearInactiveTimebars($timebar); const setMobileTimebarFixed = (isFixed, topValue = '0px') => { if (isFixed) { const isNewFixed = !$timebar.hasClass('is-fixed'); $timebar.addClass('is-fixed'); $timebar.css('top', topValue); $timebarPh.show().height(timebarH); if (isNewFixed) { $timebar.removeClass('is-enter'); requestAnimationFrame(() => { requestAnimationFrame(() => { $timebar.addClass('is-enter'); }); }); } else { $timebar.addClass('is-enter'); } return; } $timebar.removeClass('is-fixed is-enter'); $timebar.css('top', ''); $timebarPh.hide(); }; if (isHeaderFixedLayout) { const headerH = $header.outerHeight() || 0; const promoCollapsed = isPromoCollapsed(); let headerFixed; let timeFixed; if ($pagehead.length) { $pagehead.removeClass('is-fixed'); $pageheadPh.hide(); } if (isPc && isStep1PcTimebar($timebar)) { headerFixed = promoCollapsed; timeFixed = promoCollapsed; $timebar.toggleClass('is-scrolled', scrollTop > 0); } else { timeFixed = scrollTop >= timebarStart; headerFixed = timeFixed; } $header.toggleClass('is-fixed', headerFixed); $headerPh.toggle(headerFixed).height(headerH); document.documentElement.style.setProperty( '--pagehead-h', headerFixed ? `${headerH}px` : '0px' ); $timebar.toggleClass('is-fixed', timeFixed); $timebar.css('top', ''); $timebarPh.toggle(timeFixed).height(timebarH); return; } $header.removeClass('is-fixed'); $headerPh.hide(); // MO:pagehead 固定最上方,timebar 固定在其下方 const pageheadH = $pagehead.length ? $pagehead.outerHeight() : 0; if ($pagehead.length) { const pageFixed = scrollTop >= pageheadStart; document.documentElement.style.setProperty('--pagehead-h', pageFixed ? `${pageheadH}px` : '0px'); $pagehead.toggleClass('is-fixed', pageFixed); $pageheadPh.toggle(pageFixed).height(pageheadH); const stickyTop = pageFixed && $pagehead[0] ? Math.max(0, Math.round($pagehead[0].getBoundingClientRect().bottom)) : 0; const timeFixed = scrollTop >= timebarStart - (pageFixed ? pageheadH : 0); setMobileTimebarFixed(timeFixed, `${stickyTop}px`); return; } document.documentElement.style.setProperty('--pagehead-h', '0px'); const timeFixed = scrollTop >= timebarStart; setMobileTimebarFixed(timeFixed); }; const refresh = () => { measureStarts(); updateSticky(); }; refresh(); $(window).on('scroll.preorderSticky', updateSticky); $(window).on('resize.preorderSticky', refresh); }; const headerFixedFallbackHandler = () => { const $header = $('#myheader'); if (!$header.length || $('.page-preorder .timebar').length) { return; } const $headerPh = $(''); let headerStart = 0; $header.after($headerPh); const measureStart = () => { const wasFixed = $header.hasClass('is-fixed'); if (wasFixed) { $header.removeClass('is-fixed'); $headerPh.hide(); } headerStart = $header.offset().top; if (wasFixed) { $header.addClass('is-fixed'); $headerPh.show().height($header.outerHeight()); } }; const updateFixed = () => { const headerH = $header.outerHeight() || 0; const isFixed = $(window).scrollTop() > headerStart; $header.toggleClass('is-fixed', isFixed); $headerPh.toggle(isFixed).height(headerH); document.documentElement.style.setProperty( '--pagehead-h', isFixed ? `${headerH}px` : '0px' ); }; const refresh = () => { measureStart(); updateFixed(); }; refresh(); $(window).on('scroll.headerFixedFallback', updateFixed); $(window).on('resize.headerFixedFallback', refresh); }; /* step1 PC:左 KV sticky + body 捲軸(buy-layout 僅包住左右欄) ----------------------------------------*/ let scrollPreorderPreviewIntoView = () => { }; const buyLayoutHandler = () => { const $step1 = $('.step1'); const $layout = $step1.find('.buy-layout'); if (!$layout.length) { return; } const updateStickyTop = () => { if (isPreorderStackedLayout()) { document.documentElement.style.removeProperty('--buy-sticky-top'); return; } const $header = $('#myheader'); const $timebar = $step1.find('.timebar._pc'); const headerH = $header.outerHeight() || 0; const timebarH = $timebar.outerHeight() || 0; const promoCollapsed = $step1.find('.promo').hasClass('is-collapsed'); let top = 0; if (promoCollapsed && $header.hasClass('is-fixed')) { top = headerH + ($timebar.hasClass('is-fixed') ? timebarH : 0); } else if ($timebar.length) { top = Math.max(0, $timebar[0].getBoundingClientRect().bottom); } document.documentElement.style.setProperty('--buy-sticky-top', `${top}px`); }; const PREVIEW_FADE_MS = 450; const PREVIEW_COLOR_TARGET_OFFSET = 48; const PREVIEW_COLOR_FADE_START_OFFSET = 260; // Fade in the PC preview as the color block approaches the sticky form area. const getPreviewFadeProgress = () => { const $colorBlock = $step1.find('.rightside .formbody > .block.color'); if (!$colorBlock.length) { return 0; } const blockRect = $colorBlock[0].getBoundingClientRect(); if (blockRect.height < 40) { return 0; } const stickyTop = parseFloat(document.documentElement.style.getPropertyValue('--buy-sticky-top')) || 0; const fadeStart = stickyTop + PREVIEW_COLOR_FADE_START_OFFSET; const fadeEnd = stickyTop + PREVIEW_COLOR_TARGET_OFFSET; const fadeRange = Math.max(fadeStart - fadeEnd, 1); if (blockRect.top > fadeStart) { return 0; } return Math.min(1, Math.max(0, (fadeStart - blockRect.top) / fadeRange)); }; scrollPreorderPreviewIntoView = () => { if (isPreorderStackedLayout() || $step1.hasClass('is-form-locked')) { return; } const $colorBlock = $step1.find('.rightside .formbody > .block.color'); if (!$colorBlock.length) { return; } updateStickyTop(); const blockRect = $colorBlock[0].getBoundingClientRect(); if (blockRect.height < 40) { return; } const stickyTop = parseFloat(document.documentElement.style.getPropertyValue('--buy-sticky-top')) || 0; const targetColorTop = stickyTop + PREVIEW_COLOR_TARGET_OFFSET; const scrollDistance = blockRect.top - targetColorTop; if (scrollDistance <= 1) { return; } const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; window.scrollBy({ top: Math.ceil(scrollDistance), behavior: reduceMotion ? 'auto' : 'smooth' }); }; const finishPreviewReset = ($leftside, $pcPreview) => { clearTimeout($leftside.data('previewFadeTimer')); $leftside.removeData('previewFadeTimer'); $leftside.css('--preview-fade', '0').removeClass('is-preview-fading'); $pcPreview .removeClass('is-active') .attr('aria-hidden', 'true'); $pcPreview.find('.morebtn').attr('tabindex', '-1'); }; const resetProductPreview = (animate = false) => { const $leftside = $step1.find('.leftside'); const $pcPreview = $leftside.find('.previewbox._pc'); const currentFade = parseFloat($leftside.css('--preview-fade')) || 0; const runningFadeTimer = $leftside.data('previewFadeTimer'); if (animate && runningFadeTimer && $leftside.hasClass('is-preview-fading')) { return; } clearTimeout(runningFadeTimer); if (animate && currentFade > 0.01 && $leftside.hasClass('is-preview-fading')) { $leftside.css('--preview-fade', '0'); $pcPreview .removeClass('is-active') .attr('aria-hidden', 'true'); $pcPreview.find('.morebtn').attr('tabindex', '-1'); const timer = setTimeout(() => { finishPreviewReset($leftside, $pcPreview); }, PREVIEW_FADE_MS); $leftside.data('previewFadeTimer', timer); return; } finishPreviewReset($leftside, $pcPreview); }; const updateProductPreview = () => { const $leftside = $step1.find('.leftside'); const $pcPreview = $leftside.find('.previewbox._pc'); const $promo = $step1.find('.promo'); const $capacityBlock = $step1.find('.rightside .formbody > .block.capacity'); if (isPreorderStackedLayout()) { resetProductPreview(); return; } if (!$capacityBlock.length || !$pcPreview.length) { resetProductPreview(); return; } const hasSelectedColor = $step1 .find('.rightside .block.color .coloritem.is-active:not(.is-disabled)') .length > 0; if ($step1.hasClass('is-form-locked') || !hasSelectedColor) { resetProductPreview(); return; } // promo 未收起、或收起動畫進行中:維持 KV if (!$promo.hasClass('is-collapsed') || !$promo.hasClass('is-promo-settled')) { resetProductPreview($leftside.hasClass('is-preview-fading')); return; } const fade = getPreviewFadeProgress(); if (fade <= 0) { resetProductPreview($leftside.hasClass('is-preview-fading')); return; } $leftside.addClass('is-preview-fading'); $leftside.css('--preview-fade', String(fade)); const isActive = fade >= 0.5; $pcPreview .toggleClass('is-active', isActive) .attr('aria-hidden', isActive ? 'false' : 'true'); $pcPreview.find('.morebtn').attr('tabindex', isActive ? '0' : '-1'); }; const refresh = () => { updateStickyTop(); updateProductPreview(); }; refresh(); $(window).on('resize.buyLayout', refresh); $(window).on('scroll.preorderSticky scroll.promoCollapse scroll.buyLayout', refresh); }; /* 產品 Tab & 選項連動 ----------------------------------------*/ const isPreorderFormLocked = () => { return $('.step1').hasClass('is-form-locked'); }; let updatePreorderCart = () => { }; let syncPreorderColors = () => { }; const CART_SLIDE_MS = 550; const PREORDER_PRODUCT_META = { '1': { category: 'phone', icon: 'tab_phone' }, '4': { category: 'watch', icon: 'tab_watch' } }; const PREORDER_FEATURES = { zfold8ultra: [ { icon: 'zfold8ultra_feature_1.png', title: '極型極薄' }, { icon: 'zfold8ultra_feature_2.png', title: '多視窗作業' }, { icon: 'zfold8ultra_feature_3.png', title: '極致相機' } ], zfold8: [ { icon: 'zfold8_feature_1.png', title: '全新外形' }, { icon: 'zfold8_feature_2.png', title: '輕巧設計' }, { icon: 'zfold8_feature_3.png', title: '雙 50MP 鏡頭' } ], zflip8: [ { icon: 'zflip8_feature_1.png', title: 'FlexWindow 封面螢幕' }, { icon: 'zflip8_feature_2.png', title: '輕巧便攜' }, { icon: 'zflip8_feature_3.png', title: '50MP FlexCam 多角度拍攝' } ], watchultra2: [ { icon: 'watchultra2_feature_1.svg', title: '長效電力' }, { icon: 'watchultra2_feature_2.svg', title: '極致耐用' }, { icon: 'watchultra2_feature_3.svg', title: '戶外運動' } ], watch9: [ { icon: 'watch9_feature_1.svg', title: '長效電力' }, { icon: 'watch9_feature_2.svg', title: '健康管理' }, { icon: 'watch9_feature_3.svg', title: 'AI神隊友' } ] }; const SWATCH_COLORS = { zfold8ultra_gray: '#30373f', zfold8ultra_purple: '#3f283c', zfold8ultra_white: '#ffffff', zfold8ultra_green: '#375144', zfold8_gray: '#30373f', zfold8_white: '#ffffff', zfold8_purple: '#e2d9ea', zfold8_green: '#cededd', zflip8_gray: '#30373f', zflip8_white: '#ffffff', zflip8_pink: '#fcdfe4', zflip8_green: '#f4f8ea', watchultra2_gray: '#383435', watchultra2_silver: '#959595', watch44mm_gray: '#303135', watch44mm_silver: '#afafaf', watch40mm_gray: '#303135', watch40mm_white: '#efefef' }; const normalizeAssetPath = (path) => { if (!path) { return ''; } return String(path).replace(/^\//, ''); }; const getApiImages = (images = []) => { return images.map(normalizeAssetPath).filter(Boolean); }; const getDisplayImages = (apiImages = []) => { const resolver = window.preorderFrontendConfig?.resolveProductImages; if (typeof resolver === 'function') { const resolvedImages = resolver(apiImages); return Array.isArray(resolvedImages) ? resolvedImages.filter(Boolean) : apiImages; } return apiImages; }; const getDisplayPreviewImage = (apiImages = []) => { return getDisplayImages(apiImages)[0] || ''; }; const parseJsonAttr = ($el, attr) => { const raw = $el.attr(attr); if (!raw) { return []; } try { return JSON.parse(raw); } catch (error) { return []; } }; const formatPreorderPrice = (price, options = {}) => { const { withSuffix = true } = options; if (typeof price === 'string') { const digits = price.replace(/[^\d]/g, ''); const suffix = withSuffix && price.includes('起') ? '起' : ''; return `NT$${Number(digits).toLocaleString('zh-TW')}${suffix}`; } return `NT$${Number(price).toLocaleString('zh-TW')}`; }; const parsePriceNumber = (value = '') => { const digits = String(value).replace(/[^\d]/g, ''); return digits ? Number(digits) : 0; }; const formatCartPrice = (value = 0) => { return `NT$${Number(value).toLocaleString('zh-TW')}`; }; const normalizeModelKey = (value = '') => { return String(value || '').trim(); }; const isSameModel = (a = '', b = '') => { return normalizeModelKey(a) === normalizeModelKey(b); }; const hasOfferPrice = (offerText = '') => { return String(offerText).trim() !== '' && parsePriceNumber(offerText) > 0; }; const buildCartPriceHtml = (priceText = '', offerText = '') => { const originalPrice = parsePriceNumber(priceText); const offerPrice = parsePriceNumber(offerText); if (!hasOfferPrice(offerText)) { return escapeHtml(priceText); } if (!originalPrice || originalPrice === offerPrice) { return escapeHtml(formatCartPrice(offerPrice)); } return `${escapeHtml(formatCartPrice(offerPrice))}${escapeHtml(formatCartPrice(originalPrice))}`; }; const formatModelDisplayName = (txt = '') => { return String(txt).replace(/\sBT$/, ' 藍牙'); }; const escapeHtml = (value = '') => { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; const getSwatchColor = (css) => { if (!css) { return '#9e9e9e'; } return SWATCH_COLORS[css] || '#9e9e9e'; }; const getWatch9SwatchCss = (colorName = '', capacityName = '') => { const color = String(colorName).trim(); const capacity = String(capacityName).trim(); if (/44\s*mm/i.test(capacity)) { if (color === '星石灰') return 'watch44mm_gray'; if (color === '冰川銀') return 'watch44mm_silver'; return ''; } if (/40\s*mm/i.test(capacity)) { if (color === '星石灰') return 'watch40mm_gray'; if (color === '星光白') return 'watch40mm_white'; return ''; } return ''; }; const resolveSwatchCss = (css = '', options = {}) => { const { category = '', modelId = '', capacityName = '', colorName = '' } = options; if (category === 'watch' && String(modelId).includes('Watch9')) { return getWatch9SwatchCss(colorName, capacityName); } return css; }; const getPreorderFeatureKey = (model = '') => { const normalizedModel = String(model).trim().toLowerCase(); if (normalizedModel.includes('fold8 ultra')) { return 'zfold8ultra'; } if (normalizedModel.includes('fold8')) { return 'zfold8'; } if (normalizedModel.includes('flip8')) { return 'zflip8'; } if (normalizedModel.includes('watch ultra2')) { return 'watchultra2'; } if (normalizedModel.includes('watch9')) { return 'watch9'; } return 'zfold8ultra'; }; const renderPreorderFeaturebar = (model = '') => { const $featurebar = $('.block.color .featurebar'); if (!$featurebar.length) { return; } const features = PREORDER_FEATURES[getPreorderFeatureKey(model)] || PREORDER_FEATURES.zfold8ultra; $featurebar.html(features.map((feature) => { return `
  • ${escapeHtml(feature.title)}

  • `; }).join('')); }; const buildCapacityPickitem = (item, isActive = false) => { const soldOut = Number(item.qty) <= 0; const noteHtml = item.note ? `

    ${escapeHtml(item.note)}

    ` : (soldOut ? '

    已額滿。敬請挑選其他容量

    ' : ''); return `
  • ${noteHtml}
  • `; }; const buildPreorderApiUrl = (apiUrl, shopId = '') => { const $step1 = $('.step1'); const shopParam = $step1.attr('data-product-shop-param'); if (!shopId) return apiUrl; // ⭐ 只要包含 store/,直接強行回傳組合後的網址 if (apiUrl.includes('store/')) { return `${apiUrl.replace(/\/?$/, '/')}${encodeURIComponent(shopId)}`; } // 以下為舊有相容邏輯 if (!shopParam) return apiUrl; const url = new URL(apiUrl, window.location.href); url.searchParams.set(shopParam, shopId); return url.toString(); }; const preorderDataHandler = async (options = {}) => { const { shopId = '' } = options; const $step1 = $('.step1'); if (!$step1.length) { return false; } const sourceApiUrl = $step1.attr('data-product-api'); if (!sourceApiUrl) { return false; } const apiUrl = buildPreorderApiUrl(sourceApiUrl, shopId); let data; try { const response = await fetch(apiUrl); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } data = await response.json(); } catch (error) { console.error('preorderDataHandler: failed to load product data', error); return false; } const catalog = data && data.objectz ? data.objectz : null; if (!catalog) { console.error('preorderDataHandler: invalid product data'); return false; } const $productBlock = $('.block.product'); const $capacityBlock = $('.block.capacity'); const $colorBlock = $('.block.color'); const $tabs = $productBlock.find('.tabs'); const $phonePanel = $capacityBlock.find('.capacity-panel[data-category="phone"]'); const $watchPanel = $capacityBlock.find('.capacity-panel[data-category="watch"]'); $tabs.empty(); $productBlock.find('.product-panel').remove(); $phonePanel.empty(); $watchPanel.empty(); const products = (catalog.product || []).filter((item) => PREORDER_PRODUCT_META[item.id]); products.forEach((product, productIndex) => { const meta = PREORDER_PRODUCT_META[product.id]; const isActiveTab = productIndex === 0; const models = (catalog.model || []).filter((item) => item.product === product.id); $tabs.append(` `); const modelItems = models.map((model, modelIndex) => { const soldOut = Number(model.qty) <= 0; return `
  • `; }).join(''); $productBlock.append(` `); const $capacityTarget = meta.category === 'watch' ? $watchPanel : $phonePanel; const listClass = meta.category === 'watch' ? 'size-list' : 'phone-capacity-list'; models.forEach((model, modelIndex) => { const capacities = (catalog.capacity || []).filter((item) => { return String(item.product) === String(product.id) && isSameModel(item.model, model.id); }); const capacityItems = capacities.map((capacity, capacityIndex) => { return buildCapacityPickitem(capacity, capacityIndex === 0); }).join(''); $capacityTarget.append(` `); }); }); syncPreorderColors = () => { const $product = $('.block.product'); const $activeTab = $product.find('.tab.is-active'); const category = $activeTab.data('category'); const productId = String($activeTab.data('product-id') || ''); const $activeModel = $product.find(`.product-panel[data-category="${category}"] .pickitem.is-active`).first(); const modelId = $activeModel.data('model'); const $capacityPanel = $(`.block.capacity .capacity-panel[data-category="${category}"]`); const listSelector = category === 'watch' ? '.size-list:not(.is-hidden)' : '.phone-capacity-list:not(.is-hidden)'; const $activeCapacity = $capacityPanel.find(`${listSelector} .pickitem.is-active`).first(); const capacityId = $activeCapacity.data('capacity-id'); const capacityName = String($activeCapacity.attr('data-capacity-name') || $activeCapacity.find('.name').text() || '').trim(); const $colorList = $colorBlock.find('.colorlist'); const $previewImgs = $('.step1 .previewbox .preview img'); $colorList.empty(); renderPreorderFeaturebar(modelId); if (!productId || !modelId || !capacityId) { $previewImgs.attr('src', '').attr('alt', ''); updatePreorderCart(); $(window).trigger('resize.buyLayout'); return; } const colors = (catalog.color || []).filter((item) => { return String(item.product) === productId && isSameModel(item.model, modelId) && item.capacity === capacityId; }); const seen = new Set(); colors.forEach((color) => { const colorName = String(color.txt || '').trim(); const resolvedCss = resolveSwatchCss(color.css, { category, modelId, capacityName, colorName }); const seenKey = `${colorName}|${resolvedCss}`; if (!resolvedCss || seen.has(seenKey)) { return; } seen.add(seenKey); const soldOut = Number(color.qty) <= 0; const apiImages = getApiImages(color.img || []); const displayImages = getDisplayImages(apiImages); const previewImage = getDisplayPreviewImage(apiImages); const swatchColor = getSwatchColor(resolvedCss); const isWhiteSwatch = /^#(?:fff|ffffff)$/i.test(String(swatchColor).trim()); $colorList.append(`
  • ${escapeHtml(colorName)}

    ${soldOut ? '

    已額滿

    ' : ''}
  • `); }); const previewImage = $colorList.find('.coloritem').first().data('image') || ''; $previewImgs .attr('src', previewImage) .attr('alt', ''); updatePreorderCart(); $(window).trigger('resize.buyLayout'); }; syncPreorderColors(); $(window).trigger('resize.buyLayout'); return true; }; const playCartSlideIn = ($cart, onDone = () => { }) => { $cart.removeClass('is-visible'); $cart[0].offsetHeight; requestAnimationFrame(() => { requestAnimationFrame(() => { $cart.addClass('is-visible'); onDone(); }); }); }; const getPreorderCart = () => { return $('.preorder-cartbox').first(); }; const updatePreorderCartboxLayout = () => { updateStepnavHeight(); const $cart = getPreorderCart(); const h = !$cart.hasClass('is-hidden') ? $cart.outerHeight() : 0; document.documentElement.style.setProperty('--cartbox-h', `${h}px`); }; const preorderCartHandler = () => { const $cart = getPreorderCart(); const $colorBlock = $('.block.color'); const $tradein = $('.block.tradein'); const $product = $('.block.product'); if (!$cart.length) { return; } const getActiveCategory = () => { return $product.find('.tab.is-active').data('category') || 'phone'; }; const getActiveProduct = () => { const category = getActiveCategory(); return $product.find(`.product-panel[data-category="${category}"] .pickitem.is-active`).first(); }; const getActiveCapacity = () => { const category = getActiveCategory(); const $panel = $(`.block.capacity .capacity-panel[data-category="${category}"]`); if (category === 'watch') { return $panel.find('.size-list:not(.is-hidden) .pickitem.is-active').first(); } return $panel.find('.phone-capacity-list:not(.is-hidden) .pickitem.is-active').first(); }; const hasColorSelected = () => { return $colorBlock.find('.coloritem.is-active:not(.is-disabled)').length > 0; }; const hasProductSelected = () => { return getActiveProduct().length > 0; }; const hasCapacitySelected = () => { return getActiveCapacity().length > 0; }; const hasTradeinSelected = () => { return $tradein.find('.choiceitem.is-active').length > 0; }; const getCapacityLabel = () => { const category = getActiveCategory(); const $capacity = $('.block.capacity'); return category === 'watch' ? ($capacity.data('label-watch') || '尺寸') : ($capacity.data('label-phone') || '容量'); }; const getMissingFields = () => { const missing = []; if (isPreorderFormLocked()) { missing.push('取貨門市'); } if (!hasProductSelected()) { missing.push('產品'); } if (!hasCapacitySelected()) { missing.push(getCapacityLabel()); } if (!hasColorSelected()) { missing.push('顏色'); } if (!hasTradeinSelected()) { missing.push('舊換新'); } return missing; }; const isSelectionComplete = () => { return getMissingFields().length === 0; }; const updateCart = () => { const showCart = hasColorSelected() && !isPreorderFormLocked(); const canNext = showCart && isSelectionComplete(); const wasVisible = $cart.hasClass('is-visible'); if (showCart) { const $activeProduct = getActiveProduct(); const $activeCapacity = getActiveCapacity(); const $activeColor = $colorBlock.find('.coloritem.is-active').first(); const capacityName = String($activeCapacity.attr('data-capacity-name') || $activeCapacity.find('.name').text()).trim(); const noteText = String($activeCapacity.attr('data-capacity-note') || $activeCapacity.find('.note').text()).trim(); const specText = noteText || capacityName; const priceText = String($activeCapacity.attr('data-capacity-price') || $activeCapacity.find('.price').first().text()).trim(); const offerText = String($activeCapacity.attr('data-capacity-offer') || '').trim(); $cart.find('.name').text($activeProduct.find('.name').text().trim()); $cart.find('.spec').text(specText); $cart.find('.color').text($activeColor.find('.label').text().trim()); $cart.find('.price') .toggleClass('is-upgrade-price', hasOfferPrice(offerText)) .html(buildCartPriceHtml(priceText, offerText)); $cart.removeClass('is-hidden').attr('aria-hidden', 'false'); $('body').addClass('has-cartbox'); updatePreorderCartboxLayout(); if (!wasVisible) { playCartSlideIn($cart, updatePreorderCartboxLayout); } else { $cart.addClass('is-visible'); updatePreorderCartboxLayout(); } } else if (wasVisible) { $cart.removeClass('is-visible'); setTimeout(() => { if (!$cart.hasClass('is-visible')) { $cart.addClass('is-hidden').attr('aria-hidden', 'true'); $('body').removeClass('has-cartbox'); document.documentElement.style.setProperty('--cartbox-h', '0px'); } }, CART_SLIDE_MS); } else { $cart.addClass('is-hidden').attr('aria-hidden', 'true'); $('body').removeClass('has-cartbox'); document.documentElement.style.setProperty('--cartbox-h', '0px'); } $cart.find('.btnnext') .toggleClass('is-disabled', !canNext) .attr('aria-disabled', !canNext); }; updatePreorderCart = updateCart; $colorBlock.on('click', '.coloritem:not(.is-disabled) .swatch', function () { if (isPreorderFormLocked()) { return; } const $item = $(this).closest('.coloritem'); const image = $item.data('image'); const colorName = $item.data('color') || $item.find('.label').text().trim(); $colorBlock.find('.coloritem').removeClass('is-active'); $item.addClass('is-active'); if (image) { $('.step1 .previewbox .preview img') .attr('src', image) .attr('alt', `${$product.find('.product-panel:not(.is-hidden) .pickitem.is-active .name').text().trim() || '產品'} ${colorName}`.trim()); } updateCart(); $(window).trigger('resize.buyLayout'); requestAnimationFrame(scrollPreorderPreviewIntoView); }); $tradein.on('click', '.choiceitem .choicebtn', function () { if (isPreorderFormLocked()) { return; } const $item = $(this).closest('.choiceitem'); $tradein.find('.choiceitem').removeClass('is-active'); $item.addClass('is-active'); updateCart(); }); //購物車按鈕 $cart.on('click', '.btnnext', async function (e) { const missing = getMissingFields(); if (missing.length) { e.preventDefault(); alert(`請選擇以下項目:\n${missing.join('\n')}`); return; } const form = document.getElementById('form1'); // step1.html 是純前端預覽頁,沒有後端 form 時維持原本連結換頁。 if (!form) { return; } e.preventDefault(); const $button = $(this); if ($button.hasClass('is-submitting')) { return; } const setFormValue = (name, value) => { let $input = $(form).find(`[name="${name}"]`).first(); if (!$input.length) { $input = $('', { type: 'hidden', name }).appendTo(form); } $input.val(value == null ? '' : value); }; //舊換新 setFormValue('cIsExchange', $('.choiceitem.is-active .choicebtn').data('choice')); //門市 setFormValue('cBookStore_ID', $('#shop').val()); //顏色 const $finalColor = $colorBlock.find('.coloritem.is-active').first(); setFormValue( 'cProductItem_ID', $finalColor.attr('data-color-id') || $finalColor.attr('data-id') || '' ); let data; try { $('html').addClass('loadingz'); $button.addClass('is-submitting').attr('aria-disabled', 'true'); const formData = new URLSearchParams(new FormData(form)); const response = await fetch("api/preorder/cart", { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' // 模擬原本的表單送出格式 }, body: formData.toString() }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } data = await response.json(); // 4. 處理後端回傳結果 if (data.status < 0) { alert(data.message); } else { const nextUrl = $button.attr('href'); location.replace(nextUrl + "?uo=" + encodeURIComponent(data.objectz)); } } catch (error) { console.error('preorderCartHandler: failed to create cart', error); alert('網路連線失敗,請稍後再試'); } finally { $('html').removeClass('loadingz'); $button.removeClass('is-submitting').attr( 'aria-disabled', $button.hasClass('is-disabled') ? 'true' : 'false' ); } }); $(window).off('resize.preorderCart').on('resize.preorderCart', updatePreorderCartboxLayout); updateCart(); }; const storeFormHandler = () => { const $step1 = $('.step1'); const $city = $('#city'); const $shop = $('#shop'); const $storeTemp = $('select[name="cStore_Temp"]'); const $formbody = $('.formbody'); const $addr = $('.block.store .addr'); let shopLoadRequest = 0; if (!$city.length || !$shop.length) { return; } const fillShopOptions = (city) => { const $matchedStores = $storeTemp.find('option').filter(function () { return $(this).attr('data-c') === city; }); $shop.empty().append(''); $matchedStores.each(function () { const $store = $(this); $shop.append( $('