(() => { const { pageHelper, EventManager, utils, actions } = ShopbySkin; const eventDetailHelper = pageHelper.eventDetailHelper(); const containerEl = document.querySelector('[shopby-helper-key="event-detail-helper-key"]'); const { event: eventNoOrId } = utils.getQueryParams(location); const eventDetailRequest = { eventNo: eventNoOrId, sortType: 'ADMIN_SETTING', soldout: true, }; const redirectLoginPage = () => { const nextUrl = '/pages/sign-in/sign-in.html'; location.href = `${nextUrl}?from=${encodeURIComponent(location.href)}`; }; eventDetailHelper.initialize({ helperKey: 'event-detail-helper-key', }); const likeHelper = { toggleClassName: (likeEl) => { likeEl.classList.toggle('is-active'); }, toggleLikeStatus: async (likeEl) => { const productNo = likeEl.getAttribute('shopby-product-no') ?? 0; await actions.toggleLikeStatus({ productNo: Number(productNo), }); }, }; const CLICK_EVENT_HANDLER_MAP = { HANDLE_COUPON_BTN_CLICK: () => { if (!utils.isSignIn()) { ShopbySkin.EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'CAUTION', message: '로그인하셔야 본 서비스를 이용하실 수 있습니다.', onConfirm: redirectLoginPage, }); return; } const { coupon, eventNo } = document.querySelector('event-detail-product-list').store.state ?? {}; ShopbySkin.EventManager.fire('OPEN_LAYER_MODAL', { title: '쿠폰받기', name: 'coupon-download', data: { coupons: coupon?.coupons?.filter(({ downloadable }) => downloadable) ?? [], eventNo, }, isFull: false, onClose: () => null, }); }, HANDLE_SECTION_ID_CLICK: ({ target, helper }) => { const tabId = target.getAttribute('shopby-tab-id'); helper.eventAction.selectEventCategory(tabId); }, LIKE_PRODUCT: ({ target }) => { if (!utils.isSignIn()) { ShopbySkin.EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'CAUTION', message: '로그인하셔야 본 서비스를 이용하실 수 있습니다.', onConfirm: redirectLoginPage, }); return; } const likeEl = target.closest('[shopby-product-no]'); likeHelper.toggleClassName(likeEl); likeHelper.toggleLikeStatus(likeEl); }, }; const CHANGE_EVENT_HANDLER_MAP = { HANDLE_SORT_CHANGE: ({ target, helper }) => { helper.eventAction.fetchEventDetail({ ...eventDetailRequest, sortType: target.value, }); }, }; EventManager.fire('INIT_EVENT_PRODUCT_SECTION_SLIDER', { spaceBetween: 32, direction: 'horizontal', effect: 'slide', slidesPerView: 'auto', }); EventManager.on('PAGE_LOAD_COMPLETED', () => { eventDetailHelper.eventAction.fetchEventDetail(eventDetailRequest); }); const clickEventListener = ({ target }) => { const actionType = target.getAttribute('shopby-action') ?? target.closest('button')?.getAttribute('shopby-action'); CLICK_EVENT_HANDLER_MAP[actionType]?.({ helper: eventDetailHelper, target, }); }; const changeEventListener = ({ target }) => { const actionType = target.getAttribute('shopby-action'); CHANGE_EVENT_HANDLER_MAP[actionType]?.({ helper: eventDetailHelper, target, }); }; containerEl.addEventListener('click', clickEventListener); containerEl.addEventListener('change', changeEventListener); })(); /* ── 진열 섹션 스크롤형 노출 (전 기획전 · 섹션 2개 이상) ───────────────────── 섹션이 2개 이상인 기획전에서 탭 전환 대신 모든 섹션을 위아래로 나열하고, 상단 탭은 해당 섹션으로 스크롤 이동시킨다. 기획전 상세 응답에 전 섹션이 함께 내려오므로 추가 API 호출은 없다. 섹션 상품이 많으면 MORE_STEP 개씩 더보기로 노출. */ (() => { const MORE_STEP = 20; const SCROLL_GAP = 12; const STICKY_TOP = null; const SEL = { list: 'event-detail-product-list[platform]', grid: '.event-detail__products', navBtn: '.event-nav__btn', navBox: 'event-detail-display-menu', total: '.total-count', }; const container = document.querySelector('[shopby-helper-key="event-detail-helper-key"]'); if (!container) return; const listEl = () => document.querySelector(SEL.list); // SDK가 현재 탭 상품을 그리는 원본 컴포넌트 const getState = () => listEl()?.store?.state ?? {}; const navBtns = () => [...container.querySelectorAll(SEL.navBtn)]; const renderedNos = () => [...(listEl()?.querySelectorAll(`${SEL.grid} .thumb-item[shopby-product-no]`) ?? [])].map((el) => el.getAttribute('shopby-product-no')); const waitFor = (fn, timeout = 8000, interval = 50) => new Promise((resolve) => { const started = Date.now(); const tick = () => { let ok = false; try { ok = !!fn(); } catch (e) { ok = false; } if (ok) return resolve(true); if (Date.now() - started > timeout) return resolve(false); setTimeout(tick, interval); }; tick(); }); let topInset = 0; const initialInset = () => { if (STICKY_TOP !== null) return STICKY_TOP; const header = document.querySelector('.shopby-header, header'); if (!header) return 0; const { position } = getComputedStyle(header); return position === 'sticky' || position === 'fixed' ? Math.round(header.getBoundingClientRect().height) : 0; }; /* 화면 상단에 고정돼 있는 요소(헤더·띠배너 등)의 아래끝 = 탭바가 붙어야 할 위치. 스크롤 전에는 헤더가 원래 맨 위에 있어 구분이 안 되므로 스크롤된 뒤에만 측정한다. */ const measureTopInset = () => { if (window.scrollY < 150) return null; const navEl = container.querySelector(SEL.navBox); const centerX = Math.round(window.innerWidth / 2); let inset = 0; // 띠배너 → 헤더처럼 고정요소가 겹겹이 쌓일 수 있어 아래끝을 따라 내려가며 잰다 for (let depth = 0; depth < 5; depth += 1) { let next = inset; document.elementsFromPoint(centerX, inset + 1).forEach((el) => { if (el === navEl || navEl?.contains(el) || el.contains(navEl)) return; const { position } = getComputedStyle(el); if (position !== 'fixed' && position !== 'sticky') return; next = Math.max(next, Math.round(el.getBoundingClientRect().bottom)); }); if (next === inset) break; inset = next; } return inset; }; const applyTopInset = (value) => { if (value === null || value === topInset) return; topInset = value; document.documentElement.style.setProperty('--event-nav-top', `${topInset}px`); }; const stickyTop = () => topInset; /* 섹션 상품이 많을 때 MORE_STEP 개씩 끊어 보여준다 */ const applyMore = (block, grid) => { const items = [...grid.querySelectorAll('.thumb-item')]; if (items.length <= MORE_STEP) return; let shown = MORE_STEP; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'event-scroll__more'; btn.textContent = '더보기'; const render = () => { items.forEach((item, i) => item.style.setProperty('display', i < shown ? '' : 'none')); btn.hidden = shown >= items.length; }; btn.addEventListener('click', () => { shown = Math.min(shown + MORE_STEP, items.length); render(); }); render(); block.appendChild(btn); }; const buildSection = async (section, index, wrap) => { const products = section.products ?? []; if (!products.length) return true; const expected = products.map(({ productNo }) => String(productNo)); navBtns()[index]?.click(); const rendered = await waitFor(() => { const nos = renderedNos(); return nos.length === expected.length && nos.every((no, i) => no === expected[i]); }, 5000); if (!rendered) return false; const grid = listEl()?.querySelector(SEL.grid); if (!grid) return false; const block = document.createElement('section'); block.className = 'event-scroll__section'; block.dataset.sectionIndex = String(index); const title = document.createElement('h2'); title.className = 'event-scroll__title'; title.textContent = section.label ?? ''; const count = document.createElement('span'); count.className = 'event-scroll__count'; count.textContent = products.length; title.appendChild(count); const clonedGrid = grid.cloneNode(true); block.appendChild(title); block.appendChild(clonedGrid); applyMore(block, clonedGrid); wrap.appendChild(block); // 섹션이 완성되는 대로 바로 노출 return true; }; const setActive = (index) => { navBtns().forEach((btn, i) => btn.classList.toggle('is-active', i === index)); }; const bindTabs = (wrap) => { const blocks = () => [...wrap.querySelectorAll('.event-scroll__section')]; const offsetTop = () => stickyTop() + (container.querySelector(SEL.navBox)?.getBoundingClientRect().height ?? 0) + SCROLL_GAP; container.addEventListener( 'click', (e) => { const btn = e.target.closest?.(SEL.navBtn); if (!btn) return; e.preventDefault(); e.stopPropagation(); const index = navBtns().indexOf(btn); const target = blocks().find((block) => Number(block.dataset.sectionIndex) === index); if (!target) return; window.scrollTo({ top: window.scrollY + target.getBoundingClientRect().top - offsetTop(), behavior: 'smooth' }); // 스크롤 전에는 고정 헤더 높이를 알 수 없어, 이동 후 실제 값으로 한 번 보정한다 setTimeout(() => { applyTopInset(measureTopInset()); const drift = Math.round(target.getBoundingClientRect().top - offsetTop()); if (Math.abs(drift) > 4) window.scrollTo({ top: window.scrollY + drift, behavior: 'smooth' }); }, 600); }, true, ); const syncActive = () => { applyTopInset(measureTopInset()); const line = offsetTop() + 8; const current = blocks().filter((block) => block.getBoundingClientRect().top <= line).pop(); setActive(current ? Number(current.dataset.sectionIndex) : 0); }; window.addEventListener('scroll', syncActive, { passive: true }); syncActive(); }; const initialize = async () => { const ready = await waitFor( () => (getState().section ?? []).length > 1 && navBtns().length > 1 && listEl()?.querySelector(SEL.grid), ); if (!ready) return; const sections = getState().section ?? []; const sdkList = listEl(); const wrap = document.createElement('div'); wrap.className = 'event-scroll'; sdkList.style.visibility = 'hidden'; sdkList.style.height = '0'; sdkList.style.overflow = 'hidden'; sdkList.parentNode.insertBefore(wrap, sdkList); applyTopInset(initialInset()); document.body.classList.add('is-event-scroll'); container.querySelector(SEL.total)?.closest('event-detail-product-list')?.style.setProperty('display', 'none'); for (let i = 0; i < sections.length; i += 1) { const ok = await buildSection(sections[i], i, wrap); // eslint-disable-line no-await-in-loop if (ok) continue; // 한 섹션이라도 렌더 확인이 안 되면 기존 탭형으로 되돌린다 wrap.remove(); document.body.classList.remove('is-event-scroll'); container.querySelector(SEL.total)?.closest('event-detail-product-list')?.style.removeProperty('display'); sdkList.style.removeProperty('visibility'); sdkList.style.removeProperty('height'); sdkList.style.removeProperty('overflow'); navBtns()[0]?.click(); return; } sdkList.style.display = 'none'; navBtns()[0]?.click(); // 섹션 순회로 밀린 탭 슬라이더를 처음으로 되돌린다 window.scrollTo({ top: 0 }); bindTabs(wrap); }; initialize(); })();