(() => {
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');
}
});
})();