[260204] 역할별 스크립트 분리, 다크모드 추가

This commit is contained in:
2026-02-04 14:56:59 +09:00
parent d4ccc616a7
commit b29cd5e7d9
12 changed files with 1165 additions and 1056 deletions

View File

@@ -1,660 +0,0 @@
// scripts/app.js 상단
import products from '../data/index.js';
// 이제 products는 모든 카테고리가 합쳐지고 날짜순으로 정렬된 상태입니다.
console.log('Total products loaded:', products.length);
const ITEMS_PER_PAGE = 20;
let currentPage = 1;
let activeCategories = new Set(['All']);
let visibleProducts = products;
let searchKeyword = '';
const VISIBILITY_CONFIG = {
showUnlisted: false, // 🔥 미판매 노출 여부
showSold: true, // 🔥 판매완료 노출 여부
};
const STATUS_META = {
미판매: {
selectable: false,
defaultVisible: false,
soldOut: false,
},
판매예정: {
selectable: true,
defaultVisible: true,
soldOut: false,
},
판매중: {
selectable: true,
defaultVisible: true,
soldOut: false,
},
판매완료: {
selectable: true,
defaultVisible: false,
soldOut: true,
},
};
const STATUS_FILTERS = [
{
key: '판매중',
label: '판매중',
defaultActive: true,
visible: true,
},
{
key: '판매예정',
label: '판매 예정',
defaultActive: true,
visible: true,
},
{
key: '미판매',
label: '미판매',
defaultActive: false,
visible: VISIBILITY_CONFIG.showUnlisted,
},
{
key: '판매완료',
label: '판매완료',
defaultActive: false,
visible: VISIBILITY_CONFIG.showSold,
},
];
const STATUS_ORDER = {
판매중: 0,
판매예정: 1,
미판매: 2,
판매완료: 3, // 🔥 항상 맨 뒤
};
const STATUS_COLOR = {
판매중: 'bg-primary/10 text-primary border-primary/30',
판매예정: 'bg-amber-400/10 text-amber-600 border-amber-400/30',
판매완료: 'bg-slate-400/10 text-slate-500 border-slate-400/30',
미판매: 'bg-slate-200/10 text-slate-400 border-slate-300/30',
};
let activeStatuses = new Set(
Object.entries(STATUS_META)
.filter(([_, meta]) => meta.defaultVisible)
.map(([status]) => status),
);
// openModal 함수 내부에 추가하거나 전역으로 설정
const copyBtn = document.getElementById('copy-link-btn');
const copyBtnText = document.getElementById('copy-btn-text');
copyBtn.onclick = () => {
// 현재 도메인 + 제품 ID 쿼리 조합
const shareUrl = `${window.location.origin}${window.location.pathname}?id=${product.id}`;
navigator.clipboard.writeText(shareUrl).then(() => {
copyBtnText.textContent = '링크가 복사되었습니다!';
copyBtn.classList.replace('bg-slate-900', 'bg-green-600');
setTimeout(() => {
copyBtnText.textContent = '상품 링크 복사하기';
copyBtn.classList.replace('bg-green-600', 'bg-slate-900');
}, 2000);
});
};
function getStatusChipClass(status, isActive) {
const base = STATUS_COLOR[status] ?? '';
if (isActive) {
return `
${base}
opacity-100
shadow-sm
`;
}
// 🔥 비활성
return `
bg-slate-50
text-slate-400
border-slate-200
opacity-30
grayscale
hover:opacity-50
`;
}
function renderStatusChips() {
const container = document.getElementById('status-chips');
if (!container) return;
container.innerHTML = '';
STATUS_FILTERS.filter((f) => f.visible).forEach(({ key, label }) => {
const isActive = activeStatuses.has(key);
const chip = document.createElement('button');
chip.className = `
status-chip
px-4 py-2
rounded-full
text-sm font-medium
transition-all duration-200
border
${getStatusChipClass(key, isActive)}
`;
chip.textContent = label;
chip.onclick = () => toggleStatusFilter(key);
container.appendChild(chip);
});
}
function toggleStatusFilter(status) {
if (activeStatuses.has(status)) {
activeStatuses.delete(status);
} else {
activeStatuses.add(status);
}
// 최소 1개는 유지
if (activeStatuses.size === 0) {
STATUS_FILTERS.filter((f) => f.defaultActive).forEach((f) => activeStatuses.add(f.key));
}
applyFilters();
renderStatusChips();
}
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
searchKeyword = e.target.value.trim().toLowerCase();
applyFilters();
});
}
function applyFilters() {
currentPage = 1;
visibleProducts = products
.filter((product) => {
// 🔒 미판매 강제 차단
if (product.status === '미판매' && !VISIBILITY_CONFIG.showUnlisted) {
return false;
}
// 🔒 판매완료 기본 숨김
if (product.status === '판매완료' && !VISIBILITY_CONFIG.showSold) {
return false;
}
const statusMatch = activeStatuses.has(product.status);
const categoryMatch = activeCategories.has('All') || activeCategories.has(product.category);
const searchMatch = searchKeyword === '' || product.title.toLowerCase().includes(searchKeyword);
return statusMatch && categoryMatch && searchMatch;
})
// 🔥 여기서 정렬
.sort((a, b) => {
const aOrder = STATUS_ORDER[a.status] ?? 999;
const bOrder = STATUS_ORDER[b.status] ?? 999;
return aOrder - bOrder;
});
renderProducts(currentPage);
}
/**
* 1. 상품 목록 렌더링
*/
export function renderProducts(page) {
const grid = document.getElementById('product-grid');
if (!grid) return;
grid.innerHTML = '';
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const pagedProducts = visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE);
pagedProducts.forEach((product) => {
const isSold = STATUS_META[product.status]?.soldOut === true;
const cardHtml = `
<div class="group flex flex-col gap-4 cursor-pointer" onclick="openModal('${product.id}')">
<div class="relative w-full aspect-[4/5] bg-slate-50 dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm group-hover:shadow-md transition-shadow">
<div class="w-full h-full bg-center bg-no-repeat bg-cover transform ${isSold ? 'grayscale opacity-80' : 'group-hover:scale-105'} transition-transform duration-500"
style="background-image: url('${product.images[0]}')"></div>
<div class="absolute top-3 left-3">
<span class="px-2 py-1 text-[10px] uppercase tracking-wider font-bold rounded ${isSold ? 'bg-slate-900/10 text-slate-500' : 'bg-primary/10 text-primary'} backdrop-blur-md border border-primary/20">
${product.status}
</span>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-start">
<h3 class="text-slate-900 dark:text-white text-base font-semibold ${isSold ? 'line-through text-slate-400' : ''}">${product.title}</h3>
<p class="text-slate-900 dark:text-white text-base font-bold text-nowrap">${product.currency}${product.price.toLocaleString()}</p>
</div>
<p class="text-slate-500 dark:text-slate-400 text-sm font-normal">${product.description}</p>
</div>
</div>
`;
grid.insertAdjacentHTML('beforeend', cardHtml);
});
renderPagination();
}
/**
* 2. 모달 열기 및 데이터 채우기
*/
window.openModal = (id) => {
const product = products.find((p) => p.id === id);
if (!product) return;
const modal = document.getElementById('product-modal');
const images = product.images;
// --- 1. 이미지 및 UI 초기화 로직 ---
const loopImages = [images[images.length - 1], ...images, images[0]];
const mainImagesHtml = loopImages
.map(
(img) => `
<div class="flex-shrink-0 w-full h-full snap-center flex items-center justify-center p-4 select-none">
<img src="${img}" draggable="false" class="max-w-full max-h-full object-contain pointer-events-none shadow-sm rounded-lg">
</div>
`,
)
.join('');
const thumbnailsHtml = product.images
.map(
(img, idx) => `
<div onclick="scrollToImage(${idx})"
class="modal-thumb-item size-16 rounded-lg border-2 ${idx === 0 ? 'border-primary' : 'border-transparent'}
bg-cover bg-center overflow-hidden cursor-pointer ${idx === 0 ? 'opacity-100' : 'opacity-70'}
hover:opacity-100 transition-all flex-shrink-0"
style="background-image: url('${img}');"></div>
`,
)
.join('');
const dotsHtml = product.images
.map(
(_, idx) => `
<div class="modal-dot-item ${idx === 0 ? 'w-4 bg-primary' : 'w-2 bg-gray-300 dark:bg-gray-700'} h-2 rounded-full transition-all"></div>
`,
)
.join('');
// --- 2. 데이터 주입 ---
document.getElementById('modal-main-carousel').innerHTML = mainImagesHtml;
document.getElementById('modal-thumbnails').innerHTML = thumbnailsHtml;
document.getElementById('modal-dots').innerHTML = dotsHtml;
document.getElementById('modal-title').textContent = product.title;
document.getElementById('modal-price').textContent = `${product.currency}${product.price.toLocaleString()}`;
// 카테고리 및 상태
const modalCategory = document.getElementById('modal-category');
if (modalCategory) modalCategory.textContent = product.category;
const modalStatus = document.getElementById('modal-status');
if (modalStatus) {
modalStatus.textContent = product.status;
const statusStyles = {
판매중: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
판매예정: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
판매완료: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
미판매: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400',
};
modalStatus.className = 'px-2.5 py-1 rounded-lg text-xs font-bold uppercase tracking-wider ' + (statusStyles[product.status] || statusStyles['미판매']);
}
// 커스텀 태그
const customTagElement = document.getElementById('modal-custom-tag');
if (product.customTag?.trim()) {
customTagElement.textContent = product.customTag;
customTagElement.classList.remove('hidden');
} else {
customTagElement.classList.add('hidden');
}
// 상세 설명
const modalDesc = document.getElementById('modal-desc');
if (modalDesc) {
modalDesc.innerHTML = Array.isArray(product.fullDescription) ? product.fullDescription.join('<br>') : product.fullDescription || '';
}
// --- 3. [핵심] 링크 복사 버튼 이벤트 바인딩 ---
const copyBtn = document.getElementById('copy-link-btn');
const copyBtnText = document.getElementById('copy-btn-text');
if (copyBtn) {
copyBtn.onclick = () => {
const shareUrl = `${window.location.origin}${window.location.pathname}?id=${product.id}`;
navigator.clipboard.writeText(shareUrl).then(() => {
if (copyBtnText) copyBtnText.textContent = '링크가 복사되었습니다!';
copyBtn.classList.add('!bg-green-600');
setTimeout(() => {
if (copyBtnText) copyBtnText.textContent = '상품 링크 복사하기';
copyBtn.classList.remove('!bg-green-600');
}, 2000);
});
};
}
// --- 4. 모달 활성화 및 초기 위치 설정 ---
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
const container = document.getElementById('modal-main-carousel-container');
container.style.scrollBehavior = 'auto';
container.scrollLeft = container.clientWidth;
// 캐러셀 초기화
initBetterCarousel(container, images.length);
};
/**
* 모달 닫기 (URL 정리 기능 포함)
*/
window.closeModal = () => {
document.getElementById('product-modal').classList.add('hidden');
document.body.style.overflow = 'auto';
const cleanUrl = window.location.origin + window.location.pathname;
window.history.replaceState(null, '', cleanUrl);
};
function initBetterCarousel(container, originalLength) {
let isDragging = false;
let startX = 0;
let startScroll = 0;
let startTime = 0;
const width = () => container.clientWidth;
container.addEventListener('mousedown', start);
container.addEventListener('touchstart', start, { passive: true });
function start(e) {
isDragging = true;
startX = e.touches ? e.touches[0].pageX : e.pageX;
startScroll = container.scrollLeft;
startTime = Date.now();
}
container.addEventListener('mousemove', move);
container.addEventListener('touchmove', move, { passive: false });
function move(e) {
if (!isDragging) return;
const x = e.touches ? e.touches[0].pageX : e.pageX;
container.scrollLeft = startScroll - (x - startX);
}
container.addEventListener('mouseup', end);
container.addEventListener('mouseleave', end);
container.addEventListener('touchend', end);
function end(e) {
if (!isDragging) return;
isDragging = false;
const delta = container.scrollLeft - startScroll;
const elapsed = Date.now() - startTime;
const direction = Math.abs(delta) > width() * 0.1 || elapsed < 200 ? (delta > 0 ? 1 : -1) : 0;
let index = Math.round(startScroll / width()) + direction;
container.style.scrollBehavior = 'smooth';
container.scrollTo({ left: index * width() });
// 무한 루프 보정
setTimeout(() => {
container.style.scrollBehavior = 'auto';
if (index === 0) {
container.scrollLeft = width() * originalLength;
}
if (index === originalLength + 1) {
container.scrollLeft = width();
}
syncModalUI(originalLength);
}, 300);
}
}
/**
* 이미지 슬라이드와 UI(Dots, Thumbs) 동기화
*/
function syncModalUI(originalLength) {
const container = document.getElementById('modal-main-carousel-container');
const index = getRealIndex(container, originalLength);
document.querySelectorAll('.modal-thumb-item').forEach((t, i) => {
t.classList.toggle('border-primary', i === index);
t.classList.toggle('opacity-100', i === index);
t.classList.toggle('opacity-70', i !== index);
});
document.querySelectorAll('.modal-dot-item').forEach((d, i) => {
d.classList.toggle('bg-primary', i === index);
d.classList.toggle('w-4', i === index);
d.classList.toggle('bg-gray-300', i !== index);
d.classList.toggle('w-2', i !== index);
});
ensureThumbnailVisible(index);
}
/**
* 3. 모달 내 이미지 스크롤 및 UI 동기화
*/
window.scrollToImage = (index) => {
const container = document.getElementById('modal-main-carousel-container');
if (!container) return;
container.scrollTo({
left: container.clientWidth * (index + 1), // 🔥 중요
behavior: 'smooth',
});
};
/**
* 5. 기타 (페이지네이션, 모달 닫기)
*/
function renderPagination() {
const container = document.getElementById('pagination');
if (!container) return;
const totalPages = Math.ceil(visibleProducts.length / ITEMS_PER_PAGE);
let html = `<button onclick="changePage(${currentPage - 1})" class="size-10 flex items-center justify-center ${currentPage === 1 ? 'invisible' : ''}"><svg viewBox="0 0 24 24" fill="none"
stroke="#64748B" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round"
class="w-5 h-5">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>`;
for (let i = 1; i <= totalPages; i++) {
html += `<button onclick="changePage(${i})" class="size-10 font-bold rounded-lg ${i === currentPage ? 'bg-primary text-white' : 'text-slate-500'}">${i}</button>`;
}
html += `<button onclick="changePage(${currentPage + 1})" class="size-10 flex items-center justify-center ${currentPage === totalPages ? 'invisible' : ''}"><svg viewBox="0 0 24 24" fill="none"
stroke="#64748B" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round"
class="w-5 h-5">
<path d="M9 18l6-6-6-6" />
</svg>
</button>`;
container.innerHTML = html;
}
window.changePage = (page) => {
currentPage = page;
renderProducts(currentPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// 초기 실행
document.addEventListener('DOMContentLoaded', () => renderProducts(currentPage));
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const modal = document.getElementById('product-modal');
if (!modal || modal.classList.contains('hidden')) return;
closeModal();
});
const thumbnailContainer = document.getElementById('modal-thumbnails');
function ensureThumbnailVisible(index) {
const container = document.getElementById('modal-thumbnails');
if (!container) return;
const thumbs = container.querySelectorAll('.modal-thumb-item');
const active = thumbs[index];
if (!active) return;
const cRect = container.getBoundingClientRect();
const tRect = active.getBoundingClientRect();
const isVisible = tRect.top >= cRect.top && tRect.bottom <= cRect.bottom;
if (!isVisible) {
active.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}
function getRealIndex(container, originalLength) {
let rawIndex = Math.round(container.scrollLeft / container.clientWidth);
let index = rawIndex - 1; // 클론 보정
if (index < 0) index = originalLength - 1;
if (index >= originalLength) index = 0;
return index;
}
// 카테고리 필터
function getCategories(products) {
return ['All', ...new Set(products.map((p) => p.category))];
}
function renderCategoryChips(products) {
const container = document.getElementById('filter-chips');
if (!container) return;
const categories = ['All', ...new Set(products.map((p) => p.category))];
container.innerHTML = '';
categories.forEach((cat) => {
const isActive = activeCategories.has(cat);
const chip = document.createElement('button');
chip.className = `
filter-chip px-4 py-2 rounded-full text-sm font-medium transition
border
${isActive ? 'bg-primary text-white border-primary' : 'bg-slate-50 text-slate-600 border-slate-200'}
`;
chip.textContent = cat;
chip.dataset.category = cat;
chip.onclick = () => {
toggleCategory(cat);
};
container.appendChild(chip);
});
}
function toggleCategory(category) {
if (category === 'All') {
activeCategories.clear();
activeCategories.add('All');
} else {
activeCategories.delete('All');
activeCategories.has(category) ? activeCategories.delete(category) : activeCategories.add(category);
if (activeCategories.size === 0) {
activeCategories.add('All');
}
}
renderCategoryChips(products);
applyFilters();
}
function bindCategoryFilter(products) {
const chips = document.querySelectorAll('.filter-chip');
chips.forEach((chip) => {
chip.addEventListener('click', () => {
const category = chip.dataset.category;
if (category === 'All') {
activeCategories.clear();
activeCategories.add('All');
} else {
activeCategories.delete('All');
if (activeCategories.has(category)) {
activeCategories.delete(category);
} else {
activeCategories.add(category);
}
// 아무 것도 없으면 All로 복귀
if (activeCategories.size === 0) {
activeCategories.add('All');
}
}
applyFilters();
});
});
}
// 카테고리 필터
renderCategoryChips(products);
bindCategoryFilter(products);
// updateChipUI();
// 상태 필터 (정책 기반)
renderStatusChips();
// 🔥 최초 필터 적용 (이게 첫 렌더)
applyFilters();
// 초기 실행 시 호출
document.addEventListener('DOMContentLoaded', () => {
renderProducts(currentPage);
checkUrlAndOpenModal();
});
function checkUrlAndOpenModal() {
const params = new URLSearchParams(window.location.search);
const productId = params.get('id'); // URL에서 가져온 ID (문자열)
if (productId) {
// 데이터의 id와 URL의 id를 모두 문자열로 변환하여 비교
const product = products.find((p) => String(p.id) === productId);
if (product) {
// DOM 렌더링 시간을 고려해 약간의 지연 후 모달 오픈
setTimeout(() => openModal(product.id), 100);
}
}
}
// [최종 ID 생성기] 매번 완전히 새로운 8자리 난수 출력
const newId = Math.random().toString(36).substring(2, 10);
console.log(`%c[NEW ID for data.js]: ${newId}`, 'color: #137fec; font-weight: bold; border: 1px solid #137fec; padding: 2px 5px; border-radius: 4px;');

116
scripts/carousel.js Normal file
View File

@@ -0,0 +1,116 @@
/** 모달 내 이미지 캐러셀 (드래그·스와이프·인덱스 동기화) */
export function getRealIndex(container, originalLength) {
let rawIndex = Math.round(container.scrollLeft / container.clientWidth);
let index = rawIndex - 1;
if (index < 0) index = originalLength - 1;
if (index >= originalLength) index = 0;
return index;
}
export function ensureThumbnailVisible(index) {
const container = document.getElementById('modal-thumbnails');
if (!container) return;
const thumbs = container.querySelectorAll('.modal-thumb-item');
const active = thumbs[index];
if (!active) return;
const cRect = container.getBoundingClientRect();
const tRect = active.getBoundingClientRect();
const isVisible = tRect.top >= cRect.top && tRect.bottom <= cRect.bottom;
if (!isVisible) {
active.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
export function syncModalUI(originalLength) {
const container = document.getElementById('modal-main-carousel-container');
if (!container) return;
const index = getRealIndex(container, originalLength);
document.querySelectorAll('.modal-thumb-item').forEach((t, i) => {
t.classList.toggle('border-primary', i === index);
t.classList.toggle('opacity-100', i === index);
t.classList.toggle('opacity-70', i !== index);
});
document.querySelectorAll('.modal-dot-item').forEach((d, i) => {
d.classList.toggle('bg-primary', i === index);
d.classList.toggle('w-4', i === index);
d.classList.toggle('bg-gray-300', i !== index);
d.classList.toggle('w-2', i !== index);
});
ensureThumbnailVisible(index);
}
export function initBetterCarousel(container, originalLength) {
let isDragging = false;
let startX = 0;
let startScroll = 0;
let startTime = 0;
const width = () => container.clientWidth;
container.addEventListener('mousedown', start);
container.addEventListener('touchstart', start, { passive: true });
function start(e) {
isDragging = true;
startX = e.touches ? e.touches[0].pageX : e.pageX;
startScroll = container.scrollLeft;
startTime = Date.now();
}
container.addEventListener('mousemove', move);
container.addEventListener('touchmove', move, { passive: false });
function move(e) {
if (!isDragging) return;
const x = e.touches ? e.touches[0].pageX : e.pageX;
container.scrollLeft = startScroll - (x - startX);
}
container.addEventListener('mouseup', end);
container.addEventListener('mouseleave', end);
container.addEventListener('touchend', end);
function end(e) {
if (!isDragging) return;
isDragging = false;
const w = width();
if (w <= 0) return;
const endScroll = container.scrollLeft;
const delta = endScroll - startScroll;
const elapsed = Date.now() - startTime;
// 클릭만 했을 때(이동 거리 거의 없음)는 슬라이드 이동 안 함. 일정 이상 드래그했을 때만 방향 적용
const minMove = Math.min(w * 0.05, 20);
const hasMoved = Math.abs(delta) >= minMove;
const direction = hasMoved && (Math.abs(delta) > w * 0.1 || elapsed < 200) ? (delta > 0 ? 1 : -1) : 0;
let index = Math.round(endScroll / w) + direction;
index = Math.max(0, Math.min(originalLength + 1, index));
const targetLeft = index * w;
// 손 뗀 직후 같은 프레임에서 스크롤 충돌 방지 + 미세 이동은 즉시 적용해서 튐 방지
requestAnimationFrame(() => {
const snapThreshold = w * 0.03;
const useSmooth = Math.abs(container.scrollLeft - targetLeft) > snapThreshold;
container.style.scrollBehavior = useSmooth ? 'smooth' : 'auto';
container.scrollTo({ left: targetLeft });
const loopDelay = useSmooth ? 320 : 0;
setTimeout(() => {
container.style.scrollBehavior = 'auto';
if (index === 0) container.scrollLeft = w * originalLength;
if (index === originalLength + 1) container.scrollLeft = w;
syncModalUI(originalLength);
}, loopDelay);
});
}
}
export function scrollToImage(index) {
const container = document.getElementById('modal-main-carousel-container');
if (!container) return;
container.scrollTo({
left: container.clientWidth * (index + 1),
behavior: 'smooth',
});
}

66
scripts/config.js Normal file
View File

@@ -0,0 +1,66 @@
/** 상품 목록·필터·모달 관련 상수 */
export const ITEMS_PER_PAGE = 20;
export const VISIBILITY_CONFIG = {
showUnlisted: false,
showSold: true,
};
export const STATUS_META = {
미판매: {
selectable: false,
defaultVisible: false,
soldOut: false,
},
판매예정: {
selectable: true,
defaultVisible: true,
soldOut: false,
},
판매중: {
selectable: true,
defaultVisible: true,
soldOut: false,
},
판매완료: {
selectable: true,
defaultVisible: false,
soldOut: true,
},
};
export const STATUS_FILTERS = [
{ key: '판매중', label: '판매중', defaultActive: true, visible: true },
{ key: '판매예정', label: '판매 예정', defaultActive: true, visible: true },
{ key: '미판매', label: '미판매', defaultActive: false, visible: VISIBILITY_CONFIG.showUnlisted },
{ key: '판매완료', label: '판매완료', defaultActive: false, visible: VISIBILITY_CONFIG.showSold },
];
export const STATUS_ORDER = {
판매중: 0,
판매예정: 1,
미판매: 2,
판매완료: 3,
};
export const STATUS_COLOR = {
판매중: 'bg-primary/10 text-primary border-primary/30',
판매예정: 'bg-amber-400/10 text-amber-600 border-amber-400/30',
판매완료: 'bg-slate-400/10 text-slate-500 border-slate-400/30',
미판매: 'bg-slate-200/10 text-slate-400 border-slate-300/30',
};
/** 모달 커스텀 태그(customTag) 키워드별 뱃지 스타일 */
export const TAG_STYLES = {
완전생산한정판: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
특전포함: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400',
미개봉: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
무료배송: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
풀윤활완료: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
급매: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
정품: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400',
풀옵션: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400',
};
export const TAG_DEFAULT_STYLE = 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400';

117
scripts/filters.js Normal file
View File

@@ -0,0 +1,117 @@
/** 상태·카테고리·검색 필터 로직 및 UI */
import { state, productsData } from './state.js';
import {
VISIBILITY_CONFIG,
STATUS_FILTERS,
STATUS_ORDER,
STATUS_COLOR,
} from './config.js';
import { renderProducts } from './productList.js';
function getStatusChipClass(status, isActive) {
const base = STATUS_COLOR[status] ?? '';
if (isActive) {
return `${base} opacity-100 shadow-sm`;
}
return `bg-slate-50 text-slate-400 border-slate-200 opacity-30 grayscale hover:opacity-50`;
}
export function renderStatusChips() {
const container = document.getElementById('status-chips');
if (!container) return;
container.innerHTML = '';
STATUS_FILTERS.filter((f) => f.visible).forEach(({ key, label }) => {
const isActive = state.activeStatuses.has(key);
const chip = document.createElement('button');
chip.className = `status-chip px-3 py-1.5 md:px-4 md:py-2 rounded-full text-xs md:text-sm font-medium transition-all duration-200 border ${getStatusChipClass(key, isActive)}`;
chip.textContent = label;
chip.onclick = () => toggleStatusFilter(key);
container.appendChild(chip);
});
}
function toggleStatusFilter(status) {
if (state.activeStatuses.has(status)) {
state.activeStatuses.delete(status);
} else {
state.activeStatuses.add(status);
}
if (state.activeStatuses.size === 0) {
STATUS_FILTERS.filter((f) => f.defaultActive).forEach((f) => state.activeStatuses.add(f.key));
}
applyFilters();
renderStatusChips();
}
export function applyFilters() {
state.currentPage = 1;
state.visibleProducts = productsData
.filter((product) => {
if (product.status === '미판매' && !VISIBILITY_CONFIG.showUnlisted) return false;
if (product.status === '판매완료' && !VISIBILITY_CONFIG.showSold) return false;
const statusMatch = state.activeStatuses.has(product.status);
const categoryMatch = state.activeCategories.has('All') || state.activeCategories.has(product.category);
const searchMatch = state.searchKeyword === '' || product.title.toLowerCase().includes(state.searchKeyword);
return statusMatch && categoryMatch && searchMatch;
})
.sort((a, b) => {
const aOrder = STATUS_ORDER[a.status] ?? 999;
const bOrder = STATUS_ORDER[b.status] ?? 999;
return aOrder - bOrder;
});
renderProducts(state.currentPage);
}
export function getCategories(products) {
return ['All', ...new Set(products.map((p) => p.category))];
}
export function renderCategoryChips(products) {
const container = document.getElementById('filter-chips');
if (!container) return;
const categories = ['All', ...new Set(products.map((p) => p.category))];
container.innerHTML = '';
categories.forEach((cat) => {
const isActive = state.activeCategories.has(cat);
const chip = document.createElement('button');
chip.className = `filter-chip px-3 py-1.5 md:px-4 md:py-2 rounded-full text-xs md:text-sm font-medium transition border ${isActive ? 'bg-primary text-white border-primary' : 'bg-slate-50 text-slate-600 border-slate-200'}`;
chip.textContent = cat;
chip.dataset.category = cat;
chip.onclick = () => toggleCategory(cat);
container.appendChild(chip);
});
}
export function toggleCategory(category) {
if (category === 'All') {
state.activeCategories.clear();
state.activeCategories.add('All');
} else {
state.activeCategories.delete('All');
state.activeCategories.has(category) ? state.activeCategories.delete(category) : state.activeCategories.add(category);
if (state.activeCategories.size === 0) state.activeCategories.add('All');
}
renderCategoryChips(productsData);
applyFilters();
}
export function bindCategoryFilter(products) {
const chips = document.querySelectorAll('.filter-chip');
chips.forEach((chip) => {
chip.addEventListener('click', () => {
const category = chip.dataset.category;
if (category === 'All') {
state.activeCategories.clear();
state.activeCategories.add('All');
} else {
state.activeCategories.delete('All');
if (state.activeCategories.has(category)) state.activeCategories.delete(category);
else state.activeCategories.add(category);
if (state.activeCategories.size === 0) state.activeCategories.add('All');
}
applyFilters();
});
});
}

99
scripts/main.js Normal file
View File

@@ -0,0 +1,99 @@
/** 진입점: 이벤트 바인딩·초기 렌더·URL 모달 처리 */
import { state, productsData } from './state.js';
import {
applyFilters,
renderStatusChips,
renderCategoryChips,
bindCategoryFilter,
} from './filters.js';
import { renderProducts, changePage } from './productList.js';
import { openModal, closeModal } from './modal.js';
import { scrollToImage } from './carousel.js';
console.log('Total products loaded:', productsData.length);
// HTML onclick에서 사용
window.openModal = openModal;
window.closeModal = closeModal;
window.changePage = changePage;
window.scrollToImage = scrollToImage;
// 검색 입력
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
state.searchKeyword = e.target.value.trim().toLowerCase();
applyFilters();
});
}
// Escape로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const modal = document.getElementById('product-modal');
if (!modal || modal.classList.contains('hidden')) return;
closeModal();
});
function checkUrlAndOpenModal() {
const params = new URLSearchParams(window.location.search);
const productId = params.get('id');
if (productId) {
const product = productsData.find((p) => String(p.id) === productId);
if (product) {
setTimeout(() => openModal(product.id), 100);
}
}
}
// 초기 테마 설정 확인
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
const themeToggleBtn = document.getElementById('theme-toggle');
// 1. 현재 테마 상태에 따라 아이콘 표시/숨김 처리
function updateIcons() {
if (document.documentElement.classList.contains('dark')) {
themeToggleLightIcon.classList.remove('hidden');
themeToggleDarkIcon.classList.add('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
themeToggleLightIcon.classList.add('hidden');
}
}
// 2. 초기 로드 시 설정 (localStorage 또는 시스템 설정 확인)
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
updateIcons();
// 3. 버튼 클릭 이벤트
themeToggleBtn.addEventListener('click', function() {
// 테마 토글
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
updateIcons();
});
// 초기 렌더
renderCategoryChips(productsData);
bindCategoryFilter(productsData);
renderStatusChips();
applyFilters();
document.addEventListener('DOMContentLoaded', () => {
renderProducts(state.currentPage);
checkUrlAndOpenModal();
});
// 데이터용 새 ID 생성기
const newId = Math.random().toString(36).substring(2, 10);
console.log(`%c[NUMBER]: ${newId}`, 'color: #137fec; font-weight: bold; border: 1px solid #137fec; padding: 2px 5px; border-radius: 4px;');

154
scripts/modal.js Normal file
View File

@@ -0,0 +1,154 @@
/** 상품 상세 모달 (열기/닫기·콘텐츠 채우기·링크 복사) */
import { productsData } from './state.js';
import { initBetterCarousel } from './carousel.js';
import { TAG_STYLES, TAG_DEFAULT_STYLE } from './config.js';
export function openModal(id) {
const product = productsData.find((p) => p.id === id);
if (!product) return;
const modal = document.getElementById('product-modal');
const images = product.images;
const loopImages = [images[images.length - 1], ...images, images[0]];
const mainImagesHtml = loopImages
.map(
(img) => `
<div class="flex-shrink-0 w-full h-full snap-center flex items-center justify-center p-0 md:p-4 select-none">
<div class="w-full h-full max-w-full max-h-full rounded-xl md:rounded-2xl overflow-hidden flex items-center justify-center">
<img src="${img}" draggable="false" class="max-w-full max-h-full w-auto h-auto object-contain sm:object-cover pointer-events-none rounded-md">
</div>
</div>
`,
)
.join('');
const thumbnailsHtml = product.images
.map(
(img, idx) => `
<div onclick="scrollToImage(${idx})"
class="modal-thumb-item size-16 rounded-lg border-2 ${idx === 0 ? 'border-primary' : 'border-transparent'}
bg-cover bg-center overflow-hidden cursor-pointer ${idx === 0 ? 'opacity-100' : 'opacity-70'}
hover:opacity-100 transition-all flex-shrink-0"
style="background-image: url('${img}');"></div>
`,
)
.join('');
const dotsHtml = product.images
.map(
(_, idx) => `
<div class="modal-dot-item ${idx === 0 ? 'w-4 bg-primary' : 'w-2 bg-gray-300 dark:bg-gray-700'} h-2 rounded-full transition-all"></div>
`,
)
.join('');
document.getElementById('modal-main-carousel').innerHTML = mainImagesHtml;
document.getElementById('modal-thumbnails').innerHTML = thumbnailsHtml;
document.getElementById('modal-dots').innerHTML = dotsHtml;
document.getElementById('modal-title').textContent = product.title;
document.getElementById('modal-price').textContent = `${product.currency}${product.price.toLocaleString()}`;
const modalCategory = document.getElementById('modal-category');
if (modalCategory) modalCategory.textContent = product.category;
const modalStatus = document.getElementById('modal-status');
if (modalStatus) {
modalStatus.textContent = product.status;
const statusStyles = {
판매중: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
판매예정: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
판매완료: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
미판매: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400',
};
modalStatus.className = 'inline-flex shrink-0 px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-md sm:rounded-lg text-[10px] sm:text-[11px] md:text-xs font-bold uppercase tracking-wider whitespace-nowrap ' + (statusStyles[product.status] || statusStyles['미판매']);
}
const customTagElement = document.getElementById('modal-custom-tag');
const tagText = product.customTag?.trim();
if (tagText) {
customTagElement.textContent = tagText;
customTagElement.classList.remove('hidden');
customTagElement.className = `inline-flex shrink-0 px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-md sm:rounded-lg text-[10px] sm:text-[11px] md:text-xs font-bold uppercase tracking-wider whitespace-nowrap ${TAG_STYLES[tagText] || TAG_DEFAULT_STYLE}`;
} else {
customTagElement.classList.add('hidden');
}
// 구매일자: 값이 있을 때만 행 노출
const modalDate = document.getElementById('modal-date');
const modalDateRow = document.getElementById('modal-date-row');
const pDate = product.specs?.purchaseDate;
if (pDate && String(pDate).trim() !== '' && String(pDate) !== 'null') {
if (modalDate) modalDate.textContent = pDate;
if (modalDateRow) {
modalDateRow.classList.remove('hidden');
modalDateRow.classList.add('flex');
}
} else {
if (modalDateRow) {
modalDateRow.classList.add('hidden');
modalDateRow.classList.remove('flex');
}
}
// 제품 상태(specs.condition): 값이 있을 때만 행 노출
const conditionText = product.specs?.condition;
const isVerified = product.specs?.isVerified;
const conditionValueEl = document.getElementById('modal-condition');
const conditionRowEl = document.getElementById('modal-condition-row');
const conditionRowWrap = conditionRowEl?.parentElement; // 라벨+값 전체 행
const verifiedIcon = document.getElementById('modal-verified-icon');
if (conditionText && String(conditionText).trim() !== '') {
if (conditionValueEl) conditionValueEl.textContent = conditionText;
if (conditionRowWrap) {
conditionRowWrap.classList.remove('hidden');
conditionRowWrap.classList.add('flex');
}
if (verifiedIcon) {
if (isVerified) verifiedIcon.classList.remove('hidden');
else verifiedIcon.classList.add('hidden');
}
} else {
if (conditionRowWrap) {
conditionRowWrap.classList.add('hidden');
conditionRowWrap.classList.remove('flex');
}
}
const modalDesc = document.getElementById('modal-desc');
if (modalDesc) {
modalDesc.innerHTML = Array.isArray(product.fullDescription) ? product.fullDescription.join('<br>') : product.fullDescription || '';
}
const copyBtn = document.getElementById('copy-link-btn');
const copyBtnText = document.getElementById('copy-btn-text');
if (copyBtn) {
copyBtn.onclick = () => {
const shareUrl = `${window.location.origin}${window.location.pathname}?id=${product.id}`;
navigator.clipboard.writeText(shareUrl).then(() => {
if (copyBtnText) copyBtnText.textContent = '링크가 복사되었습니다!';
copyBtn.classList.add('!bg-green-600');
setTimeout(() => {
if (copyBtnText) copyBtnText.textContent = '상품 링크 복사하기';
copyBtn.classList.remove('!bg-green-600');
}, 2000);
});
};
}
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
const container = document.getElementById('modal-main-carousel-container');
container.style.scrollBehavior = 'auto';
container.scrollLeft = container.clientWidth;
initBetterCarousel(container, images.length);
}
export function closeModal() {
document.getElementById('product-modal').classList.add('hidden');
document.body.style.overflow = 'auto';
const cleanUrl = window.location.origin + window.location.pathname;
window.history.replaceState(null, '', cleanUrl);
}

71
scripts/productList.js Normal file
View File

@@ -0,0 +1,71 @@
/** 상품 그리드·페이지네이션 렌더링 */
import { state } from './state.js';
import { ITEMS_PER_PAGE, STATUS_META } from './config.js';
export function renderProducts(page) {
const grid = document.getElementById('product-grid');
if (!grid) return;
grid.innerHTML = '';
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const pagedProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE);
pagedProducts.forEach((product) => {
const isSold = STATUS_META[product.status]?.soldOut === true;
const cardHtml = `
<div class="group flex flex-col gap-4 cursor-pointer" onclick="openModal('${product.id}')">
<div class="relative w-full aspect-[4/5] bg-slate-50 dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm group-hover:shadow-md transition-shadow">
<div class="w-full h-full bg-center bg-no-repeat bg-cover transform ${isSold ? 'grayscale opacity-80' : 'group-hover:scale-105'} transition-transform duration-500"
style="background-image: url('${product.images[0]}')"></div>
<div class="absolute top-3 left-3">
<span class="px-2 py-1 text-[10px] uppercase tracking-wider font-bold rounded ${isSold ? 'bg-slate-900/10 text-slate-500' : 'bg-primary/10 text-primary'} backdrop-blur-md border border-primary/20">
${product.status}
</span>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex flex-col sm:flex-row justify-between items-start">
<h3 class="text-slate-900 dark:text-white text-base font-semibold ${isSold ? 'line-through text-slate-400' : ''}">${product.title}</h3>
<p class="text-slate-900 dark:text-white text-base font-bold text-nowrap">${product.currency}${product.price.toLocaleString()}</p>
</div>
<p class="text-slate-500 dark:text-slate-400 text-sm font-normal">${product.description}</p>
</div>
</div>
`;
grid.insertAdjacentHTML('beforeend', cardHtml);
});
renderPagination();
}
export function renderPagination() {
const container = document.getElementById('pagination');
if (!container) return;
const totalPages = Math.ceil(state.visibleProducts.length / ITEMS_PER_PAGE);
const { currentPage } = state;
let html = `<button onclick="changePage(${currentPage - 1})" class="size-10 flex items-center justify-center ${currentPage === 1 ? 'invisible' : ''}"><svg viewBox="0 0 24 24" fill="none"
stroke="#64748B" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round"
class="w-5 h-5">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>`;
for (let i = 1; i <= totalPages; i++) {
html += `<button onclick="changePage(${i})" class="size-10 font-bold rounded-lg ${i === currentPage ? 'bg-primary text-white' : 'text-slate-500'}">${i}</button>`;
}
html += `<button onclick="changePage(${currentPage + 1})" class="size-10 flex items-center justify-center ${currentPage === totalPages ? 'invisible' : ''}"><svg viewBox="0 0 24 24" fill="none"
stroke="#64748B" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round"
class="w-5 h-5">
<path d="M9 18l6-6-6-6" />
</svg>
</button>`;
container.innerHTML = html;
}
export function changePage(page) {
state.currentPage = page;
renderProducts(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
}

17
scripts/state.js Normal file
View File

@@ -0,0 +1,17 @@
/** 앱 전역 상태 */
import products from '../data/index.js';
import { STATUS_META } from './config.js';
export const productsData = products;
export const state = {
currentPage: 1,
activeCategories: new Set(['All']),
visibleProducts: [...products],
searchKeyword: '',
activeStatuses: new Set(
Object.entries(STATUS_META)
.filter(([_, meta]) => meta.defaultVisible)
.map(([status]) => status),
),
};