(() => { const { pageHelper, EventManager, utils, actions, constants } = ShopbySkin; const { unescapeHTML } = utils; const { Handlebars } = ShopbySkin; Handlebars.registerHelper('floorDiscount', function (discountRate) { if (typeof discountRate === 'number') { return Math.floor(discountRate); } else { return 0; } }); const productOptionAction = actions.getProductOptionAction(); const productOptionEl = document.querySelector('product-purchase-v2'); const orderSheetAppCardEl = document.querySelector('order-sheet-app-card'); // 상품 정보 저장 변수 let currentProductData = null; // 추가구성상품 상태 관리 let extraProductsData = null; const extraProductSelections = new Map(); // key: ${productNo}_${optionNo} // 동적 JSON-LD 생성 함수 const createProductJsonLD = (product) => { // 기존 JSON-LD 제거 const existingJsonLD = document.querySelectorAll('script[type="application/ld+json"]'); existingJsonLD.forEach((script) => script.remove()); // 브랜드 추출 함수 const extractBrand = () => { const brandPatterns = { 닷모드: ['닷모드', 'dotmod', 'dot mod', 'dotaio'], 쥴: ['쥴', 'juul'], 아이코스: ['아이코스', 'iqos'], 필립모리스: ['필립모리스', 'philip morris'], 말보로: ['말보로', 'marlboro'], 던힐: ['던힐', 'dunhill'], 바포레소: ['바포레소', 'vaporesso'], 지킷: ['지킷', 'geekvape'], 프리맥스: ['프리맥스', 'freemax'], 로스트바이프: ['로스트바이프', 'lost vape'], 삼성: ['삼성', 'samsung'], LG: ['lg', 'LG'], 아스파이어: ['아스파이어', 'aspire'], 일렉트로닉: ['일렉트로닉', 'electronic'], 카나: ['카나', 'kana'], 카이로스: ['카이로스', 'kairos'], 오리진: ['오리진', 'origin'], 파도: ['파도', 'pado'], }; const productName = (product?.productName || '').toLowerCase(); const category = (product?.baseInfo?.categoryName || '').toLowerCase(); for (const [brand, patterns] of Object.entries(brandPatterns)) { if ( patterns.some((pattern) => productName.includes(pattern) || category.includes(pattern)) ) { return brand; } } return '전자담배 액상'; }; // 할인 정보 계산 const getOfferDetails = () => { const basePrice = product?.salePrice || product?.price || 0; const originalPrice = product?.originalPrice || basePrice; let finalPrice = basePrice; let hasDiscount = false; if (product?.immediateDiscountAmt > 0) { finalPrice -= product.immediateDiscountAmt; hasDiscount = true; } if (product?.additionDiscountAmt > 0) { finalPrice -= product.additionDiscountAmt; hasDiscount = true; } return { '@type': 'Offer', url: window.location.href, priceCurrency: 'KRW', price: finalPrice.toString(), priceValidUntil: '2025-12-31', itemCondition: 'https://schema.org/NewCondition', availability: product?.stockCnt > 0 ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', validFrom: '2024-01-01', ...(hasDiscount && { priceSpecification: { '@type': 'UnitPriceSpecification', price: finalPrice.toString(), priceCurrency: 'KRW', validFrom: '2024-01-01', validThrough: '2025-12-31', }, }), seller: { '@type': 'Organization', name: '주식회사 샤슈컴퍼니', url: 'https://siasiu.com', logo: 'https://siasiu.com/assets/icon/icon-512.png', contactPoint: { '@type': 'ContactPoint', telephone: '+82-010-2264-4940', contactType: 'customer service', }, }, shippingDetails: { '@type': 'OfferShippingDetails', shippingRate: { '@type': 'MonetaryAmount', value: '3000', currency: 'KRW', }, shippingDestination: { '@type': 'DefinedRegion', addressCountry: 'KR', }, deliveryTime: { '@type': 'ShippingDeliveryTime', handlingTime: { '@type': 'QuantitativeValue', minValue: 1, maxValue: 2, unitCode: 'DAY', }, transitTime: { '@type': 'QuantitativeValue', minValue: 1, maxValue: 3, unitCode: 'DAY', }, }, }, }; }; // 상품 설명 생성 const generateDescription = () => { const category = product.categories?.[0]?.fullCategoryLabel?.replace('✅', '') || ''; let description = `${product.productName}`; if (category) { description += ` | ${category}`; } return description; }; // 이미지 URL 정규화 (CDN 이미지는 https: 만 붙이면 됨) const normalizeImageUrl = (url) => { if (!url) return null; if (url.startsWith('//')) return `https:${url}`; return url; }; // 1. Product Schema const productSchema = { '@context': 'https://schema.org', '@type': 'Product', name: product?.productName || '', description: generateDescription(), brand: { '@type': 'Brand', name: extractBrand(), }, sku: product?.productNo?.toString() || '', category: product?.baseInfo?.categoryName || '전자담배 액상', image: product?.images?.map((img) => normalizeImageUrl(img.imageUrl)).filter(Boolean) || [], offers: getOfferDetails(), }; // 리뷰가 있는 경우에만 aggregateRating 추가 const reviewCount = product.counter?.reviewCnt || 0; if (reviewCount > 0 && product.reviewRate) { productSchema.aggregateRating = { '@type': 'AggregateRating', ratingValue: product.reviewRate.toString(), reviewCount: reviewCount.toString(), bestRating: '5', worstRating: '1', }; } // 2. BreadcrumbList Schema const breadcrumbSchema = { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [ { '@type': 'ListItem', position: 1, name: '홈', item: 'https://siasiu.com/', }, ...(product?.baseInfo?.categoryName ? [ { '@type': 'ListItem', position: 2, name: product.baseInfo.categoryName, item: `https://siasiu.com/pages/product/product-list.html?categoryNo=${ product?.baseInfo?.categoryNo || '' }`, }, ] : []), { '@type': 'ListItem', position: product?.baseInfo?.categoryName ? 3 : 2, name: product?.productName || '', }, ], }; // 3. Organization Schema const organizationSchema = { '@context': 'https://schema.org', '@type': 'Organization', name: '주식회사 샤슈컴퍼니', alternateName: 'SIASIU COMPANY', url: 'https://siasiu.com', logo: { '@type': 'ImageObject', url: 'https://siasiu.com/assets/icon/icon-512.png', width: '512', height: '512', }, contactPoint: [ { '@type': 'ContactPoint', telephone: '+82-010-2264-4940', contactType: 'customer service', availableLanguage: 'Korean', }, { '@type': 'ContactPoint', email: 'siasiucp@naver.com', contactType: 'customer service', }, ], address: { '@type': 'PostalAddress', addressCountry: 'KR', addressRegion: '서울특별시', addressLocality: '동대문구', postalCode: '02644', }, sameAs: [ 'https://www.instagram.com/siasiucp_official', 'https://www.instagram.com/dotmod__korea', 'https://blog.naver.com/siasiu2', 'https://smartstore.naver.com/49jd', ], }; // JSON-LD 스크립트 생성 및 삽입 const insertJsonLD = (schema, comment) => { // HTML 주석 추가 if (comment) { const commentNode = document.createComment(` ${comment} `); document.head.appendChild(commentNode); } const script = document.createElement('script'); script.type = 'application/ld+json'; script.textContent = JSON.stringify(schema, null, 2); document.head.appendChild(script); }; // Product Schema 삽입 insertJsonLD(productSchema, 'JSON-LD 구조화 데이터 - Product Schema'); // BreadcrumbList Schema 삽입 if (breadcrumbSchema) { insertJsonLD(breadcrumbSchema, 'JSON-LD 구조화 데이터 - BreadcrumbList Schema'); } // Organization Schema 삽입 if (organizationSchema) { insertJsonLD(organizationSchema, 'JSON-LD 구조화 데이터 - Organization Schema'); } }; // 상품 데이터 기반 메타태그 즉시 생성 const createProductMetaTags = (product) => { const head = document.querySelector('head'); const { baseInfo } = product; const productName = product.productName || baseInfo?.productName || '상품'; const description = baseInfo?.promotionText || productName || '샤슈컴퍼니'; const imageUrl = baseInfo?.imageUrls?.[0] || baseInfo?.imageUrlInfo?.[0]?.url || ''; const normalizedImageUrl = imageUrl.startsWith('//') ? `https:${imageUrl}` : imageUrl; // 기존 메타태그 제거 (중복 방지) const existingMetas = head.querySelectorAll('meta[property^="og:"], meta[name^="twitter:"]'); existingMetas.forEach((meta) => meta.remove()); // Open Graph 메타태그 생성 const metaTags = [ { property: 'og:type', content: 'product' }, { property: 'og:title', content: productName }, { property: 'og:description', content: description }, { property: 'og:image', content: normalizedImageUrl }, { property: 'og:url', content: window.location.href }, { property: 'og:site_name', content: '샤슈컴퍼니' }, { property: 'og:image:width', content: '436' }, { property: 'og:image:height', content: '134' }, { name: 'twitter:card', content: 'summary' }, { name: 'twitter:title', content: productName }, { name: 'twitter:description', content: description }, { name: 'twitter:image', content: normalizedImageUrl }, { name: 'description', content: description }, ]; // 메타태그 삽입 metaTags.forEach(({ property, name, content }) => { const meta = document.createElement('meta'); if (property) meta.setAttribute('property', property); if (name) meta.setAttribute('name', name); meta.setAttribute('content', content); head.appendChild(meta); }); // title 태그 업데이트 const titleTag = document.querySelector('title'); if (titleTag) { titleTag.textContent = productName + ' | 샤슈컴퍼니'; } else { const title = document.createElement('title'); title.textContent = productName + ' | 샤슈컴퍼니'; head.appendChild(title); } }; EventManager.on('QUERY_PRODUCT_DETAIL', async (product) => { // 상품 정보 저장 currentProductData = product; // 단위가격 표시 (슬롯 렌더링 완료 후 삽입) const unitPrice = product.price?.unitPrice; const unitPriceValue = typeof unitPrice === 'object' ? unitPrice?.parsedValue : unitPrice; if (unitPriceValue != null) { const unitName = product.price?.unitName; const unitNameType = product.price?.unitNameType; let text = unitPriceValue.toLocaleString('ko-KR') + '원'; const unitParts = [unitName, unitNameType].filter(Boolean).join(' '); if (unitParts) { text += ' / ' + unitParts; } setTimeout(() => { const priceDetailEl = document.querySelector('.product-summary__price-detail'); if (priceDetailEl && !document.querySelector('.product-summary__unit-price')) { const unitPriceDiv = document.createElement('div'); unitPriceDiv.className = 'product-summary__unit-price'; unitPriceDiv.innerHTML = '
단위가격
' + text + '
'; priceDetailEl.parentNode.insertBefore(unitPriceDiv, priceDetailEl.nextSibling); } }, 0); } // 상품 데이터 기반 메타태그 즉시 생성 createProductMetaTags(product); const widthPercentage = (product?.reviewRate / 5) * 100; // const starFill = document.querySelector('.star span'); // starFill.style.width = `${widthPercentage}%`; document.querySelector('.review_cnt').innerHTML = '리뷰 ' + Number(product?.counter?.reviewCnt || 0); document.querySelector('.rating-score').innerHTML = product?.reviewRate || 0; await productOptionAction.fetchOptions(product.productNo, { productInfo: product, }); productOptionAction.fetchOptionSelector(); startCustomOrderPrefill(); product?.baseInfo?.stickerInfos?.forEach((sticker) => { const stickerEl = document.querySelector(`.devSticker`); const stickerHtml = ` 아이콘이름 `; stickerEl.innerHTML = stickerHtml; }); // 동적 JSON-LD 생성 createProductJsonLD(product); }); EventManager.on('QUERY_RELATED_PRODUCTS_BY_PRODUCT_NO', async (products) => { products.forEach((product) => { if (product.originalPrice > 0 && product.discountedPrice < product.originalPrice) { product.discountRate = Math.floor( ((product.originalPrice - product.discountedPrice) / product.originalPrice) * 100 ); } else { product.discountRate = 0; } }); }); const normalizeProductDetailImageUrl = (url) => { const source = String(url || '').trim(); if (!source) return ''; if (source.startsWith('//')) return `${window.location.protocol}${source}`; return source; }; const hydrateProductDetailImages = () => { const productDetailEl = document.querySelector('product-detail'); if (!productDetailEl) return; const imgs = productDetailEl.querySelectorAll('img'); if (!imgs.length) return; utils.lazyLoad({ imgEls: imgs }); imgs.forEach((img) => { img.style.height = 'auto'; img.style.display = 'block'; const parentEl = img.parentElement; const grandParentEl = parentEl?.parentElement; [parentEl, grandParentEl].forEach((wrapperEl) => { if (!wrapperEl) return; const inlineStyle = wrapperEl.getAttribute('style') || ''; if (/height\s*:\s*100px/i.test(inlineStyle)) { wrapperEl.style.setProperty('height', 'auto', 'important'); wrapperEl.style.setProperty('min-height', '0', 'important'); wrapperEl.style.setProperty('overflow', 'visible', 'important'); } }); const currentSrc = img.getAttribute('src') || img.currentSrc; if (currentSrc) return; const lazySrc = img.getAttribute('data-src') || img.getAttribute('ec-data-src') || img.getAttribute('data-original'); const normalizedSrc = normalizeProductDetailImageUrl(lazySrc); if (normalizedSrc) { img.setAttribute('src', normalizedSrc); } }); }; let productDetailImageObserver = null; const observeProductDetailImages = () => { const productDetailEl = document.querySelector('product-detail'); if (!productDetailEl || productDetailImageObserver) return; productDetailImageObserver = new MutationObserver(hydrateProductDetailImages); productDetailImageObserver.observe(productDetailEl, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-src', 'ec-data-src', 'src'], }); setTimeout(() => { productDetailImageObserver?.disconnect(); productDetailImageObserver = null; }, 10000); }; const scheduleProductDetailImageHydration = () => { observeProductDetailImages(); [0, 100, 300, 700, 1500].forEach((delay) => { setTimeout(hydrateProductDetailImages, delay); }); }; EventManager.on('PRODUCT_DETAIL_CONTENT_LOADED', scheduleProductDetailImageHydration); const getProductContentEl = () => document.querySelector('[shopby-element="product-content"]'); const getProductContentDetailEl = () => document.querySelector('[shopby-element="product-content-detail"]'); const getProductContentBoxEl = () => document.querySelector('.product-content__content-box'); const getProductContentToggleEl = () => document.querySelector('[shopby-element="TOGGLE_SHOW_MORE"]'); const setProductContentExpanded = (isExpanded) => { const contentEl = getProductContentEl(); const contentBoxEl = getProductContentBoxEl(); const moreBtnEl = getProductContentToggleEl(); const buttonEl = moreBtnEl?.querySelector('button') || moreBtnEl; if (!contentEl) return; contentEl.dataset.showMoreState = isExpanded ? 'expanded' : 'collapsed'; contentEl.classList.toggle('hidden', !isExpanded); if (contentBoxEl) { contentBoxEl.style.maxHeight = isExpanded ? 'none' : ''; contentBoxEl.style.overflow = isExpanded ? 'visible' : ''; } if (buttonEl) { buttonEl.innerText = isExpanded ? '상품정보 접기' : '상품정보 펼쳐보기'; } }; const updateProductContentToggle = () => { const contentEl = getProductContentEl(); const detailEl = getProductContentDetailEl(); const moreBtnEl = getProductContentToggleEl(); if (!contentEl || !detailEl || !moreBtnEl) return; if (detailEl.offsetHeight <= 430) { moreBtnEl.classList.add('hidden'); return; } moreBtnEl.classList.remove('hidden'); if (!contentEl.dataset.showMoreState) { setProductContentExpanded(false); } }; EventManager.on('CHECK_PRODUCT_CONTENT_SIZE', () => { // 상품 펼쳐보기 버튼 생성, hidden 처리 const observerTarget = getProductContentDetailEl(); if (!observerTarget) return; if (observerTarget.dataset.contentResizeBound === 'true') { updateProductContentToggle(); return; } observerTarget.dataset.contentResizeBound = 'true'; const observer = new ResizeObserver(updateProductContentToggle); observer.observe(observerTarget); observerTarget.querySelectorAll('img').forEach((image) => { if (image.complete) return; image.addEventListener('load', updateProductContentToggle, { once: true }); }); requestAnimationFrame(updateProductContentToggle); // 상품 펼쳐보기 버튼 생성, hidden 처리 END }); const searchParams = new URLSearchParams(location.search); const productNo = searchParams.get('productNo'); const channelType = searchParams.get('channelType'); const trackingKey = searchParams.get('trackingKey'); const normalizeCustomOrderValue = (value) => (value == null ? '' : String(value).trim()); const normalizeCustomOrderQueueItem = (item) => { if (!item || typeof item !== 'object') { return null; } const productNo = normalizeCustomOrderValue(item.p || item.productNo); const productName = normalizeCustomOrderValue(item.n || item.productName); const quantity = Math.max(1, Number(item.q || item.quantity) || 1); if (!productNo || !productName) { return null; } return { productNo, productName, quantity, quantityText: `${quantity}개`, }; }; const parseCustomOrderItems = () => { const rawItems = searchParams.get('customOrderItems'); if (!rawItems) { return []; } try { const parsedItems = JSON.parse(rawItems); if (!Array.isArray(parsedItems)) { return []; } return parsedItems.map(normalizeCustomOrderQueueItem).filter(Boolean); } catch (error) { return []; } }; const customOrderItemsParam = searchParams.get('customOrderItems') || ''; const customOrderItems = parseCustomOrderItems(); const customOrderIndex = Math.min( Math.max(0, Number(searchParams.get('customOrderIndex')) || 0), Math.max(0, customOrderItems.length - 1) ); const customOrderCurrentItem = customOrderItems[customOrderIndex] || null; const customOrderProductName = customOrderCurrentItem?.productName || searchParams.get('inputOption0')?.trim() || ''; const customOrderQtyParam = customOrderCurrentItem?.quantity || searchParams.get('buyQty') || ''; const customOrderQtyTextParam = customOrderCurrentItem?.quantityText || searchParams.get('inputOption1')?.trim() || ''; const customOrderQty = Math.max( 1, Number(customOrderQtyParam) || Number(customOrderQtyTextParam.replace(/[^0-9]/g, '')) || 1 ) || 1; const customOrderPrefill = { hasValue: searchParams.get('customOrder') === 'Y' && (Boolean(customOrderProductName) || searchParams.has('inputOption1') || searchParams.has('buyQty')), productName: customOrderProductName, quantity: customOrderQty, quantityText: customOrderQtyTextParam || `${customOrderQty}개`, items: customOrderItems, itemsParam: customOrderItemsParam, index: customOrderIndex, autoAdd: searchParams.get('customOrderAuto') === 'Y', }; const cartAction = actions.getCartAction(); utils.setChannelType(channelType); const { productDetailHelper } = pageHelper; const { initialize, deleteProductInquiryBy, searchReviewsV2 } = productDetailHelper(); let customOrderPrefillApplied = false; let customOrderCartGuideShown = false; let customOrderPrefillRetryTimer = null; let customOrderPrefillObserver = null; let customOrderAutoAddStarted = false; const CUSTOM_ORDER_PREFILL_MAX_RETRIES = 100; const CUSTOM_ORDER_PREFILL_RETRY_DELAY = 150; initialize({ productNo, channelType, cartAction, productOptionAction, }); EventManager.on('QUERY_PRODUCT_DETAIL', (data) => { EventManager.fire('CHANGE_SUB_TITLE', { subTitle: data?.baseInfo?.productName, }); // 외부스크립트 등록 기능. 삭제금지 ShopbyExternalScript?.setGlobalObjectSb({ product: { ...data } }); }); const redirectLoginPage = () => { const nextUrl = `${location.origin}${constants.PAGE.SIGN_IN}`; utils.moveTo({ url: `${nextUrl}?from=${encodeURIComponent(location.href)}`, }); }; const getController = (target) => { const controllerEl = target.getAttribute('shopby-controller-id') ? target : target.closest('[shopby-controller-id]'); const controllerId = controllerEl?.getAttribute('shopby-controller-id'); if (!controllerId) { return null; } const { data } = productOptionAction.fetchOptionSelector(controllerId); return data; }; const getProductOptionRoot = () => productOptionEl || document.querySelector('product-purchase-v2'); const getCustomOrderTextOptionLabel = (inputEl) => { const wrapperEl = inputEl.closest('[shopby-input-no]'); const labelEl = wrapperEl?.querySelector('.text-option__label span, .text-option label span'); return (labelEl?.textContent || inputEl.placeholder || '').replace(/\(필수\)/g, '').trim(); }; const resolveCustomOrderTextOptionValue = (inputEl, fallbackIndex) => { const normalizedLabel = getCustomOrderTextOptionLabel(inputEl).replace(/\s/g, ''); if (normalizedLabel.includes('상품명')) { return customOrderPrefill.productName; } if ( normalizedLabel.includes('구매개수') || normalizedLabel.includes('구매갯수') || normalizedLabel.includes('구매수량') || normalizedLabel.includes('개수') || normalizedLabel.includes('수량') ) { return customOrderPrefill.quantityText; } return [customOrderPrefill.productName, customOrderPrefill.quantityText][fallbackIndex] || ''; }; const setCustomOrderTextOption = (inputEl, value) => { if (!value) { return false; } const controller = getController(inputEl); const controllerEl = inputEl.closest('[shopby-controller-id]'); const optionId = controllerEl?.getAttribute('shopby-option-id'); const inputNo = Number(controllerEl?.getAttribute('shopby-input-no')); const textOptionId = inputEl.getAttribute('shopby-text-option-id'); const maxLength = Number(inputEl.getAttribute('shopby-max-length')) || 100; const safeValue = String(value).slice(0, maxLength); inputEl.value = safeValue; const counterEl = inputEl.closest('[shopby-counter]'); const countEl = counterEl?.querySelector('[shopby-count]'); const errorMessageEl = counterEl?.querySelector('[shopby-error-message]'); if (countEl) { countEl.textContent = safeValue.length; } if (errorMessageEl) { errorMessageEl.textContent = ''; } if (!controller || !optionId || Number.isNaN(inputNo)) { return false; } controller.inputTextOption({ value: safeValue, optionId, inputNo, textOptionId, maxLength, }); return true; }; const setCustomOrderQuantity = () => { const optionRootEl = getProductOptionRoot(); const quantityEl = optionRootEl?.querySelector( '.quantity[shopby-controller-id][shopby-option-id][shopby-count], [shopby-controller-id][shopby-option-id][shopby-count]' ); if (!quantityEl) { return false; } const controller = getController(quantityEl); const optionId = quantityEl.getAttribute('shopby-option-id'); if (!controller || !optionId) { return false; } controller.changeCount({ optionId, count: customOrderPrefill.quantity, }); quantityEl.setAttribute('shopby-count', String(customOrderPrefill.quantity)); const countInputEl = quantityEl.querySelector('input[shopby-action="CHANGE_COUNT"]'); if (countInputEl) { countInputEl.value = String(customOrderPrefill.quantity); } return true; }; const applyCustomOrderTextOptions = () => { const optionRootEl = getProductOptionRoot(); const inputEls = Array.from( optionRootEl?.querySelectorAll('input[shopby-action="INPUT_TEXT_OPTION"]') || [] ); let fallbackIndex = 0; let appliedCount = 0; inputEls.forEach((inputEl) => { const value = resolveCustomOrderTextOptionValue(inputEl, fallbackIndex); if (setCustomOrderTextOption(inputEl, value)) { fallbackIndex += 1; appliedCount += 1; } }); return appliedCount > 0; }; const getCustomOrderUnitPrice = () => { const productSummary = document.querySelector('product-summary')?.store?.state; return Number( productSummary?.price?.salePrice || productSummary?.summary?.discountedPrice || currentProductData?.price?.salePrice || currentProductData?.salePrice || 0 ); }; const escapeCustomOrderHTML = (value) => String(value || '').replace(/[&<>"']/g, (char) => { const entityMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; return entityMap[char]; }); const getNextCustomOrderUrl = () => { if (!customOrderPrefill.items.length) { return ''; } const nextIndex = customOrderPrefill.index + 1; const nextItem = customOrderPrefill.items[nextIndex]; if (!nextItem) { return ''; } const nextUrl = new URL(location.href); nextUrl.searchParams.set('productNo', nextItem.productNo); nextUrl.searchParams.set('customOrderProductNo', nextItem.productNo); nextUrl.searchParams.set('inputOption0', nextItem.productName); nextUrl.searchParams.set('inputOption1', nextItem.quantityText); nextUrl.searchParams.set('buyQty', String(nextItem.quantity)); nextUrl.searchParams.set('customOrder', 'Y'); nextUrl.searchParams.set('customOrderItems', customOrderPrefill.itemsParam); nextUrl.searchParams.set('customOrderIndex', String(nextIndex)); nextUrl.searchParams.set('customOrderAuto', 'Y'); nextUrl.searchParams.set('sbCacheBust', String(Date.now())); return nextUrl.toString(); }; const submitCustomOrderCartGuide = async (buttonEl) => { const nextUrl = getNextCustomOrderUrl(); buttonEl.disabled = true; buttonEl.textContent = nextUrl ? '담고 다음 상품으로 이동 중...' : '담는 중...'; try { await addSelectedProductToCart({ moveToCart: !nextUrl, nextUrl }); } catch (error) { buttonEl.disabled = false; buttonEl.textContent = '장바구니 담기 →'; throw error; } }; const createCustomOrderCartGuide = () => { if ( !customOrderPrefill.hasValue || customOrderCartGuideShown || document.getElementById('custom-order-cart-guide') ) { return; } customOrderCartGuideShown = true; const unitPrice = getCustomOrderUnitPrice(); const totalPrice = unitPrice * customOrderPrefill.quantity; const escapedProductName = escapeCustomOrderHTML(customOrderPrefill.productName); const escapedQuantityText = escapeCustomOrderHTML(customOrderPrefill.quantityText); const customOrderTotalCount = customOrderPrefill.items.length || 1; const customOrderCurrentNumber = customOrderPrefill.index + 1; const overlayEl = document.createElement('div'); overlayEl.id = 'custom-order-cart-guide'; overlayEl.style.cssText = ` position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(0, 0, 0, 0.42); `; overlayEl.innerHTML = `

🛒 장바구니 담기

총 ${customOrderTotalCount}개 상품 순서대로 일괄 장바구니에 자동 담깁니다

상품 ${customOrderCurrentNumber} / ${customOrderTotalCount}

${escapedProductName}

옵션: ${escapedProductName} · 수량: ${escapedQuantityText}${ totalPrice ? ` · ${totalPrice.toLocaleString('ko-KR')}원` : '' }

${ totalPrice ? `
합계 ${totalPrice.toLocaleString('ko-KR')}원
` : '' }
`; document.body.appendChild(overlayEl); overlayEl.querySelector('[data-custom-order-close]')?.addEventListener('click', () => { overlayEl.remove(); }); const cartButtonEl = overlayEl.querySelector('[data-custom-order-cart]'); cartButtonEl?.addEventListener('click', async (event) => { await submitCustomOrderCartGuide(event.currentTarget); }); if (customOrderPrefill.autoAdd && cartButtonEl && !customOrderAutoAddStarted) { customOrderAutoAddStarted = true; setTimeout(() => submitCustomOrderCartGuide(cartButtonEl), 500); } }; const stopCustomOrderPrefillObserver = () => { if (customOrderPrefillObserver) { customOrderPrefillObserver.disconnect(); customOrderPrefillObserver = null; } if (customOrderPrefillRetryTimer) { clearTimeout(customOrderPrefillRetryTimer); customOrderPrefillRetryTimer = null; } }; const queueCustomOrderPrefillRetry = (retryCount) => { if (retryCount >= CUSTOM_ORDER_PREFILL_MAX_RETRIES) { stopCustomOrderPrefillObserver(); return; } if (customOrderPrefillRetryTimer) { clearTimeout(customOrderPrefillRetryTimer); } customOrderPrefillRetryTimer = setTimeout(() => { customOrderPrefillRetryTimer = null; applyCustomOrderPrefill(retryCount + 1); }, CUSTOM_ORDER_PREFILL_RETRY_DELAY); }; const observeCustomOrderPrefillTarget = () => { if (!customOrderPrefill.hasValue || customOrderPrefillObserver || customOrderPrefillApplied) { return; } const optionRootEl = getProductOptionRoot() || document.body; customOrderPrefillObserver = new MutationObserver(() => { if (!customOrderPrefillApplied) { applyCustomOrderPrefill(0); } }); customOrderPrefillObserver.observe(optionRootEl, { childList: true, subtree: true }); }; const applyCustomOrderPrefill = (retryCount = 0) => { if (!customOrderPrefill.hasValue || customOrderPrefillApplied) { return; } const didSetQuantity = setCustomOrderQuantity(); const didSetTextOptions = applyCustomOrderTextOptions(); if (didSetQuantity || didSetTextOptions) { productOptionAction.fetchOptionSelector(); } if (!didSetQuantity || !didSetTextOptions) { queueCustomOrderPrefillRetry(retryCount); return; } customOrderPrefillApplied = true; stopCustomOrderPrefillObserver(); setTimeout(createCustomOrderCartGuide, 0); }; const startCustomOrderPrefill = () => { if (!customOrderPrefill.hasValue || customOrderPrefillApplied) { return; } observeCustomOrderPrefillTarget(); applyCustomOrderPrefill(0); }; const normalizeCustomOrderCompareValue = (value) => normalizeCustomOrderValue(value).replace(/\s+/g, ' ').toLowerCase(); const normalizeCustomOrderInputLabel = (value) => normalizeCustomOrderValue(value).replace(/\s/g, ''); const isCustomOrderQuantityLabel = (label) => { const normalizedLabel = normalizeCustomOrderInputLabel(label); return ( normalizedLabel.includes('구매개수') || normalizedLabel.includes('구매갯수') || normalizedLabel.includes('구매수량') || normalizedLabel.includes('개수') || normalizedLabel.includes('수량') ); }; const getCustomOrderInputLabel = (input) => input?.inputLabel || input?.label || input?.optionName || ''; const getCustomOrderInputValue = (input) => input?.inputValue || input?.value || ''; const getCustomOrderProductNameInput = (optionInputs = []) => { const inputs = Array.isArray(optionInputs) ? optionInputs : []; return ( inputs.find((input) => normalizeCustomOrderInputLabel(getCustomOrderInputLabel(input)).includes('상품명') ) || inputs.find((input) => !isCustomOrderQuantityLabel(getCustomOrderInputLabel(input))) || inputs[0] || null ); }; const getCustomOrderProductNameKey = (item) => normalizeCustomOrderCompareValue( getCustomOrderInputValue(getCustomOrderProductNameInput(item?.optionInputs)) ); const getCustomOrderCartMergeKey = (item) => { const productNameKey = getCustomOrderProductNameKey(item); const itemProductNo = Number(item?.productNo); const itemOptionNo = Number(item?.optionNo); if (!productNameKey || !itemProductNo || !itemOptionNo) { return ''; } return `${itemProductNo}::${itemOptionNo}::${productNameKey}`; }; const isSameCustomOrderCartItem = (cartItem, nextItem) => { const currentMergeKey = getCustomOrderCartMergeKey(cartItem); const nextMergeKey = getCustomOrderCartMergeKey(nextItem); return Boolean(currentMergeKey) && currentMergeKey === nextMergeKey; }; const getCustomOrderQuantityText = (quantity) => `${Math.max(1, Number(quantity) || 1)}개`; const updateCustomOrderQuantityInputs = (optionInputs = [], quantity) => { const inputs = Array.isArray(optionInputs) ? optionInputs : []; const quantityText = getCustomOrderQuantityText(quantity); let updatedQuantityInput = false; const nextInputs = inputs.map((input, index) => { if (!isCustomOrderQuantityLabel(getCustomOrderInputLabel(input)) && index !== 1) { return input; } updatedQuantityInput = true; return { ...input, inputValue: quantityText, }; }); return updatedQuantityInput ? nextInputs : inputs; }; const fetchCurrentCartProducts = async () => { try { await cartAction.fetchCartDetail(); const { data = [] } = await cartAction.fetchAllValidProducts(); return Array.isArray(data) ? data : []; } catch (error) { return []; } }; const mergeCustomOrderCartItems = async (items) => { if (!customOrderPrefill.hasValue) { return items; } const currentCartProducts = await fetchCurrentCartProducts(); if (currentCartProducts.length === 0) { return items; } const itemsToAdd = []; const itemsToModify = []; items.forEach((item) => { const currentCartItem = currentCartProducts.find((cartItem) => isSameCustomOrderCartItem(cartItem, item) ); if (!currentCartItem) { itemsToAdd.push(item); return; } const nextOrderCnt = Math.max(1, Number(currentCartItem.orderCnt) || 1) + Math.max(1, Number(item.orderCnt) || 1); currentCartItem.orderCnt = nextOrderCnt; currentCartItem.optionInputs = updateCustomOrderQuantityInputs( currentCartItem.optionInputs || item.optionInputs, nextOrderCnt ); itemsToModify.push({ cartNo: currentCartItem.cartNo, orderCnt: nextOrderCnt, optionInputs: currentCartItem.optionInputs, groupId: currentCartItem.groupId ?? item.groupId ?? null, }); }); if (itemsToModify.length > 0) { await cartAction.modifyCart(itemsToModify); } return itemsToAdd; }; const dedupeCustomOrderCartProducts = async () => { if (!customOrderPrefill.hasValue) { return; } const currentCartProducts = await fetchCurrentCartProducts(); const cartGroups = new Map(); currentCartProducts.forEach((cartItem) => { const mergeKey = getCustomOrderCartMergeKey(cartItem); if (!mergeKey || !cartItem.cartNo) { return; } cartGroups.set(mergeKey, [...(cartGroups.get(mergeKey) || []), cartItem]); }); const itemsToModify = []; const cartNosToDelete = []; cartGroups.forEach((cartItems) => { if (cartItems.length < 2) { return; } const [targetItem, ...duplicateItems] = cartItems; const nextOrderCnt = cartItems.reduce( (sum, item) => sum + Math.max(1, Number(item.orderCnt) || 1), 0 ); targetItem.optionInputs = updateCustomOrderQuantityInputs( targetItem.optionInputs, nextOrderCnt ); itemsToModify.push({ cartNo: targetItem.cartNo, orderCnt: nextOrderCnt, optionInputs: targetItem.optionInputs, groupId: targetItem.groupId ?? null, }); cartNosToDelete.push(...duplicateItems.map((item) => item.cartNo).filter(Boolean)); }); if (cartNosToDelete.length > 0) { await cartAction.deleteCartNos(cartNosToDelete); } if (itemsToModify.length > 0) { await cartAction.modifyCart(itemsToModify); } }; const addSelectedProductToCart = async ({ moveToCart = false, nextUrl = '' } = {}) => { const { data } = productOptionAction.fetchUserOptionSelections(); const { addCart } = pageHelper.productDetailHelper(); const mainItems = data.map((item) => ({ ...item, channelType, productNo, orderCnt: item.count, })); const extraItems = getExtraProductCartItems(); const nextItems = await mergeCustomOrderCartItems([...mainItems, ...extraItems]); if (nextItems.length > 0) { await addCart(nextItems); } await dedupeCustomOrderCartProducts(); if (nextUrl) { location.href = nextUrl; return; } if (moveToCart) { utils.moveTo({ url: constants.PAGE.CART, }); return; } EventManager.fire('MODAL_CONFIRM_OPEN', { message: '

상품이 장바구니에 담겼습니다. 바로 확인하시겠습니까?

', cancelLabel: '쇼핑계속하기', confirmLabel: '장바구니 가기', onConfirm: () => { utils.moveTo({ url: constants.PAGE.CART, }); }, }); }; const validationMap = { receiverName: (value) => { if (!value) { return { message: '받는 사람을 입력해주세요', slotName: 'shippingInfo.receiverName', }; } return false; }, receiverAddress: (value) => { if (!value) { return { message: '주소를 입력해주세요.', slotName: 'shippingInfo.receiverAddress', }; } return false; }, receiverDetailAddress: (value) => { if (!value) { return { message: '상세 주소를 입력해주세요.', slotName: 'shippingInfo.receiverDetailAddress', }; } return false; }, receiverContact1: (value) => { const MIN_CONTACT_LENGTH = 10; const slotName = 'shippingInfo.receiverContact1'; if (!value) { return { message: '받는 사람 휴대폰 번호를 입력해주세요.', slotName, }; } if (value.length < MIN_CONTACT_LENGTH) { return { message: '휴대폰번호는 10자~12자 이내로 입력해주세요.', slotName, }; } return false; }, }; const checkValidation = ({ data, fields }) => { let errorInfo = null; for (let i = 0; i < fields.length; i += 1) { const fieldName = fields[i]; if (!validationMap[fieldName]) { continue; } const error = validationMap[fieldName](data[fieldName].trim(), data); if (error) { errorInfo = error; break; } } if (errorInfo?.message) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'CAUTION', message: `${errorInfo.message}`, }); return false; } return true; }; let shopbyCommonData = {}; EventManager.on('PAGE_LOAD_COMPLETED', (commonData) => { shopbyCommonData = commonData; // 외부스크립트 등록 기능. 삭제금지 ShopbyExternalScript?.setPageScriptType('PRODUCT'); }); const ACTION_HANDLER_MAP = { DISCOUNT_DETAIL: () => { EventManager.fire('OPEN_LAYER_MODAL', { title: '할인내역', name: 'discount-guide', data: { DISCOUNT_LABEL_MAP: { additionDiscountAmt: '추가할인', immediateDiscountAmt: '즉시할인', }, }, modalAddClass: 'no-full', classModifier: 'discount-price-information', onClose: () => null, }); }, COUPON_DOWNLOAD: () => { if (!utils.isSignIn()) { ShopbySkin.EventManager.fire('MODAL_ALERT_OPEN', { message: '로그인 후 이용할 수 있습니다.', onClose: redirectLoginPage, }); return; } ShopbySkin.EventManager.fire('OPEN_LAYER_MODAL', { title: '쿠폰받기', name: 'coupon-download', data: { productNo, channelType, }, isFull: true, onClose: () => null, }); }, PRODUCT_INQUIRY_WRITE: (target) => { const guestWriteable = target.getAttribute('shopby-guest-writeable') === 'true'; if (!utils.isSignIn() && !guestWriteable) { ShopbySkin.EventManager.fire('MODAL_ALERT_OPEN', { message: '로그인 후 이용할 수 있습니다.', onClose: redirectLoginPage, }); return; } const productName = target .closest('[shopby-product-name]') ?.getAttribute('shopby-product-name'); const imageUrl = target.closest('[shopby-image-url]')?.getAttribute('shopby-image-url'); const imageType = target.closest('[shopby-image-type]')?.getAttribute('shopby-image-type'); const { inquiryTags } = document.querySelector('product-detail-inquiry-list').store.state; EventManager.fire('OPEN_LAYER_MODAL', { name: 'product-inquiry-form', title: '상품 문의', data: { productNo, productName, unescapedProductName: unescapeHTML(productName), imageUrl, imageType, isModify: false, commonData: shopbyCommonData, useSelectProduct: false, inquiryTags, }, onClose: ({ reason }) => { if (reason === 'DID_SUBMIT') { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'success', message: '상품문의가 등록됐습니다.', onClose: () => { location.reload(); }, }); } }, }); }, INQUIRY_DETAIL: (target) => { const readable = target.closest('[shopby-readable]')?.getAttribute('shopby-readable') === 'true'; if (!readable) { EventManager.fire('MODAL_ALERT_OPEN', { message: '비밀글 조회 권한이 없습니다.', }); } }, INQUIRY_DELETE: (target) => { const inquiryNo = target.getAttribute('shopby-inquiry-no'); EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'warning', message: '

삭제 시 복구가 불가능합니다.
정말 삭제하시겠습니까?

', onConfirm: async () => { await deleteProductInquiryBy({ inquiryNo }); EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'success', message: '삭제되었습니다.', onClose: () => { location.reload(); }, }); }, }); }, INQUIRY_MODIFY: (target) => { const inquiryNo = target.getAttribute('shopby-inquiry-no'); const { inquiryTags } = document.querySelector('product-detail-inquiry-list').store.state; EventManager.fire('OPEN_LAYER_MODAL', { name: 'product-inquiry-form', title: '상품문의', data: { productNo, inquiryNo, isModify: true, commonData: shopbyCommonData, inquiryTags, }, onClose: ({ reason }) => { if (reason === 'DID_MODIFY') { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'success', message: '수정이 완료되었습니다.', onClose: () => { location.reload(); }, }); } }, }); }, WRITE_REVIEW: (target) => { const reviewableCount = Number(target.getAttribute('shopby-reviewable-count')); const usesAttachment = target.getAttribute('shopby-uses-attachment') === 'true'; EventManager.fire('OPEN_LAYER_MODAL', { name: 'product-review-form', title: '상품후기', isFull: true, data: { productNo, isModify: false, useSelectOrderProduct: reviewableCount > 1, usesAttachment, }, onClose: ({ reason }) => { if (reason === 'DID_SUBMIT') { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'success', message: '상품후기가 작성되었습니다.', onClose: () => { location.reload(); }, }); } }, }); }, REVIEW_DETAIL: (target) => { target.classList.toggle('open'); }, REVIEW_DETAIL_POPUP: (target) => { const reviewNo = target.closest('[shopby-review-no]')?.getAttribute('shopby-review-no'); ShopbySkin.EventManager.fire('OPEN_LAYER_MODAL', { name: 'product-review-detail', title: '상품후기 상세', isFull: true, data: { productNo, reviewNo, queryParams: { boardType: 'ALL', }, }, }); }, PHOTO_REVIEW_MORE: async () => { const { data } = await searchReviewsV2({ productNo, channelType, sortType: 'RECOMMEND', hasAttachmentFile: true, pageSize: 20, }); const photoCollections = data.reviews.map(({ images, reviewNo }) => ({ urls: images, attachedFileCount: images.length, productNo, reviewNo, })); EventManager.fire('OPEN_LAYER_MODAL', { name: 'product-review-photos', title: `포토(${photoCollections.length})`, isFull: true, data: { images: photoCollections, }, }); }, REVIEW_MODIFY: (target) => { const reviewNo = target.closest('[shopby-review-no]')?.getAttribute('shopby-review-no'); const boardName = target.closest('[shopby-board-name]')?.getAttribute('shopby-board-name'); const usesAttachment = target.closest('[shopby-uses-attachment]')?.getAttribute('shopby-uses-attachment') === 'true'; EventManager.fire('OPEN_LAYER_MODAL', { name: 'product-review-form', title: boardName, isFull: true, data: { productNo, reviewNo, isModify: true, usesAttachment, }, onClose: ({ reason }) => { if (reason === 'DID_MODIFY') { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'success', message: '수정이 완료되었습니다.', onClose: () => { location.reload(); }, }); } }, }); }, REVIEW_DELETE: (target) => { const reviewNo = target.closest('[shopby-review-no]')?.getAttribute('shopby-review-no'); const givenAccumulation = target.getAttribute('shopby-given-accumulation'); const confirmMessage = givenAccumulation.toUpperCase() === 'Y' ? '

삭제 시 복구가 불가하며,
지급된 적립금이 차감됩니다.
정말 삭제하시겠습니까?

' : '

삭제 시 복구가 불가능합니다.
정말 삭제하시겠습니까?

'; const myProductReviewListHelper = pageHelper.myProductReviewListHelper(); myProductReviewListHelper.initialize(); EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'warning', message: confirmMessage, onConfirm: async () => { await myProductReviewListHelper.deleteReview({ productNo, reviewNo }); EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'success', message: '삭제되었습니다.', onClose: () => { location.reload(); }, }); }, }); }, RECOMMEND: () => { if (!utils.isSignIn()) { ShopbySkin.EventManager.fire('MODAL_ALERT_OPEN', { message: '로그인 후 이용할 수 있습니다.', onClose: redirectLoginPage, }); } }, ADD_CART: () => addSelectedProductToCart(), PRODUCT_ORDER: async () => { const { isSignedIn } = utils; const { data } = productOptionAction.fetchUserOptionSelections(); const { requestOrderSheet } = pageHelper.productDetailHelper(); const mainProducts = data.map((item) => ({ ...item, productNo, orderCnt: item.count, additionalProductNo: 0, channelType, })); const extraProducts = getExtraProductOrderItems(); const { orderSheetNo } = await requestOrderSheet({ cartNos: null, channelType, productCoupons: null, trackingKey, products: [...mainProducts, ...extraProducts], }); const orderSheetNoURL = `${location.origin}${constants.PAGE.ORDER_SHEET_FORM}?ordersheetNo=${orderSheetNo}`; const nextUrl = isSignedIn() ? orderSheetNoURL : `${location.origin}${constants.PAGE.SIGN_IN}?from=${orderSheetNoURL}`; utils.moveTo({ url: nextUrl }); }, PAY_APP_CARD: async () => { // 앱카드 결제 if (!utils.isSignIn()) { EventManager.fire('MODAL_ALERT_OPEN', { message: '로그인하셔야 본 서비스를 이용하실 수 있습니다', onClose: redirectLoginPage, }); return; } const { data } = productOptionAction.fetchUserOptionSelections(); const { requestOrderSheet } = pageHelper.productDetailHelper(); const { orderSheetNo } = await requestOrderSheet({ cartNos: null, channelType, productCoupons: null, trackingKey, products: data.map((item) => ({ ...item, productNo, orderCnt: item.count, additionalProductNo: 0, channelType, })), }); const orderSheetFormHelper = pageHelper.orderSheetFormHelper(); orderSheetFormHelper.orderSheetNo = orderSheetNo; await orderSheetFormHelper.actions.fetchOrderSheet({ orderSheetNo, includesMemberAddress: false, }); await orderSheetFormHelper.actions.fetchMyShippingAddress(); await orderSheetFormHelper.fetchPaymentInfo(); const amount = data?.reduce((acc, cur) => acc + cur.calcPrice, 0); await orderSheetFormHelper.actions.fetchAppCardCards({ amount, }); }, PRODUCT_LIKE: (target) => { const productNo = target.closest('[shopby-product-no]').getAttribute('shopby-product-no'); if (!productNo) { return; } ShopbySkin.actions.toggleLikeStatus({ productNo, onSuccess: (isLiked) => { const el = target.closest('[type=button]'); if (isLiked) { el.classList.add('is-active'); // 상품 정보와 함께 이벤트 발생 EventManager.fire('PRODUCT_LIKED', { productNo, productName: currentProductData?.productName || '', salePrice: currentProductData?.price?.salePrice || 0, }); return; } el.classList.remove('is-active'); }, }); }, TOGGLE_SHOW_MORE: (target) => { const toggleTarget = document.querySelector('[shopby-element="product-content"]'); if (!toggleTarget) return; const isExpanded = toggleTarget.classList.contains('hidden'); setProductContentExpanded(isExpanded); }, WRITE_REVIEW_REPORT: (target) => { const reviewNo = target.closest('[shopby-review-no]')?.getAttribute('shopby-review-no'); if (!utils.isSignedIn()) { EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'WARNING', message: '로그인 후 이용할 수 있습니다.', onConfirm: () => { utils.moveTo({ url: `/pages/sign-in/sign-in.html?from=${encodeURIComponent(location.href)}`, }); }, }); return; } EventManager.fire('OPEN_LAYER_MODAL', { name: 'report-form', title: '신고하기', isFull: false, onClose: ({ reason }) => { if (reason === 'DID_SUBMIT') { location.reload(); } }, onConfirm: async ({ reportReasonType, content }) => { const { status } = await actions.postProductReviewReport({ productNo, reviewNo, reportReasonCd: reportReasonType, content, }); if (status === 204) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'success', message: '신고 되었습니다.', onClose: () => { location.reload(); }, }); } }, }); }, CANCEL_REVIEW_REPORT: (target) => { const reviewNo = target.closest('[shopby-review-no]')?.getAttribute('shopby-review-no'); if (!utils.isSignedIn()) { EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'WARNING', message: '로그인 후 이용할 수 있습니다.', onConfirm: () => { utils.moveTo({ url: `/pages/sign-in/sign-in.html?from=${encodeURIComponent(location.href)}`, }); }, }); return; } EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'WARNING', message: '신고를 취소 하시겠습니까?', confirmLabel: '확인', onConfirm: async () => { const { status } = await actions.deleteProductReviewReport({ productNo, reviewNo, }); if (status === 204) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'success', message: '신고가 취소 되었습니다.', onClose: () => { location.reload(); }, }); } }, }); }, // 상품문의 신고 WRITE_INQUIRY_REPORT: (target) => { const inquiryNo = target.closest('[shopby-inquiry-no]')?.getAttribute('shopby-inquiry-no'); if (!utils.isSignedIn()) { EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'WARNING', message: '로그인 후 이용할 수 있습니다.', onConfirm: () => { utils.moveTo({ url: `/pages/sign-in/sign-in.html?from=${encodeURIComponent(location.href)}`, }); }, }); return; } EventManager.fire('OPEN_LAYER_MODAL', { name: 'report-form', title: '신고하기', isFull: false, onClose: ({ reason }) => { if (reason === 'DID_SUBMIT') { location.reload(); } }, onConfirm: async ({ reportReasonType, content }) => { const { status } = await actions.postProductInquiryReport({ productNo, inquiryNo, reportType: reportReasonType, reason: content, }); if (status === 204) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'success', message: '신고 되었습니다.', onClose: () => { location.reload(); }, }); } }, }); }, // 상품 문의 신고 취소 CANCEL_INQUIRY_REPORT: (target) => { const inquiryNo = target.closest('[shopby-inquiry-no]')?.getAttribute('shopby-inquiry-no'); if (!utils.isSignedIn()) { EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'WARNING', message: '로그인 후 이용할 수 있습니다.', onConfirm: () => { utils.moveTo({ url: `/pages/sign-in/sign-in.html?from=${encodeURIComponent(location.href)}`, }); }, }); return; } EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'WARNING', message: '신고를 취소 하시겠습니까?', confirmLabel: '확인', onConfirm: async () => { const { status } = await actions.deleteProductInquiryReport({ productNo, inquiryNo, }); if (status === 204) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'success', message: '신고가 취소 되었습니다.', onClose: () => { location.reload(); }, }); } }, }); }, }; const ACTION_CHANGE_HANDLER_MAP = { SORT_REVIEWS: (target) => { const { value } = target; EventManager.fire('CHANGE_SORT_REVIEWS', { value }); }, }; const CLICK_ACTION_HANDLER_MAP = { HANDLE_COUNT_BTN_CLICK: (target) => { const controller = getController(target); if (!controller) { return; } const controllerEl = target.closest('[shopby-controller-id]'); const optionId = controllerEl.getAttribute('shopby-option-id'); const count = Number(controllerEl.getAttribute('shopby-count')); const incremental = target.getAttribute('shopby-variation') === 'INCREASE'; controller.changeCount({ optionId, count: incremental ? count + 1 : count - 1, }); productOptionAction.fetchOptionSelector(); }, REMOVE_OPTION: (target) => { const controller = getController(target); if (!controller) { return; } const controllerEl = target.closest('[shopby-controller-id]'); const optionId = controllerEl.getAttribute('shopby-option-id'); controller.removeOption({ optionId }); productOptionAction.fetchOptionSelector(); }, TOGGLE_TEXT_OPTIONS_BY_QUANTITY: (target) => { const controller = getController(target); if (!controller) { return; } const controllerEl = target.closest('[shopby-controller-id]'); const optionId = controllerEl.getAttribute('shopby-option-id'); const inputNo = Number(controllerEl.getAttribute('shopby-input-no')); const type = controllerEl.getAttribute('shopby-text-option-type'); const count = Number(controllerEl.getAttribute('shopby-count')); controller.toggleTextOptionType({ optionId, inputNo, type, count, }); productOptionAction.fetchOptionSelector(); }, // eslint-disable-next-line complexity CLICK_PAY_BTN: async () => { // 결제하기 const orderSheetFormHelper = pageHelper.orderSheetFormHelper(); const formData = orderSheetFormHelper.getFormData(); const { appCardInfo, shippingInfo, orderSheetTerms, hasDeliverableProduct, payMethods } = formData; const isValidShippingInfo = checkValidation({ data: shippingInfo, fields: ['receiverName', 'receiverAddress', 'receiverDetailAddress', 'receiverContact1'], }); if (hasDeliverableProduct && !isValidShippingInfo) { return; } const isNotValidCheckedTerms = orderSheetTerms .filter(({ required }) => required) .some(({ checked }) => !checked); if (isNotValidCheckedTerms) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'CAUTION', message: '필수 약관을 확인해 주세요.', onClose: () => { const termCheckBox = document.querySelector('input[shopby-term-id]'); termCheckBox?.focus(); }, }); return; } const { pgType: selectedPgType } = payMethods.selectedPayMethod; if (selectedPgType === 'APP_CARD' && !appCardInfo.cardCode) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'CAUTION', message: '카드를 선택해 주세요.', }); return; } await orderSheetFormHelper.pay({ platform: ShopbySkin.platform, confirmUrl: `${location.origin}/pages/order/order-confirm.html?deliverable=${ hasDeliverableProduct ? 'Y' : 'N' }`, successCallback: (result) => { const { pgType, extraData } = result; const isOneClickPayAppCard = extraData?.authYn === 'Y'; if (pgType === 'APP_CARD' && !isOneClickPayAppCard) { EventManager.fire('OPEN_LAYER_MODAL', { name: 'app-card-authenticate', modalAddClass: 'app-card-authenticate-modal', isFull: true, data: { orderResponse: { ...result }, onRetry: CLICK_ACTION_HANDLER_MAP.CLICK_PAY_BTN, }, }); } }, }); }, SHOW_TERM_DETAIL: (target) => { const orderSheetFormHelper = pageHelper.orderSheetFormHelper(); const { orderSheetTerms } = orderSheetFormHelper.getFormData(); const termId = Number(target.getAttribute('shopby-term-id')); const foundTerm = orderSheetTerms.find(({ termsNo, customTermsNo }) => { const termNo = termsNo ?? customTermsNo; return termNo === termId; }); if (foundTerm) { EventManager.fire('OPEN_LAYER_MODAL', { name: 'term-detail', data: { ...foundTerm, title: foundTerm.termsName }, }); } }, CLICK_DELETE_CARD_ON_APP_CARD: (target) => { const acntId = target.closest('[shopby-acnt-id]')?.getAttribute('shopby-acnt-id'); if (!acntId) { return; } const orderSheetFormHelper = pageHelper.orderSheetFormHelper(); EventManager.fire('MODAL_CONFIRM_OPEN', { noticeType: 'WARNING', message: '카드를 삭제하시겠습니까?', confirmLabel: '확인', onConfirm: async () => { const { isSuccess } = await orderSheetFormHelper.actions.deleteAppCardPayCardsAcntId({ acntId, }); if (isSuccess) { EventManager.fire('MODAL_ALERT_OPEN', { noticeType: 'SUCCESS', message: '카드가 삭제되었습니다.', }); const { data } = productOptionAction.fetchUserOptionSelections(); const amount = data?.reduce((acc, cur) => acc + cur.calcPrice, 0); orderSheetFormHelper.actions.fetchAppCardCards({ amount, }); } }, }); }, }; const CHANGE_ACTION_HANDLER_MAP = { SELECT_OPTION: (target) => { const controller = getController(target); if (!controller) { return; } const labelId = target.getAttribute('shopby-label-id'); const index = Number(target.getAttribute('shopby-index')); const optionId = target.value; controller.selectOption({ labelId, optionId, index, }); productOptionAction.fetchOptionSelector(); }, CHANGE_COUNT: (target) => { const controller = getController(target); if (!controller) { return; } const controllerEl = target.closest('[shopby-controller-id]'); const optionId = controllerEl.getAttribute('shopby-option-id'); const count = Number(target.value); controller.changeCount({ optionId, count, }); productOptionAction.fetchOptionSelector(); }, INPUT_TEXT_OPTION: (target) => { const controller = getController(target); if (!controller) { return; } const controllerEl = target.closest('[shopby-controller-id]'); const optionId = controllerEl.getAttribute('shopby-option-id'); const inputNo = Number(controllerEl.getAttribute('shopby-input-no')); const textOptionId = target.getAttribute('shopby-text-option-id'); controller.inputTextOption({ value: target.value, optionId, inputNo, textOptionId, }); productOptionAction.fetchOptionSelector(); }, }; const INPUT_ACTION_HANDLER_MAP = { INPUT_TEXT_OPTION: (target) => { const counterEl = target.closest('[shopby-counter]'); const countEl = counterEl.querySelector('[shopby-count]'); const errorMessageEl = counterEl.querySelector('[shopby-error-message]'); countEl.textContent = target.value.length; if (target.value.length > 100) { errorMessageEl.textContent = '텍스트옵션 입력글자수를 초과하였습니다.'; } else { errorMessageEl.textContent = ''; } }, }; // CLICK document.querySelector('product-summary')?.addEventListener('click', ({ target }) => { ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(); }); document.querySelector('product-detail')?.addEventListener('click', ({ target }) => { ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); document .querySelector('product-detail-review-summary') ?.addEventListener('click', ({ target }) => { ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); document.querySelector('product-detail-review-list')?.addEventListener('click', ({ target }) => { ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); document.querySelector('product-detail-board-button')?.addEventListener('click', ({ target }) => { ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); document .querySelector('[shopby-tab-element="PRODUCT_INQUIRY"]') ?.addEventListener('click', ({ target }) => { ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); document.querySelector('product-purchase-v2')?.addEventListener('click', ({ target }) => { ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); document.querySelector('related-product')?.addEventListener('click', ({ target }) => { ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); document .querySelector('product-detail-review-photo-collect') ?.addEventListener('click', ({ target }) => { ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); productOptionEl?.addEventListener('click', ({ target }) => { CLICK_ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); orderSheetAppCardEl?.addEventListener('click', ({ target }) => { CLICK_ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); // CHANGE document.querySelector('product-detail-review-list')?.addEventListener('change', ({ target }) => { ACTION_CHANGE_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); productOptionEl?.addEventListener('change', ({ target }) => { CHANGE_ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); productOptionEl?.addEventListener('input', ({ target }) => { INPUT_ACTION_HANDLER_MAP[target.getAttribute('shopby-action')]?.(target); }); setTimeout(() => { // 함께 많이 본 상품 const relateSwiper = new Swiper('.relate_swiper', { slidesPerView: 2.4, spaceBetween: 10, observer: true, observeParents: true, slidesOffsetBefore: 15, slidesOffsetAfter: 15, }); }, 1000); // ======================================== // 배송 날짜 계산 시스템 // ======================================== // Phase 2: 날짜 포맷팅 및 계산 함수들 // 한글 요일 변환 const getDayOfWeekKorean = (date) => { const days = ['일', '월', '화', '수', '목', '금', '토']; return days[date.getDay()]; }; // 날짜 포맷팅 "M/D (요일)" 또는 "내일" const formatDate = (dateStr) => { const date = new Date(dateStr); const today = new Date(); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); // 시간 제거 (날짜만 비교) today.setHours(0, 0, 0, 0); tomorrow.setHours(0, 0, 0, 0); date.setHours(0, 0, 0, 0); // "M/D (요일)" 형식 const month = date.getMonth() + 1; const day = date.getDate(); const dayOfWeek = getDayOfWeekKorean(date); const formattedDate = `${month}/${day}(${dayOfWeek})`; // 내일인 경우 "내일 M/D (요일)" 반환 if (date.getTime() === tomorrow.getTime()) { return `내일 ${formattedDate}`; } // 그 외는 "M/D (요일)" 형식만 return formattedDate; }; // 다음 배송 가능 날짜 찾기 (continuousHoliday 활용) const getNextShippingDate = (rawData, continuousHoliday) => { const today = new Date(); today.setHours(0, 0, 0, 0); // 로컬 날짜를 YYYY-MM-DD 형식으로 변환 (시간대 이슈 방지) const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); const todayStr = `${year}-${month}-${day}`; // 1. 오늘이 연속 휴일 구간에 포함되는지 체크 const currentHolidayPeriod = continuousHoliday.find((period) => { const isInRange = todayStr >= period.startDate && todayStr <= period.endDate; return isInRange; }); if (currentHolidayPeriod) { // 연속 휴일 구간에 포함 → 마지막 날 다음날 반환 // 시간대 이슈 방지를 위해 로컬 날짜로 변환 const [year, month, day] = currentHolidayPeriod.endDate.split('-').map(Number); const nextDate = new Date(year, month - 1, day); nextDate.setDate(nextDate.getDate() + 1); // 로컬 날짜를 YYYY-MM-DD 형식으로 변환 (시간대 이슈 방지) const nextYear = nextDate.getFullYear(); const nextMonth = String(nextDate.getMonth() + 1).padStart(2, '0'); const nextDay = String(nextDate.getDate()).padStart(2, '0'); const nextDateStr = `${nextYear}-${nextMonth}-${nextDay}`; const formatted = formatDate(nextDateStr); return { date: nextDateStr, formatted: formatted, }; } // 3. 예외: rawData에서 다음 평일 찾기 (Fallback) for (const item of rawData) { // 문자열 비교로 날짜 체크 (시간대 이슈 방지) if (item.date > todayStr && !item.isHoliday) { return { date: item.date, formatted: formatDate(item.date), }; } } return null; // 배송 불가 }; // Phase 3: 17시 기준 체크 및 카운트다운 // 현재 시간이 17시 이전인지 체크 const isBeforeCutoffTime = () => { const now = new Date(); const cutoffHour = 17; return now.getHours() < cutoffHour; }; // 17시까지 남은 시간 계산 const getTimeUntilCutoff = () => { const now = new Date(); const cutoff = new Date(); cutoff.setHours(17, 0, 0, 0); if (now >= cutoff) { return '00:00:00'; } const diff = cutoff - now; const hours = Math.floor(diff / (1000 * 60 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((diff % (1000 * 60)) / 1000); return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String( seconds ).padStart(2, '0')}`; }; // 카운트다운 타이머 시작 const startCountdownTimer = (elementId) => { const updateTimer = () => { const timerElement = document.getElementById(elementId); if (!timerElement) return; const timeString = getTimeUntilCutoff(); timerElement.textContent = timeString; if (timeString === '00:00:00') { clearInterval(timerId); location.reload(); // 17시 넘으면 페이지 새로고침 } }; updateTimer(); // 즉시 실행 const timerId = setInterval(updateTimer, 1000); // 1초마다 업데이트 }; // Phase 4: 오늘 휴일 체크 // 오늘이 휴일인지 확인 const isTodayHoliday = (rawData) => { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); const todayStr = `${year}-${month}-${day}`; const todayData = rawData.find((item) => item.date === todayStr); return todayData ? todayData.isHoliday : false; }; // Phase 5: 렌더링 데이터 생성 const generateShippingInfo = (rawData, continuousHoliday) => { const todayIsHoliday = isTodayHoliday(rawData); const beforeCutoff = isBeforeCutoffTime(); const nextShippingDate = getNextShippingDate(rawData, continuousHoliday); // 케이스 1: 평일 17시 이전 → 오늘 출발 가능 if (!todayIsHoliday && beforeCutoff) { return { canShipToday: true, truckIcon: '/assets/images/icons/truck-blue.png', title: '오늘출발 상품', countdown: getTimeUntilCutoff(), message: ' 오늘 바로 발송됩니다.', }; } // 케이스 2: 평일 17시 이후 OR 휴일 → 다음 평일 출발 return { canShipToday: false, truckIcon: '/assets/images/icons/truck-blue.png', title: '오늘출발 마감', nextDate: nextShippingDate ? nextShippingDate.formatted : '미정', message: '에 발송됩니다.', }; }; // Phase 1: API 데이터 가져오기 const getHolidayData = async () => { try { // 실제 API 호출 const response = await fetch('https://management.dotmodkorea.com/api/gov/holidays'); const data = await response.json(); return data; // Mock 데이터 (API 없을 때 테스트용 - 주석 해제해서 사용) const mockData = { rawData: [ // 2025년 11월 { date: '2025-11-01', isHoliday: true }, // 토요일 { date: '2025-11-02', isHoliday: true }, // 일요일 { date: '2025-11-03', isHoliday: false }, // 월요일 { date: '2025-11-04', isHoliday: false }, // 화요일 { date: '2025-11-05', isHoliday: false }, // 수요일 { date: '2025-11-06', isHoliday: false }, // 목요일 { date: '2025-11-07', isHoliday: false }, // 금요일 { date: '2025-11-08', isHoliday: true }, // 토요일 { date: '2025-11-09', isHoliday: true }, // 일요일 { date: '2025-11-10', isHoliday: false }, // 월요일 { date: '2025-11-11', isHoliday: false }, // 화요일 { date: '2025-11-12', isHoliday: false }, // 수요일 { date: '2025-11-13', isHoliday: true }, // 목요일 { date: '2025-11-14', isHoliday: false }, // 금요일 { date: '2025-11-15', isHoliday: true }, // 토요일 { date: '2025-11-16', isHoliday: true }, // 일요일 { date: '2025-11-17', isHoliday: false }, // 월요일 { date: '2025-11-18', isHoliday: false }, // 화요일 { date: '2025-11-19', isHoliday: false }, // 수요일 { date: '2025-11-20', isHoliday: false }, // 목요일 { date: '2025-11-21', isHoliday: false }, // 금요일 { date: '2025-11-22', isHoliday: true }, // 토요일 { date: '2025-11-23', isHoliday: true }, // 일요일 { date: '2025-11-24', isHoliday: false }, // 월요일 { date: '2025-11-25', isHoliday: false }, // 화요일 { date: '2025-11-26', isHoliday: false }, // 수요일 { date: '2025-11-27', isHoliday: false }, // 목요일 { date: '2025-11-28', isHoliday: false }, // 금요일 { date: '2025-11-29', isHoliday: true }, // 토요일 { date: '2025-11-30', isHoliday: true }, // 일요일 // 2025년 12월 { date: '2025-12-01', isHoliday: false }, // 월요일 { date: '2025-12-02', isHoliday: false }, // 화요일 { date: '2025-12-03', isHoliday: false }, // 수요일 { date: '2025-12-04', isHoliday: false }, // 목요일 { date: '2025-12-05', isHoliday: false }, // 금요일 { date: '2025-12-06', isHoliday: true }, // 토요일 { date: '2025-12-07', isHoliday: true }, // 일요일 { date: '2025-12-08', isHoliday: false }, // 월요일 { date: '2025-12-09', isHoliday: false }, // 화요일 { date: '2025-12-10', isHoliday: false }, // 수요일 { date: '2025-12-11', isHoliday: false }, // 목요일 { date: '2025-12-12', isHoliday: false }, // 금요일 { date: '2025-12-13', isHoliday: true }, // 토요일 { date: '2025-12-14', isHoliday: true }, // 일요일 { date: '2025-12-15', isHoliday: false }, // 월요일 { date: '2025-12-16', isHoliday: false }, // 화요일 { date: '2025-12-17', isHoliday: false }, // 수요일 { date: '2025-12-18', isHoliday: false }, // 목요일 { date: '2025-12-19', isHoliday: false }, // 금요일 { date: '2025-12-20', isHoliday: true }, // 토요일 { date: '2025-12-21', isHoliday: true }, // 일요일 { date: '2025-12-22', isHoliday: false }, // 월요일 { date: '2025-12-23', isHoliday: false }, // 화요일 { date: '2025-12-24', isHoliday: false }, // 수요일 { date: '2025-12-25', isHoliday: true }, // 목요일 (크리스마스) { date: '2025-12-26', isHoliday: false }, // 금요일 { date: '2025-12-27', isHoliday: true }, // 토요일 { date: '2025-12-28', isHoliday: true }, // 일요일 { date: '2025-12-29', isHoliday: false }, // 월요일 { date: '2025-12-30', isHoliday: false }, // 화요일 { date: '2025-12-31', isHoliday: false }, // 수요일 ], continuousHoliday: [ { startDate: '2025-11-01', endDate: '2025-11-02', totalDates: 2 }, // 주말 { startDate: '2025-11-08', endDate: '2025-11-09', totalDates: 2 }, // 주말 { startDate: '2025-11-13', endDate: '2025-11-13', totalDates: 1 }, // 크리스마스 { startDate: '2025-11-15', endDate: '2025-11-16', totalDates: 2 }, // 주말 { startDate: '2025-11-22', endDate: '2025-11-23', totalDates: 2 }, // 주말 { startDate: '2025-11-29', endDate: '2025-11-30', totalDates: 2 }, // 주말 { startDate: '2025-12-06', endDate: '2025-12-07', totalDates: 2 }, // 주말 { startDate: '2025-12-13', endDate: '2025-12-14', totalDates: 2 }, // 주말 { startDate: '2025-12-20', endDate: '2025-12-21', totalDates: 2 }, // 주말 { startDate: '2025-12-25', endDate: '2025-12-25', totalDates: 1 }, // 크리스마스 { startDate: '2025-12-27', endDate: '2025-12-28', totalDates: 2 }, // 주말 ], }; return mockData; } catch (error) { console.error('❌ 공휴일 API 호출 실패:', error); return null; } }; // ======================================== // 추가구성상품 (Extra Products) 시스템 // ======================================== const normalizeExtraImageUrl = (url) => { if (!url) return ''; if (url.startsWith('//')) return `https:${url}`; return url; }; const filterExtraProducts = (extraProducts) => { return extraProducts.filter((ep) => { if (ep.limitations?.canAddToCart === false) return false; const hasRequiredInput = ep.optionInfo?.inputs?.some((input) => input.required === true); if (hasRequiredInput) return false; const hasHierarchicalOptions = ep.optionInfo?.multiOptions?.some( (opt) => opt.children && opt.children.length > 0 ); if (hasHierarchicalOptions && !ep.optionInfo?.flatOptions?.length) return false; if (!ep.optionInfo?.flatOptions?.length) return false; return true; }); }; const preprocessExtraProducts = (extraProducts) => { return extraProducts.map((ep) => ({ ...ep, normalizedImageUrl: normalizeExtraImageUrl(ep.imageUrl), optionType: ep.optionInfo?.optionType || 'DEFAULT', flatOptions: ep.optionInfo?.flatOptions || [], })); }; // ShopBy API 공통 헤더 생성 (로그인 시에만 회원 토큰 헤더 포함) const getShopApiHeaders = (clientId, accessToken) => ({ accept: 'application/json', version: '1.0', clientid: clientId, platform: ShopbySkin.platform || 'MOBILE_WEB', language: 'ko', ...(accessToken && { 'shop-by-authorization': `Bearer ${accessToken}` }), }); const fetchExtraProducts = async (clientId, accessToken) => { try { const response = await fetch( `https://shop-api.e-ncp.com/products/${productNo}/extra-products`, { headers: getShopApiHeaders(clientId, accessToken) } ); if (!response.ok) return; const data = await response.json(); if (!data?.extraProducts?.length) return; const filtered = filterExtraProducts(data.extraProducts); if (!filtered.length) return; const processed = preprocessExtraProducts(filtered); extraProductsData = { ...data, extraProducts: processed }; renderExtraProducts(extraProductsData); } catch (error) { console.error('추가구성상품 조회 실패:', error); } }; const renderExtraProducts = (data) => { const container = document.getElementById('extra-products-container'); const templateSource = document.getElementById('extra-products-template')?.innerHTML; if (!container || !templateSource) return; const template = Handlebars.compile(templateSource); container.innerHTML = template(data); container.style.display = 'block'; // DOM 재배치: product-purchase-v2 내부 .purchase__total 앞으로 이동 const totalEl = document.querySelector('.purchase__total'); if (totalEl?.parentNode) { totalEl.parentNode.insertBefore(container, totalEl); if (!document.getElementById('extra-selections-container')) { const selectionsContainer = document.createElement('div'); selectionsContainer.id = 'extra-selections-container'; totalEl.parentNode.insertBefore(selectionsContainer, totalEl); } } // 프레임워크의 기본 수량 행 숨기기 const purchaseEl = document.querySelector('product-purchase-v2'); if (purchaseEl && !purchaseEl.classList.contains('has-extra-products')) { purchaseEl.classList.add('has-extra-products'); const style = document.createElement('style'); style.id = 'extra-products-hide-style'; style.textContent = 'product-purchase-v2.has-extra-products .purchase__quantity-box > div > .quantity { display: none !important; }' + 'product-purchase-v2.has-extra-products .purchase__option { max-height: 60vh; overflow-y: auto; }'; document.head.appendChild(style); } // 펼침/접기 토글 const header = document.getElementById('extra-products-header'); const list = document.getElementById('extra-products-list'); const arrow = document.getElementById('extra-products-arrow'); let isExpanded = true; const toggleList = () => { isExpanded = !isExpanded; list.style.display = isExpanded ? 'block' : 'none'; arrow.textContent = isExpanded ? '∧' : '∨'; }; header?.addEventListener('click', toggleList); // DEFAULT 옵션 타입 container .querySelectorAll('[data-extra-quantity-row][data-default-buy-price]') .forEach((row) => { const epProductNo = Number(row.dataset.extraQuantityRow); const buyPrice = Number(row.dataset.defaultBuyPrice); renderDefaultQuantityRow(epProductNo, buyPrice); }); // 옵션 셀렉트 변경 핸들러 container.querySelectorAll('[data-extra-option-select]').forEach((select) => { select.addEventListener('change', (e) => { const epProductNo = Number(e.target.dataset.extraOptionSelect); const selectedOption = e.target.selectedOptions[0]; if (!selectedOption?.value) return; const optionNo = Number(selectedOption.value); const selectionKey = `${epProductNo}_${optionNo}`; if (extraProductSelections.has(selectionKey)) { alert('이미 선택된 옵션입니다.'); e.target.value = ''; return; } const ep = extraProductsData?.extraProducts?.find((p) => p.productNo === epProductNo); const minBuyCnt = ep?.limitations?.minBuyCnt || 1; extraProductSelections.set(selectionKey, { productNo: epProductNo, optionNo: optionNo, optionValue: selectedOption.textContent.trim(), buyPrice: Number(selectedOption.dataset.buyPrice || 0), count: Math.max(1, minBuyCnt), }); e.target.value = ''; refreshSelections(); }); }); // store.attach if (purchaseEl?.store) { purchaseEl.store.attach('controllers', () => { setTimeout(refreshSelections, 0); }); } refreshSelections(); }; const renderDefaultQuantityRow = (epProductNo, buyPrice) => { const row = document.querySelector( `[data-extra-quantity-row="${epProductNo}"][data-default-buy-price]` ); if (!row) return; row.innerHTML = `
`; row .querySelector(`[data-extra-default-add="${epProductNo}"]`) ?.addEventListener('click', () => { const ep = extraProductsData?.extraProducts?.find((p) => p.productNo === epProductNo); const minBuyCnt = ep?.limitations?.minBuyCnt || 1; const optionNo = ep?.flatOptions?.[0]?.optionNo; if (!optionNo) { console.error('추가구성상품 옵션 번호 없음:', epProductNo); alert('해당 상품은 현재 구매할 수 없습니다.'); return; } const selectionKey = `${epProductNo}_${optionNo}`; if (extraProductSelections.has(selectionKey)) { const existing = extraProductSelections.get(selectionKey); existing.count += 1; } else { extraProductSelections.set(selectionKey, { productNo: epProductNo, optionNo: optionNo, optionValue: ep?.productName || '', buyPrice: buyPrice, count: Math.max(1, minBuyCnt), }); } refreshSelections(); }); }; // 인라인 CSS 스타일 상수 (모바일) const ROW_STYLE = 'position:relative; width:100%; padding:15px 12px 20px; margin-top:10px; background:#f7f7f7; border-radius:5px;'; const LABEL_STYLE = 'display:block; padding-right:20px; color:#000; font-size:15px; font-weight:400; line-height:20px;'; const CONTENT_STYLE = 'display:flex; align-items:center; justify-content:space-between; margin-top:12px;'; const COUNTER_STYLE = 'display:inline-flex; max-width:94px; height:28px; border-radius:5px; font-size:13px; font-family:Pretendard; background-color:#fff;'; const BTN_DECREASE_STYLE = 'width:28px; height:28px; border:1px solid #e5e5e5; background:#fff url(../../assets/img/sub/icon_minus.png) no-repeat center / 11px; font-size:0; border-radius:5px 0 0 5px; cursor:pointer;'; const BTN_INCREASE_STYLE = 'width:28px; height:28px; border:1px solid #e5e5e5; background:#fff url(../../assets/img/sub/icon_plus.png) no-repeat center / 11px; font-size:0; border-radius:0 5px 5px 0; cursor:pointer;'; const INPUT_WRAP_STYLE = 'flex:1; border:solid #e5e5e5; border-width:1px 0; height:28px;'; const INPUT_STYLE = 'width:100%; height:100%; border:none; padding:0; font-size:13px; color:#000; font-weight:500; text-align:center; font-family:Pretendard; box-sizing:border-box;'; const PRICE_STYLE = 'font-size:15px; color:#000; font-weight:500;'; const REMOVE_BTN_STYLE = 'position:absolute; top:12px; right:12px; width:20px; height:20px; border:none; background:transparent; cursor:pointer; padding:0;'; const bindExtraRowEvents = (row, selectionKey, selection, minBuyCnt) => { row.querySelectorAll(`[data-extra-btn="${selectionKey}"]`).forEach((btn) => { btn.addEventListener('click', () => { if (btn.dataset.dir === 'increase') { selection.count += 1; } else { selection.count = Math.max(minBuyCnt, selection.count - 1); } refreshSelections(); }); }); row.querySelector(`[data-extra-count="${selectionKey}"]`)?.addEventListener('change', (e) => { selection.count = Math.max(minBuyCnt, Number(e.target.value) || 1); refreshSelections(); }); row.querySelector(`[data-extra-remove="${selectionKey}"]`)?.addEventListener('click', () => { extraProductSelections.delete(selectionKey); const select = document.querySelector(`[data-extra-option-select="${selection.productNo}"]`); if (select) select.value = ''; const defaultRow = document.querySelector( `[data-extra-quantity-row="${selection.productNo}"][data-default-buy-price]` ); if (defaultRow) { renderDefaultQuantityRow(selection.productNo, Number(defaultRow.dataset.defaultBuyPrice)); } refreshSelections(); }); }; const createSelectionRowHTML = (label, count, price, dataPrefix, dataKey, showRemove) => { return `
${label}
${price.toLocaleString('ko-KR')}원
${ showRemove ? `` : '' }
`; }; const getMainTotalFromStore = () => { const purchaseEl = document.querySelector('product-purchase-v2'); const controllers = purchaseEl?.store?.state?.controllers; if (!controllers || !controllers.length) return 0; return controllers.reduce((sum, ctrl) => { const quantities = ctrl.quantities || []; return sum + quantities.reduce((s, q) => s + (q.calcPrice || 0), 0); }, 0); }; const renderAllSelectionRows = () => { const container = document.getElementById('extra-selections-container'); if (!container) return; container.innerHTML = ''; const purchaseEl = document.querySelector('product-purchase-v2'); const controllers = purchaseEl?.store?.state?.controllers; const mainProductName = currentProductData?.productName || ''; if (controllers) { controllers.forEach((ctrl) => { const quantities = ctrl.quantities || []; const originType = ctrl.originOption?.type; const flatOptionsCount = ctrl.originOption?.flatOptions?.length || 0; const showRemove = !(originType === 'DEFAULT' || flatOptionsCount <= 1); quantities.forEach((q) => { const optionText = (q.selectedValues || []).join(' | '); const label = mainProductName + (optionText ? ' / ' + optionText : ''); const controllerId = q.controller?.id || ctrl.id; const html = createSelectionRowHTML( label, q.count, q.calcPrice || 0, 'main', q.id, showRemove ); container.insertAdjacentHTML('beforeend', html); const row = container.querySelector(`[data-main-row="${q.id}"]`); if (!row) return; row.querySelectorAll(`[data-main-btn="${q.id}"]`).forEach((btn) => { btn.addEventListener('click', () => { const newCount = btn.dataset.dir === 'increase' ? q.count + 1 : Math.max(1, q.count - 1); const { data: controller } = productOptionAction.fetchOptionSelector(controllerId); if (controller) { controller.changeCount({ optionId: q.id, count: newCount }); productOptionAction.fetchOptionSelector(); } }); }); row.querySelector(`[data-main-count="${q.id}"]`)?.addEventListener('change', (e) => { const newCount = Math.max(1, Number(e.target.value) || 1); const { data: controller } = productOptionAction.fetchOptionSelector(controllerId); if (controller) { controller.changeCount({ optionId: q.id, count: newCount }); productOptionAction.fetchOptionSelector(); } }); if (showRemove) { row.querySelector(`[data-main-remove="${q.id}"]`)?.addEventListener('click', () => { const { data: controller } = productOptionAction.fetchOptionSelector(controllerId); if (controller) { controller.removeOption({ optionId: q.id }); productOptionAction.fetchOptionSelector(); } }); } }); }); } extraProductSelections.forEach((selection, selectionKey) => { const ep = extraProductsData?.extraProducts?.find((p) => p.productNo === selection.productNo); if (!ep) return; const minBuyCnt = ep?.limitations?.minBuyCnt || 1; const linePrice = selection.buyPrice * selection.count; const productName = ep.productName || ''; const optionValue = selection.optionValue || ''; const label = productName + (optionValue ? ' / ' + optionValue : ''); const html = createSelectionRowHTML( label, selection.count, linePrice, 'extra', selectionKey, true ); container.insertAdjacentHTML('beforeend', html); const row = container.querySelector(`[data-extra-row="${selectionKey}"]`); if (!row) return; bindExtraRowEvents(row, selectionKey, selection, minBuyCnt); }); }; const getExtraTotalPrice = () => [...extraProductSelections.values()].reduce((sum, sel) => sum + sel.buyPrice * sel.count, 0); const updateCombinedTotal = () => { const mainTotalEl = document.querySelector( '.purchase__total em[slot="controllers"] .highlight' ); if (!mainTotalEl) return; const mainTotal = getMainTotalFromStore(); const extraTotal = getExtraTotalPrice(); mainTotalEl.textContent = (mainTotal + extraTotal).toLocaleString('ko-KR'); }; const refreshSelections = () => { renderAllSelectionRows(); updateCombinedTotal(); }; const getExtraProductCartItems = () => { return [...extraProductSelections.values()].map((sel) => ({ productNo: Number(sel.productNo), optionNo: sel.optionNo, orderCnt: sel.count, channelType, })); }; const getExtraProductOrderItems = () => { return [...extraProductSelections.values()].map((sel) => ({ productNo: Number(sel.productNo), optionNo: sel.optionNo, orderCnt: sel.count, channelType, })); }; // ======================================== // PAGE_LOAD_COMPLETED 이벤트 리스너 EventManager.on('PAGE_LOAD_COMPLETED', async (commonData) => { // 0. product-purchase-v2가 완전히 로드될 때까지 대기 await new Promise((resolve) => setTimeout(resolve, 200)); // 200ms 대기 // 1. 구매 가능 여부 체크 (얼리 리턴) const productElement = document.querySelector('product-purchase-v2'); const productDom = productElement?.store?.state; const isUnPurchasable = productDom?.unpurchasable; if (isUnPurchasable === true) { return; } // 2. 추가구성상품 조회 및 렌더링 const clientId = commonData?.clientId || shopbyCommonData?.clientId; const accessToken = utils.memberAuth.get(); // 쿠키에 저장된 회원 액세스 토큰 if (clientId) { await fetchExtraProducts(clientId, accessToken); } // 3. API 데이터 가져오기 const holidayData = await getHolidayData(); if (!holidayData || !holidayData.rawData) { console.error('❌ 공휴일 데이터를 불러올 수 없습니다.'); return; } // 3. 배송 정보 데이터 생성 (continuousHoliday 활용!) const shippingInfo = generateShippingInfo(holidayData.rawData, holidayData.continuousHoliday); // 4. Handlebars 템플릿 컴파일 const templateSource = document.getElementById('shipping-info-template').innerHTML; const template = Handlebars.compile(templateSource); const html = template(shippingInfo); // 5. DOM에 렌더링 (shipping-today 컴포넌트에 삽입) const targetElement = document.querySelector('shipping-today'); if (targetElement) { targetElement.innerHTML = html; } else { console.error('❌ shipping-today 요소를 찾을 수 없습니다.'); } // 6. 카운트다운 타이머 시작 (오늘 출발 가능한 경우) if (shippingInfo.canShipToday) { startCountdownTimer('countdown-timer'); } }); })();