/* =======================================
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(
$('