(() => { const ELEMENT_SELECTOR = { HELPER_KEY: '[shopby-helper-key="cart-helper-key"]', ACTION: 'shopby-action', SELECTED_COUNT: 'shopby-selected-count', CHECKED_CART_NOS_AS_STRING: 'shopby-checked-cart-nos-as-string', DELIVERY_GROUP_IDX: 'shopby-delivery-group-idx', IS_INVALID_PRODUCT: 'shopby-is-invalid-product', CART_NO: 'shopby-cart-no', CART_PRODUCTS: 'cart-products-v2', CHECKBOX_PER_DELIVERY_GROUP_SLOT: '[slot="checkboxPerDeliveryGroup"]', CHECKBOX_PER_CART_NO_SLOT: '[slot="checkboxPerCartNo"]', }; const { pageHelper, EventManager, actions, utils } = ShopbySkin; const containerEl = document.querySelector(ELEMENT_SELECTOR.HELPER_KEY); const cartProductsEl = containerEl.querySelector(ELEMENT_SELECTOR.CART_PRODUCTS); const cartHelper = pageHelper.cartHelper(); cartHelper.initialize({ helperKey: 'cart-helper-key', }); const handleOrder = async ({ helper, cartNos, products }) => { const { data: { orderSheetNo }, } = await helper.orderSheetAction.makeOrderSheet({ cartNos, products, }); const ordersheetUri = `/pages/order/order-sheet-form.html?ordersheetNo=${orderSheetNo}`; if (utils.isSignedIn()) { location.href = ordersheetUri; } else { const url = new URL(location.href); url.pathname = '/pages/sign-in/sign-in.html'; url.searchParams.set('from', encodeURIComponent(ordersheetUri)); location.href = url.toString(); } }; const CLICK_EVENT_HANDLER_MAP = { TOGGLE_ALL: ({ helper, target }) => { helper.cartAction.updateIsAllChecked(target.checked); }, TOGGLE_CHECKBOX_PER_DELIVERY_GROUP: ({ helper, target }) => { const labelEl = target.closest(ELEMENT_SELECTOR.CHECKBOX_PER_DELIVERY_GROUP_SLOT); const deliveryGroupIdx = Number(labelEl.getAttribute(ELEMENT_SELECTOR.DELIVERY_GROUP_IDX) ?? -1); const isInvalidProduct = labelEl.getAttribute(ELEMENT_SELECTOR.IS_INVALID_PRODUCT) === 'true'; helper.cartAction.updateIsDeliveryGroupChecked({ deliveryGroupIdx: isInvalidProduct ? -1 : deliveryGroupIdx, isChecked: target.checked, }); }, TOGGLE_CHECKBOX_PER_CART_NO: ({ helper, target }) => { const labelEl = target.closest(ELEMENT_SELECTOR.CHECKBOX_PER_CART_NO_SLOT); helper.cartAction.updateIsCartNoChecked({ cartNo: Number(labelEl.getAttribute(ELEMENT_SELECTOR.CART_NO) ?? 0), isChecked: target.checked, }); }, DELETE_SELECTED_ITEMS: async ({ helper, target }) => { const selectedCount = Number(target.getAttribute(ELEMENT_SELECTOR.SELECTED_COUNT)); const checkedCartNos = target.getAttribute(ELEMENT_SELECTOR.CHECKED_CART_NOS_AS_STRING)?.split?.(',')?.map(Number) ?? []; if (!selectedCount) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'CAUTION', message: `선택하신 상품이 없습니다.`, }); return; } const { data } = await helper.cartAction.deletableRequiredOption(); if (!data) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'CAUTION', message: '선택옵션은 단독 주문이 불가합니다. 필수옵션 취소 필요 시 선택옵션을 먼저 취소해주시거나, 함께 취소해주세요.', }); return; } EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'WARNING', message: `선택하신 ${selectedCount}개의 상품을 삭제하시겠습니까?`, confirmLabel: '삭제', onConfirm: async () => { EventManager.fire('UPDATE_CART_PRODUCT'); await helper.cartAction.deleteCartNos(checkedCartNos); helper.cartAction.fetchCartDetail(); }, }); }, ORDER: ({ helper }) => { const { data: checkedCartNos } = helper.cartAction.fetchCheckedCartNos(); const { data: checkedProducts } = helper.cartAction.fetchCheckedProducts(); const invalidCartNos = Array.from( containerEl.querySelectorAll('[slot="deliveryGroup"][shopby-delivery-group-idx="-1"]') ).flatMap((el) => { const cartNoEls = el.querySelectorAll('[shopby-cart-no]'); return Array.from(cartNoEls).flatMap((el) => Number(el.getAttribute('shopby-cart-no'))); }); const hasInvalidCartNo = invalidCartNos.some((no) => checkedCartNos.includes(no)); const editingEl = containerEl.querySelector('[shopby-status="EDITING"]'); if (editingEl) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'CAUTION', message: `변경중인 옵션이 있습니다.`, onClose: () => { editingEl?.focus?.(); }, }); return; } if (!checkedCartNos.length) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'CAUTION', message: `선택된 상품이 없습니다.`, }); return; } if (hasInvalidCartNo) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'CAUTION', message: `선택하신 상품에 구매불가한 상품이 있습니다.`, }); return; } handleOrder({ cartNos: checkedCartNos, products: checkedProducts, helper, }); }, ORDER_ALL: async ({ helper }) => { const editingEl = containerEl.querySelector('[shopby-status="EDITING"]'); if (editingEl) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'CAUTION', message: `변경중인 옵션이 있습니다.`, onClose: () => { editingEl?.focus?.(); }, }); return; } const { data } = await helper.cartAction.fetchAllValidProducts(); const cartNos = data.map(({ cartNo }) => cartNo); handleOrder({ cartNos, products: data, helper, }); }, }; const clickEventListener = ({ target }) => { const actionType = target.getAttribute(ELEMENT_SELECTOR.ACTION); CLICK_EVENT_HANDLER_MAP[actionType]?.({ target, helper: cartHelper, }); }; containerEl.addEventListener('click', clickEventListener); // 네이버 주문 const naverPay = { action: null, usable: false, el: document.querySelector('#naver-pay'), getVisibility: (visible) => (visible ? 'visible' : 'hidden'), getTotalCount: (data) => Object.keys(data)?.length || 0, handleReceiverCheckingStatus: (data) => { const { el, action, getTotalCount, getVisibility, usable } = naverPay; const totalCount = getTotalCount(data); const showsNaverPayButton = usable && totalCount > 0; const visibility = getVisibility(showsNaverPayButton); if (el) { el.style.visibility = visibility; return; } if (!showsNaverPayButton) { return; } action.showNaverPayButton({ containerElementId: 'naver-pay', isCartPage: true, }); }, handleReceiverCheckedProducts: (data) => { const items = data.map(({ productNo, optionNo, orderCnt, optionInputs, channelType }) => ({ productNo, optionNo, orderCnt, optionInputs, channelType: channelType || utils.getQueryParams()?.channelType, })); naverPay.action.prepareNaverPay({ items }); }, setNaverPayAction: async ({ env, clientId, orderConfig }) => { naverPay.action = await actions.getNaverPayAction({ mallProfile: env, clientId, platform: utils.isMobileDevice ? 'MOBILE_WEB' : 'PC', }); naverPay.usable = Boolean(orderConfig.naverPay); }, }; EventManager.on('UPDATE_CART_PRODUCT', cartProductsEl?.showLoadingSpinner); EventManager.on('ON_UPDATED_CART_PRODUCT', cartProductsEl?.hideLoadingSpinner); EventManager.on('QUERY_CART_CHECKED_PRODUCTS', naverPay.handleReceiverCheckedProducts); EventManager.on('QUERY_CART_CHECKING_STATUS_PER_CART_NO', naverPay.handleReceiverCheckingStatus); EventManager.on('PAGE_LOAD_COMPLETED', (commonData) => { cartHelper.cartAction.fetchCartDetail(); naverPay.setNaverPayAction(commonData); // 외부스크립트 등록 기능. 삭제금지 ShopbyExternalScript?.setPageScriptType('CART'); }); EventManager.on('QUERY_CART_DETAIL', (cart) => { // 외부스크립트 등록 기능. 삭제금지 ShopbyExternalScript?.setGlobalObjectSb({ cart }); }); // ── 무료배송 안내 + 그룹 배송비 (추가 기능, 실패 시 조용히 숨김) ────── // 배송그룹이 1개면 상단 바, 2개 이상이면 상단 바를 숨기고 그룹 헤더 아래에 // 같은 디자인의 축소 바를 그룹마다 붙인다. // 배송비는 상품행이 아니라 그룹 헤더 우측에 "선택된 상품" 기준으로 표시해 // 하단 결제금액의 배송비 합계와 항상 같은 값이 되게 한다. (function freeShip() { const barEl = document.getElementById('gbFreeShip'); if (!barEl) return; const msgEl = barEl.querySelector('.gb-freeship__msg'); const fillEl = barEl.querySelector('.gb-freeship__fill'); const MINI_CLASS = 'gb-freeship--mini'; const FEE_CLASS = 'gb-group-fee'; const HAS_FEE_CLASS = 'gb-freeship-hasfree'; let groups = []; // 배송그룹별 { idx, free, threshold, baseAmt, payType, amtByCartNo } let keyIndex = {}; // productNo_optionNo → { groupIdx, amt } (cartNo 누락 대비) let checkedSet = null; // 선택된 cartNo Set (null=미초기화) let unitByCartNo = {}; // cartNo → 1개 금액 (수량 변경 즉시 반영용) const won = (n) => (n || 0).toLocaleString('ko-KR'); function rebuild(cart) { groups = []; keyIndex = {}; unitByCartNo = {}; ((cart && cart.deliveryGroups) || []).forEach((g, idx) => { const cond = g.deliveryCondition || {}; const conditional = cond.deliveryConditionType === 'CONDITIONAL' && cond.aboveDeliveryAmt > 0; const first = (g.orderProducts || [])[0] || {}; const group = { idx, free: cond.deliveryConditionType === 'FREE', threshold: conditional ? cond.aboveDeliveryAmt : 0, baseAmt: cond.baseDeliveryAmt || 0, apiAmt: g.deliveryAmt || 0, payType: g.deliveryPayType || first.deliveryPayType || '', notDeliverable: !!g.isAllNotDeliverable, amtByCartNo: {}, }; (g.orderProducts || []).forEach((op) => { (op.orderProductOptions || []).forEach((opt) => { const amt = (opt.price && (opt.price.buyAmt != null ? opt.price.buyAmt : opt.price.salePrice)) || 0; if (opt.cartNo != null) { group.amtByCartNo[opt.cartNo] = amt; if (opt.orderCnt > 0) unitByCartNo[opt.cartNo] = amt / opt.orderCnt; } if (opt.productNo != null && opt.optionNo != null) { keyIndex[opt.productNo + '_' + opt.optionNo] = { groupIdx: idx, amt }; } }); }); groups.push(group); }); } function subtotal(group) { if (!checkedSet) { // 미초기화(최초 로드): 그 그룹에 담긴 금액 합 return Object.keys(group.amtByCartNo).reduce((s, k) => s + group.amtByCartNo[k], 0); } let sum = 0; checkedSet.forEach((no) => { sum += group.amtByCartNo[no] || 0; }); return sum; } function hasSelection(group) { if (!checkedSet) return Object.keys(group.amtByCartNo).length > 0; let found = false; checkedSet.forEach((no) => { if (group.amtByCartNo[no] != null) found = true; }); return found; } // 그룹 헤더에 표시할 배송비 — 선택된 상품 기준이라 하단 합계와 값이 같다 function feeInfo(group) { if (group.notDeliverable || !hasSelection(group)) return null; const pay = group.payType === 'PAY_ON_DELIVERY' ? ' (착불)' : ' (선결제)'; if (group.free) return { free: true, text: '무료배송' }; if (group.threshold) { if (subtotal(group) >= group.threshold) return { free: true, text: '무료배송' }; return { free: false, text: '배송비 ' + won(group.baseAmt) + '원' + pay }; } if (!group.apiAmt) return { free: true, text: '무료배송' }; return { free: false, text: '배송비 ' + won(group.apiAmt) + '원' + pay }; } // 문구·아이콘·진행바는 상단 바와 축소 바가 동일 (축소 바만 뒤 문장 생략) function msgHtml(remain, mini) { if (remain <= 0) return '무료배송 적용!' + (mini ? '' : ' 지금 주문하면 배송비가 무료예요.'); return '' + won(remain) + '원만 더 담으면 무료배송'; } const percent = (sel, threshold) => Math.max(0, Math.min(100, Math.round((sel / threshold) * 100))); function renderTopBar() { // 배송그룹이 1개일 때만 상단 바 (2개 이상은 그룹별 축소 바로 대체) const only = groups.length === 1 ? groups[0] : null; if (!only || !only.threshold) { barEl.hidden = true; return; } const sel = subtotal(only); const remain = only.threshold - sel; fillEl.style.width = percent(sel, only.threshold) + '%'; barEl.classList.toggle('is-done', remain <= 0); msgEl.innerHTML = msgHtml(remain, false); barEl.hidden = false; } function miniBar(sel, threshold) { const remain = threshold - sel; const el = document.createElement('div'); el.className = 'gb-freeship ' + MINI_CLASS + (remain <= 0 ? ' is-done' : ''); el.innerHTML = '

' + msgHtml(remain, true) + '

' + '
'; return el; } function feeTag(info) { const el = document.createElement('span'); el.className = FEE_CLASS + (info.free ? ' is-free' : ''); el.textContent = info.text; return el; } function groupEl(idx) { return document.querySelector('[slot="deliveryGroup"][shopby-delivery-group-idx="' + idx + '"]'); } function mountMini(host, el) { const cell = host.querySelector('.cart-list__delivery-group') || host; cell.appendChild(el); } function clearGroupUi() { document.querySelectorAll('.' + MINI_CLASS + ', .' + FEE_CLASS).forEach((el) => el.remove()); document.querySelectorAll('.' + HAS_FEE_CLASS).forEach((el) => el.classList.remove(HAS_FEE_CLASS)); } function renderGroupUi() { clearGroupUi(); const multi = groups.length > 1; groups.forEach((g) => { const host = groupEl(g.idx); if (!host) return; const info = feeInfo(g); const label = host.querySelector('.form-check'); if (info && label) { label.classList.add(HAS_FEE_CLASS); label.appendChild(feeTag(info)); } if (g.free) return; // 무조건 무료 그룹: 바 없이 헤더 우측 텍스트만 if (!multi || !g.threshold) return; // 그룹 1개면 상단 바가 담당 mountMini(host, miniBar(subtotal(g), g.threshold)); }); } function render() { try { renderTopBar(); } catch (e) { barEl.hidden = true; } try { renderGroupUi(); } catch (e) { /* 유지 */ } } // 그룹 헤더 DOM 이 이벤트 시점에 아직 안 그려져 있을 수 있어 몇 프레임 뒤 재주입 function renderSoon() { render(); [0, 60, 250].forEach((ms) => setTimeout(() => { try { renderGroupUi(); } catch (e) { /* 유지 */ } }, ms)); } EventManager.on('QUERY_CART_DETAIL', (cart) => { try { rebuild(cart); renderSoon(); } catch (e) { barEl.hidden = true; } }); EventManager.on('QUERY_CART_CHECKED_PRODUCTS', (products) => { try { const list = Array.isArray(products) ? products : (products && products.data) || []; checkedSet = new Set(); list.forEach((p) => { if (p.cartNo != null) checkedSet.add(p.cartNo); else if (p.productNo != null && p.optionNo != null) { const info = keyIndex[p.productNo + '_' + p.optionNo]; if (info && groups[info.groupIdx]) { const alias = 'k_' + p.productNo + '_' + p.optionNo; groups[info.groupIdx].amtByCartNo[alias] = info.amt; checkedSet.add(alias); } } }); renderSoon(); } catch (e) { /* 유지 */ } }); // 수량·선택 변경은 서버 왕복(PUT /cart → GET /cart → 상품상세 → /cart/subset, // 실측 1.5초)이 끝나야 이벤트가 와서 안내가 뒤늦게 움직인다. 클릭 직후 화면의 // 수량·체크 상태로 금액을 다시 계산해 먼저 그리고, 서버 응답이 오면 rebuild 가 덮어쓴다. // 하단 결제 합계는 손대지 않는다(서버 값 그대로). function syncFromDom() { let changed = false; document.querySelectorAll('.counter input').forEach((input) => { const host = input.closest('[shopby-cart-no]'); if (!host) return; const cartNo = Number(host.getAttribute('shopby-cart-no')); const cnt = Number(input.value); const unit = unitByCartNo[cartNo]; if (!cartNo || !cnt || cnt < 1 || !unit) return; const group = groups.find((g) => g.amtByCartNo[cartNo] != null); if (!group || group.amtByCartNo[cartNo] === unit * cnt) return; group.amtByCartNo[cartNo] = unit * cnt; changed = true; }); // 체크박스 전부 cartNo 를 들고 있을 때만 선택 집합을 화면 기준으로 다시 만든다 const boxes = [...document.querySelectorAll('[slot="checkboxPerCartNo"]')]; if (boxes.length && boxes.every((el) => el.getAttribute('shopby-cart-no'))) { const next = new Set(); boxes.forEach((el) => { const input = el.querySelector('input[type="checkbox"]'); if (input && input.checked) next.add(Number(el.getAttribute('shopby-cart-no'))); }); if (!checkedSet || next.size !== checkedSet.size || [...next].some((no) => !checkedSet.has(no))) { checkedSet = next; changed = true; } } if (changed) render(); } // 스텝퍼 값·체크 상태는 클릭 직후 프레임에 반영되므로 몇 프레임 따라가며 확인한다 function syncSoon() { [0, 16, 40, 90, 180].forEach((ms) => setTimeout(() => { try { syncFromDom(); } catch (e) { /* 유지 */ } }, ms)); } document.addEventListener('click', syncSoon, true); document.addEventListener('change', syncSoon, true); })(); // ── 수량 스텝퍼 직접 변경 → 즉시 저장 (옵션/수량 변경 버튼 없이) ────── (function directQtyPersist() { let cartAction; try { cartAction = actions.getCartAction(); } catch (e) { return; } if (!cartAction || typeof cartAction.modifyCart !== 'function') return; const lineByCartNo = new Map(); // cartNo → { productNo, optionNo, optionInputs, savedCnt } const timers = new Map(); EventManager.on('QUERY_CART_DETAIL', (cart) => { try { (cart?.deliveryGroups || []).forEach((g) => (g.orderProducts || []).forEach((op) => (op.orderProductOptions || []).forEach((o) => { if (o.cartNo != null) { lineByCartNo.set(o.cartNo, { productNo: o.productNo, optionNo: o.optionNo, optionInputs: o.optionInputs || [], savedCnt: o.orderCnt, }); } }) ) ); } catch (e) { /* 무시 */ } }); const findCtrl = (el) => el.closest('[slot="quantityController"]') || el.closest('[shopby-cart-no][shopby-product-no]'); function persist(ctrlEl) { const cartNo = Number(ctrlEl.getAttribute('shopby-cart-no')); if (!cartNo) return; clearTimeout(timers.get(cartNo)); // 옵션 변경(EDITING) 중이면 기존 '변경완료' 플로우가 저장한다 if (ctrlEl.getAttribute('shopby-status') === 'EDITING') return; const input = ctrlEl.querySelector('.counter input'); const orderCnt = Number(input && input.value); if (!orderCnt || orderCnt < 1) return; const line = lineByCartNo.get(cartNo); if (line && line.savedCnt === orderCnt) return; // 변화 없음 const productNo = line ? line.productNo : Number(ctrlEl.getAttribute('shopby-product-no')); const optionNo = line ? line.optionNo : Number(ctrlEl.getAttribute('shopby-option-no')); if (!productNo || !optionNo) return; timers.set(cartNo, setTimeout(() => { try { cartAction.modifyCart([{ changedOption: false, optionInputs: line ? line.optionInputs : [], optionNo, orderCnt, productNo, cartNo, }]); } catch (e) { /* 무시 */ } }, 100)); } containerEl.addEventListener('click', (e) => { const btn = e.target.closest('.counter__btn'); if (!btn) return; const ctrl = findCtrl(btn); if (ctrl) setTimeout(() => persist(ctrl), 60); }); containerEl.addEventListener('change', (e) => { const input = e.target.closest('.counter input'); if (!input) return; const ctrl = findCtrl(input); if (ctrl) persist(ctrl); }); })(); // ── 재고 초과 수량 변경 안내 (SDK 는 재고로 조용히 깎기만 해서 사용자가 이유를 모름) ── (function stockLimitAlert() { // 샵바이는 재고 무제한을 stockCnt -999 로 표현한다 const UNLIMITED_STOCK_CNT = -999; const getMax = (el) => { const changer = el.closest('[slot="quantityChanger"]'); const max = Number(changer && changer.getAttribute('shopby-max')); return max > 0 && max !== UNLIMITED_STOCK_CNT ? max : 0; }; // 재고 노출 OFF 정책이라 남은 수량을 문구에 쓰지 않는다 const alertOverStock = () => EventManager.fire('MODAL_ALERT_OPEN', { message: '구매 가능한 수량을 초과했습니다.', modalAddClass: 'stock-limit-alert', }); // SDK 핸들러가 값을 깎기 전에 읽어야 해서 캡처 단계에서 검사 containerEl.addEventListener('click', (e) => { const btn = e.target.closest('.counter__btn'); if (!btn || !String(btn.getAttribute('shopby-on:click')).includes('INCREASE')) return; const max = getMax(btn); const input = btn.closest('[slot="quantityChanger"]').querySelector('input'); if (max && Number(input && input.value) >= max) alertOverStock(); }, true); containerEl.addEventListener('change', (e) => { const input = e.target.closest('.counter input'); if (!input) return; const max = getMax(input); if (max && Number(input.value) > max) alertOverStock(); }, true); })(); })();