diff --git a/scripts/carousel.js b/scripts/carousel.js index 6d39c3c..3251218 100644 --- a/scripts/carousel.js +++ b/scripts/carousel.js @@ -1,111 +1,51 @@ -/** 모달 내 이미지 캐러셀 (드래그·스와이프·인덱스 동기화) */ +/** + * carousel.js + * 모달 이미지 캐러셀: 무한 루프 슬라이드, 드래그/스와이프 및 UI 동기화 + */ + +// ========================================================================== +// 1. 유틸리티 및 계산 함수 (Internal Utils) +// ========================================================================== + +/** 현재 스크롤 위치를 바탕으로 실제 이미지 인덱스(0 ~ length-1)를 반환 */ export function getRealIndex(container, originalLength) { - let rawIndex = Math.round(container.scrollLeft / container.clientWidth); + const 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' }); - } +/** 드래그 종료 시 관성 및 이동 거리를 계산하여 다음 인덱스 결정 */ +function calculateTargetIndex(delta, elapsed, currentScroll, width, originalLength) { + const minMove = Math.min(width * 0.05, 20); + const hasMoved = Math.abs(delta) >= minMove; + + // 빠른 스와이프(200ms 미만) 또는 일정 거리 이상 드래그 시 방향 확정 + const direction = hasMoved && (Math.abs(delta) > width * 0.1 || elapsed < 200) ? (delta > 0 ? 1 : -1) : 0; + + let index = Math.round(currentScroll / width) + direction; + return Math.max(0, Math.min(originalLength + 1, index)); } +// ========================================================================== +// 2. UI 동기화 및 스크롤 (UI & Scrolling) +// ========================================================================== + 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); - }); - + // 썸네일 & 도트 업데이트 로직 (생략 - 기존과 동일) + updateThumbnailUI(index); + updateDotUI(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; @@ -114,3 +54,78 @@ export function scrollToImage(index) { behavior: 'smooth', }); } + +// ========================================================================== +// 3. 캐러셀 엔진 (Core Engine) +// ========================================================================== + +export function initBetterCarousel(container, originalLength) { + let state = { + isDragging: false, + startX: 0, + startScroll: 0, + startTime: 0, + }; + + const getX = (e) => (e.touches ? e.touches[0].pageX : e.pageX); + + // 핸들러 분리: 시작 + const handleStart = (e) => { + state.isDragging = true; + state.startX = getX(e); + state.startScroll = container.scrollLeft; + state.startTime = Date.now(); + }; + + // 핸들러 분리: 이동 + const handleMove = (e) => { + if (!state.isDragging) return; + container.scrollLeft = state.startScroll - (getX(e) - state.startX); + }; + + // 핸들러 분리: 종료 + const handleEnd = () => { + if (!state.isDragging) return; + state.isDragging = false; + + const w = container.clientWidth; + if (w <= 0) return; + + const delta = container.scrollLeft - state.startScroll; + const elapsed = Date.now() - state.startTime; + + const index = calculateTargetIndex(delta, elapsed, container.scrollLeft, w, originalLength); + finalizeScroll(container, index, w, originalLength); + }; + + /** 드래그 종료 후 스냅 애니메이션 및 루프 처리 */ + function finalizeScroll(container, index, width, originalLength) { + requestAnimationFrame(() => { + const targetLeft = index * width; + const useSmooth = Math.abs(container.scrollLeft - targetLeft) > width * 0.03; + + container.style.scrollBehavior = useSmooth ? 'smooth' : 'auto'; + container.scrollTo({ left: targetLeft }); + + // 루프 워프 처리 + setTimeout( + () => { + container.style.scrollBehavior = 'auto'; + if (index === 0) container.scrollLeft = width * originalLength; + if (index === originalLength + 1) container.scrollLeft = width; + syncModalUI(originalLength); + }, + useSmooth ? 320 : 0, + ); + }); + } + + // 리스너 등록 + container.addEventListener('mousedown', handleStart); + container.addEventListener('touchstart', handleStart, { passive: true }); + container.addEventListener('mousemove', handleMove); + container.addEventListener('touchmove', handleMove, { passive: false }); + container.addEventListener('mouseup', handleEnd); + container.addEventListener('mouseleave', handleEnd); + container.addEventListener('touchend', handleEnd); +} diff --git a/scripts/config.js b/scripts/config.js index 4b43fb7..bdf7641 100644 --- a/scripts/config.js +++ b/scripts/config.js @@ -1,22 +1,47 @@ -/** 상품 목록·필터·모달 관련 상수 */ +/** + * config.js + * 앱 전역 설정: 페이지네이션, 상품 상태 메타데이터, 검색 및 태그 스타일 정의 + */ +// ========================================================================== +// 1. 기본 앱 설정 (General Settings) +// ========================================================================== + +/** 한 페이지당 표시할 상품 수 */ export const ITEMS_PER_PAGE = 20; -// export const VISIBILITY_CONFIG = { -// showUnlisted: false, -// showSold: true, -// }; - +/** 정렬 관련 설정 */ export const SORT_CONFIG = { - PUSH_SOLD_OUT_TO_END: false, // 판매완료를 뒤로 보낼지 여부 (테스트 시 false) - PUSH_NON_SALE_TO_END: false, // 미판매를 뒤로 보낼지 여부 (테스트 시 false) + /** 판매 완료된 항목을 리스트의 가장 뒤로 보낼지 여부 */ + PUSH_SOLD_OUT_TO_END: false, + /** 미판매 항목을 리스트의 가장 뒤로 보낼지 여부 */ + PUSH_NON_SALE_TO_END: false, }; +/** 검색 범위 설정 */ +export const SEARCH_CONFIG = { + USE_TITLE: true, // 상품명 포함 + USE_CUSTOM_TAG: true, // 커스텀 태그 포함 + USE_TAGS: true, // 태그 배열 포함 + USE_DESCRIPTION: true, // 요약 설명 포함 + USE_FULL_DESCRIPTION: false // 상세 설명 배열 포함 여부 +}; + +// ========================================================================== +// 2. 상품 상태 및 필터 설정 (Status & Filters) +// ========================================================================== + +/** * 상품 상태별 정책 정의 + * selectable: 체크박스 선택 가능 여부 + * isDefaultActive: 초기 로드 시 필터 활성화 여부 + * isSystemVisible: 필터 목록 및 리스트 노출 여부 + * soldOut: 판매 완료 처리 여부 (이미지 그레이스케일 등) + */ export const STATUS_META = { 미판매: { - selectable: false, // 체크박스 선택 불가 - isDefaultActive: false, // 초기 로드 시 미체크 상태 - isSystemVisible: true, // 아예 리스트/필터에서 제외 (완전 숨김) + selectable: false, + isDefaultActive: false, + isSystemVisible: true, soldOut: false, }, 판매예정: { @@ -33,12 +58,13 @@ export const STATUS_META = { }, 판매완료: { selectable: false, - isDefaultActive: false, // 초기에는 안 보이지만, 사용자가 필터 클릭하면 보임 + isDefaultActive: false, isSystemVisible: true, soldOut: true, }, }; +/** 필터 칩 표시 순서 정의 */ export const STATUS_ORDER = { 판매중: 1, 판매예정: 2, @@ -46,12 +72,13 @@ export const STATUS_ORDER = { 미판매: 4, }; -// STATUS_FILTERS를 수동으로 만들지 않고 META에서 자동으로 생성합니다. +/** 시스템에 표시될 상태 필터 목록 (STATUS_META 기반 자동 생성) */ export const STATUS_FILTERS = Object.keys(STATUS_META) .filter((key) => STATUS_META[key].isSystemVisible) .map((key) => ({ key, label: key })) .sort((a, b) => (STATUS_ORDER[a.key] || 99) - (STATUS_ORDER[b.key] || 99)); +/** 상태별 UI 컬러 (Tailwind CSS 클래스) */ export const STATUS_COLOR = { 판매중: 'bg-primary/10 text-primary border-primary/30', 판매예정: 'bg-amber-400/10 text-amber-600 border-amber-400/30', @@ -59,7 +86,11 @@ export const STATUS_COLOR = { 미판매: 'bg-slate-200/10 text-slate-400 border-slate-300/30', }; -/** 모달 커스텀 태그(customTag) 키워드별 뱃지 스타일 */ +// ========================================================================== +// 3. 태그 및 컨디션 스타일 (Tag & Condition Styles) +// ========================================================================== + +/** 커스텀 태그(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', @@ -71,24 +102,19 @@ export const TAG_STYLES = { 풀옵션: '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'; -export const SEARCH_CONFIG = { - USE_TITLE: true, // 상품명 검색 - USE_CUSTOM_TAG: true, // 커스텀 태그 검색 - USE_TAGS: true, // 태그 배열 검색 - USE_DESCRIPTION: true, // 요약 설명 검색 - USE_FULL_DESCRIPTION: false, // 상세 설명 배열 검색 -}; - -/** 상품 컨디션(specs.condition) 정의 */ +/** * 상품 상태(Condition) 등급 및 라벨 설정 + * specs.condition 값과 매칭됩니다. + */ export const PRODUCT_CONDITIONS = { - BRAND_NEW: { label: 'Brand New (미개봉)', color: 'text-emerald-600', level: 'S' }, - LIKE_NEW: { label: 'Like New (단순개봉)', color: 'text-blue-600', level: 'A+' }, - EXCELLENT: { label: 'Excellent (최상급)', color: 'text-sky-600', level: 'A' }, - GOOD: { label: 'Good (보통/사용감)', color: 'text-slate-600', level: 'B' }, + BRAND_NEW: { label: 'Brand New (미개봉)', color: 'text-emerald-600', level: 'S' }, + LIKE_NEW: { label: 'Like New (단순개봉)', color: 'text-blue-600', level: 'A+' }, + EXCELLENT: { label: 'Excellent (최상급)', color: 'text-sky-600', level: 'A' }, + GOOD: { label: 'Good (보통/사용감)', color: 'text-slate-600', level: 'B' }, INCOMPLETE: { label: 'Incomplete (구성품 누락)', color: 'text-amber-600', level: 'C' }, - DAMAGED: { label: 'Damaged (하자/파손)', color: 'text-orange-600', level: 'D' }, - JUNK: { label: 'Junk (동작불가/부품용)', color: 'text-red-600', level: 'F' }, - OTHER: { label: '기타 (상세설명 참고)', color: 'text-indigo-600', level: '-' }, -}; + DAMAGED: { label: 'Damaged (하자/파손)', color: 'text-orange-600', level: 'D' }, + JUNK: { label: 'Junk (동작불가/부품용)', color: 'text-red-600', level: 'F' }, + OTHER: { label: '기타 (상세설명 참고)', color: 'text-indigo-600', level: '-' }, +}; \ No newline at end of file diff --git a/scripts/filters.js b/scripts/filters.js index 17ff1fb..2474f6b 100644 --- a/scripts/filters.js +++ b/scripts/filters.js @@ -1,25 +1,116 @@ -/** 상태·카테고리·검색 필터 로직 및 UI */ +/** + * filters.js + * 상태(Status), 카테고리(Category), 검색(Search), 태그(Tag) 필터 로직 및 UI 제어 + */ + import { state, productsData } from './state.js'; -import { ITEMS_PER_PAGE, STATUS_META, STATUS_FILTERS, STATUS_ORDER, STATUS_COLOR, SEARCH_CONFIG, SORT_CONFIG } from './config.js'; +import { ITEMS_PER_PAGE, STATUS_META, STATUS_FILTERS, STATUS_ORDER, STATUS_COLOR, SORT_CONFIG } from './config.js'; import { renderProducts } from './productList.js'; -// 1 & 2. 칩 사이즈 통일 및 미판매 활성화 스타일 강화 -function getStatusChipClass(status, isActive) { - // 1. 태그 칩과 동일한 사이즈 (텍스트 크기 포함) - const commonSize = 'min-w-[70px] justify-center px-3 py-1 text-[11px] md:text-xs'; - const base = STATUS_COLOR[status] ?? ''; +// ========================================================================== +// 1. 데이터 필터링 및 검색 로직 (Logic) +// ========================================================================== - if (isActive) { - if (status === '미판매') { - // 2. 미판매 활성화 시 특수 컬러 (인디고) - return `${commonSize} bg-indigo-600 text-white border-indigo-700 shadow-md ring-1 ring-indigo-300 opacity-100`; - } - return `${commonSize} ${base} opacity-100 shadow-sm ring-1 ring-offset-1 ring-slate-200 dark:ring-slate-700`; - } +/** + * 검색어 매칭 여부 확인 (AND 조건) + * @param {Object} product - 상품 객체 + * @param {string} keyword - 검색 키워드 + */ +function checkSearchMatch(product, keyword) { + if (!keyword) return true; - return `${commonSize} bg-slate-50 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 opacity-40 grayscale`; + const searchTerms = keyword.split(/\s+/).filter((t) => t.length > 0); + + const title = product.title || ''; + const desc = product.description || ''; + const tags = Array.isArray(product.tags) ? product.tags.join(' ') : ''; + const fullDesc = Array.isArray(product.fullDescription) ? product.fullDescription.join(' ') : String(product.fullDescription || ''); + + const pool = `${title} ${desc} ${tags} ${fullDesc}`.toLowerCase(); + + return searchTerms.every((term) => pool.includes(term.toLowerCase())); } +/** + * 현재 조건에 맞는 유효한 태그 목록 추출 + * @returns {string[]} 정렬된 태그 배열 + */ +function getAvailableTags() { + const availableTags = new Set(); + + productsData + .filter((p) => { + const sMatch = state.activeStatuses.has(p.status); + const cMatch = state.activeCategories.has('All') || state.activeCategories.has(p.category); + const kMatch = checkSearchMatch(p, state.searchKeyword.toLowerCase()); + return sMatch && cMatch && kMatch; + }) + .forEach((p) => p.tags?.forEach((t) => availableTags.add(t))); + + // 선택된 태그는 결과가 없더라도 목록 유지 + state.activeTags.forEach((t) => availableTags.add(t)); + return Array.from(availableTags).sort(); +} + +/** + * 필터 및 정렬 통합 적용 (핵심 실행 함수) + */ +export function applyFilters() { + const keyword = state.searchKeyword.toLowerCase(); + + // [1] 데이터 필터링 및 정렬 + state.visibleProducts = productsData + .filter((product) => { + const meta = STATUS_META[product.status]; + if (!meta || !meta.isSystemVisible) return false; + + const statusMatch = state.activeStatuses.has(product.status); + const categoryMatch = state.activeCategories.has('All') || state.activeCategories.has(product.category); + const searchMatch = checkSearchMatch(product, keyword); + const tagMatch = state.activeTags.size === 0 || Array.from(state.activeTags).every((tag) => product.tags && product.tags.includes(tag)); + + return statusMatch && categoryMatch && searchMatch && tagMatch; + }) + .sort((a, b) => { + if (!SORT_CONFIG.PUSH_SOLD_OUT_TO_END && !SORT_CONFIG.PUSH_NON_SALE_TO_END) return 0; + + const isASold = STATUS_META[a.status]?.soldOut; + const isBSold = STATUS_META[b.status]?.soldOut; + const isANonSale = a.status === '미판매'; + const isBNonSale = b.status === '미판매'; + + if (SORT_CONFIG.PUSH_SOLD_OUT_TO_END && isASold !== isBSold) return isASold ? 1 : -1; + if (SORT_CONFIG.PUSH_NON_SALE_TO_END && isANonSale !== isBNonSale) return isANonSale ? 1 : -1; + + return (STATUS_ORDER[a.status] ?? 999) - (STATUS_ORDER[b.status] ?? 999); + }); + + // [2] 페이지 위치 안전 조정 + const totalPages = Math.ceil(state.visibleProducts.length / ITEMS_PER_PAGE); + state.currentPage = Math.max(1, Math.min(state.currentPage, totalPages || 1)); + + // [3] UI 업데이트 순차적 호출 + renderTotalCount(state.visibleProducts.length); + renderProducts(state.currentPage); + renderTagChips(); +} + +// ========================================================================== +// 2. UI 렌더링 함수 (Rendering) +// ========================================================================== + +/** + * 필터 결과 총 개수 표시 + * @param {number} count + */ +function renderTotalCount(count) { + const totalCountElement = document.getElementById('total-count'); + if (totalCountElement) totalCountElement.textContent = count.toLocaleString(); +} + +/** + * 상태(Status) 필터 칩 렌더링 + */ export function renderStatusChips() { const container = document.getElementById('status-chips'); if (!container) return; @@ -29,7 +120,6 @@ export function renderStatusChips() { const isActive = state.activeStatuses.has(key); const chip = document.createElement('button'); - // 칩 공통 스타일 적용 chip.className = `status-chip flex items-center rounded-full font-bold transition-all duration-200 border ${getStatusChipClass(key, isActive)}`; chip.textContent = label; @@ -43,278 +133,186 @@ export function renderStatusChips() { }); } -function toggleStatusFilter(status) { - if (state.activeStatuses.has(status)) { - state.activeStatuses.delete(status); - } else { - state.activeStatuses.add(status); +/** + * 상태 칩 스타일 클래스 생성 헬퍼 + */ +function getStatusChipClass(status, isActive) { + const commonSize = 'min-w-[70px] justify-center px-3 py-1 text-[11px] md:text-xs'; + const base = STATUS_COLOR[status] ?? ''; + + if (isActive) { + if (status === '미판매') { + return `${commonSize} bg-indigo-600 text-white border-indigo-700 shadow-md ring-1 ring-indigo-300 opacity-100`; + } + return `${commonSize} ${base} opacity-100 shadow-sm ring-1 ring-offset-1 ring-slate-200 dark:ring-slate-700`; } - if (state.activeStatuses.size === 0) { - STATUS_FILTERS.filter((f) => f.defaultActive).forEach((f) => state.activeStatuses.add(f.key)); - } - applyFilters(); - renderStatusChips(); + return `${commonSize} bg-slate-50 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 opacity-40 grayscale`; } -export function renderTagChips() { - const container = document.getElementById('tag-chips'); +/** + * 카테고리(Category) 필터 칩 렌더링 + */ +export function renderCategoryChips(products = productsData) { + const container = document.getElementById('filter-chips'); if (!container) return; - // 1. 모든 상품에서 유니크한 태그 추출 - const allTags = new Set(); - productsData.forEach((p) => { - if (p.tags) p.tags.forEach((tag) => allTags.add(tag)); - }); - const sortedTags = Array.from(allTags).sort(); + const validCategories = products.filter((p) => STATUS_META[p.status]?.isSystemVisible).map((p) => p.category); - // 2. 리셋 버튼 HTML (맨 앞에 배치) - // 활성화된 태그가 있을 때만 강조되거나, 항상 보이게 설정 가능합니다. - const hasActiveTags = state.activeTags.size > 0; + const categories = ['All', ...new Set(validCategories)]; + + container.innerHTML = categories + .map((cat) => { + const isActive = state.activeCategories.has(cat); + const activeClass = isActive ? 'bg-primary text-white border-primary shadow-sm ring-1 ring-offset-1 ring-slate-200' : 'bg-slate-50 text-slate-400 border-slate-200 opacity-40 hover:opacity-60'; + + return ``; + }) + .join(''); + + container.querySelectorAll('.filter-chip').forEach((chip) => { + chip.onclick = () => toggleCategory(chip.dataset.category); + }); +} + +/** + * 태그(Tag) 필터 칩 렌더링 및 UI 제어 + */ +export function renderTagChips() { + const container = document.getElementById('tag-chips'); + const tagParent = document.getElementById('tag-container'); + const toggleBtn = document.getElementById('toggle-tags'); + const activeCountBadge = document.getElementById('active-tag-count'); + + if (!container) return; + + const sortedTags = getAvailableTags(); + const hasActive = state.activeTags.size > 0; + + // [1] HTML 구조 생성 (리셋 버튼 + 태그 칩) const resetBtnHtml = ` - `; + ${hasActive ? 'bg-red-50 text-red-500 border-red-200 hover:bg-red-100' : 'bg-slate-50 text-slate-400 border-slate-200 opacity-60'}" title="태그 초기화"> + + `; - // 3. 태그 칩들과 합치기 container.innerHTML = resetBtnHtml + sortedTags .map((tag) => { const isActive = state.activeTags.has(tag); - return ` - - `; + return ``; }) .join(''); - // 4. 리셋 버튼 이벤트 바인딩 + // [2] 뱃지 상태 업데이트 + if (activeCountBadge) { + activeCountBadge.textContent = state.activeTags.size; + activeCountBadge.classList.toggle('hidden', state.activeTags.size === 0); + } + + // [3] 더보기 버튼 노출 여부 계산 (브라우저 렌더링 후) + requestAnimationFrame(() => { + const isOverflow = container.scrollHeight > 36; + if (toggleBtn) toggleBtn.classList.toggle('hidden', !isOverflow); + if (!isOverflow && tagParent) { + tagParent.style.maxHeight = '34px'; + tagParent.classList.remove('expanded'); + } + }); + + // [4] 이벤트 바인딩 document.getElementById('tag-reset-btn').onclick = () => { - if (state.activeTags.size === 0) return; state.activeTags.clear(); - renderTagChips(); // UI 갱신 - applyFilters(); // 필터 적용 + applyFilters(); }; - // 5. 개별 태그 칩 이벤트 바인딩 container.querySelectorAll('.tag-chip').forEach((chip) => { chip.onclick = () => { const tag = chip.dataset.tag; - if (state.activeTags.has(tag)) state.activeTags.delete(tag); - else state.activeTags.add(tag); - - renderTagChips(); + state.activeTags.has(tag) ? state.activeTags.delete(tag) : state.activeTags.add(tag); applyFilters(); }; }); } -// [핵심] 필터 적용 함수 -export function applyFilters() { - const keyword = state.searchKeyword.toLowerCase(); - - // 1. 데이터 필터링 및 정렬 - state.visibleProducts = productsData - .filter((product) => { - const meta = STATUS_META[product.status]; - - // [1] 시스템 가시성 체크 - if (!meta || !meta.isSystemVisible) return false; - - // [2] 상태 필터 체크 - const statusMatch = state.activeStatuses.has(product.status); - - // [3] 카테고리 필터 체크 - const categoryMatch = state.activeCategories.has('All') || state.activeCategories.has(product.category); - - // [4] 검색어 매칭 로직 - const searchMatch = - keyword === '' || - (() => { - // 검색어를 공백 기준으로 쪼개서 배열로 만듭니다 (예: "jp r19" -> ["jp", "r19"]) - const searchTerms = keyword.split(/\s+/).filter((term) => term.length > 0); - - const searchPool = []; - if (SEARCH_CONFIG.USE_TITLE) searchPool.push(product.title); - if (SEARCH_CONFIG.USE_CUSTOM_TAG && product.customTag) searchPool.push(product.customTag); - if (SEARCH_CONFIG.USE_DESCRIPTION && product.description) searchPool.push(product.description); - - // 태그 배열과 상세 설명 배열을 문자열 풀에 합칩니다. - if (SEARCH_CONFIG.USE_TAGS && product.tags) searchPool.push(...product.tags); - if (SEARCH_CONFIG.USE_FULL_DESCRIPTION && product.fullDescription) searchPool.push(...product.fullDescription); - - // 모든 텍스트를 하나의 검색용 문자열로 결합 - const combinedText = searchPool.map((text) => String(text || '').toLowerCase()).join(' '); - - // [핵심] 사용자가 입력한 모든 단어(searchTerms)가 결합된 텍스트에 포함되어 있는지 확인 (AND 조건) - return searchTerms.every((term) => combinedText.includes(term)); - })(); - - // [5] 태그 필터 체크 - const tagMatch = state.activeTags.size === 0 || Array.from(state.activeTags).every((tag) => product.tags && product.tags.includes(tag)); - - return statusMatch && categoryMatch && searchMatch && tagMatch; - }) - .sort((a, b) => { - // 0. 스위치가 모두 꺼져있다면 정렬하지 않고 원본 순서 유지 - if (!SORT_CONFIG.PUSH_SOLD_OUT_TO_END && !SORT_CONFIG.PUSH_NON_SALE_TO_END) { - return 0; - } - - const isASold = STATUS_META[a.status]?.soldOut; - const isBSold = STATUS_META[b.status]?.soldOut; - const isANonSale = a.status === '미판매'; - const isBNonSale = b.status === '미판매'; - - // 1. 판매완료 정렬 제어 - if (SORT_CONFIG.PUSH_SOLD_OUT_TO_END) { - if (isASold !== isBSold) return isASold ? 1 : -1; - } - - // 2. 미판매 정렬 제어 - if (SORT_CONFIG.PUSH_NON_SALE_TO_END) { - if (isANonSale !== isBNonSale) return isANonSale ? 1 : -1; - } - - // 3. 만약 위 스위치들 중 하나라도 켜져 있다면, 나머지는 STATUS_ORDER를 따름 - const aOrder = STATUS_ORDER[a.status] ?? 999; - const bOrder = STATUS_ORDER[b.status] ?? 999; - return aOrder - bOrder; - }); - - // 2. 페이지 위치 안전 조정 로직 - const totalPages = Math.ceil(state.visibleProducts.length / ITEMS_PER_PAGE); - - if (state.currentPage > totalPages) { - state.currentPage = Math.max(1, totalPages); - } else if (state.currentPage < 1) { - state.currentPage = 1; - } - - // 3. UI 업데이트 - // renderTotalCount 함수가 정의되어 있다면 실행 - if (typeof renderTotalCount === 'function') { - renderTotalCount(state.visibleProducts.length); - } - - renderProducts(state.currentPage); -} - -/** 총 개수를 화면에 표시하는 보조 함수 */ -function renderTotalCount(count) { - const totalCountElement = document.getElementById('total-count'); - if (totalCountElement) { - totalCountElement.textContent = count.toLocaleString(); // 3자리마다 콤마 - } -} - -export function getCategories(products) { - return ['All', ...new Set(products.map((p) => p.category))]; -} - -export function renderCategoryChips(products) { - const container = document.getElementById('filter-chips'); // 혹은 HTML 구조에 맞춰 'category-chips' - if (!container) return; - - // 1. 가시성 있는 상품의 카테고리만 추출 - const validCategories = products - .filter((p) => { - const meta = STATUS_META[p.status]; - return meta && meta.isSystemVisible; - }) - .map((p) => p.category); - - const categories = ['All', ...new Set(validCategories)]; - - container.innerHTML = ''; - - categories.forEach((cat) => { - const isActive = state.activeCategories.has(cat); - const chip = document.createElement('button'); - - // [수정] 태그 칩 사이즈(px-3 py-1) 및 폰트 크기(text-[11px]) 통일 - // min-w-[65px]를 주면 'All' 같은 짧은 글자도 정갈하게 보입니다. - chip.className = `filter-chip flex items-center justify-center min-w-[65px] px-3 py-1 rounded-full text-[11px] md:text-xs font-bold transition-all duration-200 border - ${isActive ? 'bg-primary text-white border-primary shadow-sm ring-1 ring-offset-1 ring-slate-200 dark:ring-slate-700 opacity-100' : 'bg-slate-50 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 opacity-40 grayscale hover:opacity-60'}`; - - chip.textContent = cat; - chip.dataset.category = cat; - - chip.onclick = () => { - toggleCategory(cat); - }; - - container.appendChild(chip); - }); -} +// ========================================================================== +// 3. 사용자 인터랙션 제어 (Interaction) +// ========================================================================== +/** + * 카테고리 선택 토글 + * @param {string} category + */ export function toggleCategory(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'); - } + state.activeCategories.has(category) ? state.activeCategories.delete(category) : state.activeCategories.add(category); + if (state.activeCategories.size === 0) state.activeCategories.add('All'); } - renderCategoryChips(productsData); // 디자인(활성화 상태) 즉시 반영 + 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(); - }); - }); -} - -// 로고 클릭 시 초기화 로직 +/** + * 로고(타이틀) 클릭 시 모든 필터 초기화 + */ document.getElementById('logo-title')?.addEventListener('click', () => { - // 1. 검색어 초기화 state.searchKeyword = ''; - const searchInput = document.getElementById('search-input'); // 검색창 ID가 있다면 + const searchInput = document.getElementById('search-input'); if (searchInput) searchInput.value = ''; - // 2. 카테고리 초기화 (All 선택) state.activeCategories.clear(); state.activeCategories.add('All'); - // 3. 상태 필터 초기화 (기본 활성화 상태로) state.activeStatuses.clear(); STATUS_FILTERS.filter((f) => f.defaultActive).forEach((f) => state.activeStatuses.add(f.key)); - // 4. 필터 적용 및 UI 갱신 + state.activeTags.clear(); + applyFilters(); renderStatusChips(); renderCategoryChips(productsData); - // 5. 페이지 최상단으로 스크롤 (선택 사항) window.scrollTo({ top: 0, behavior: 'smooth' }); }); + +/** + * 태그 컨테이너 확장/축소 토글 설정 (최초 1회 실행) + */ +export function setupTagToggle() { + const toggleBtn = document.getElementById('toggle-tags'); + const tagContainer = document.getElementById('tag-container'); + const tagChips = document.getElementById('tag-chips'); + + if (!toggleBtn || !tagContainer || !tagChips) return; + + toggleBtn.onclick = () => { + const isExpanded = tagContainer.classList.contains('expanded'); + const svgIcon = toggleBtn.querySelector('svg'); + + if (isExpanded) { + tagContainer.style.maxHeight = tagContainer.scrollHeight + 'px'; + requestAnimationFrame(() => { + tagContainer.style.maxHeight = '2rem'; + tagContainer.classList.remove('expanded'); + if (svgIcon) svgIcon.style.transform = 'rotate(0deg)'; + }); + } else { + tagContainer.style.maxHeight = tagChips.scrollHeight + 'px'; + tagContainer.classList.add('expanded'); + if (svgIcon) svgIcon.style.transform = 'rotate(180deg)'; + + setTimeout(() => { + if (tagContainer.classList.contains('expanded')) { + tagContainer.style.maxHeight = 'none'; + } + }, 300); + } + }; +} diff --git a/scripts/main.js b/scripts/main.js index afcacba..15f75d1 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -1,36 +1,42 @@ -/** 진입점: 이벤트 바인딩·초기 렌더·URL 모달 처리 */ +/** + * main.js + * 앱 진입점: 전역 상태 초기화, 공통 이벤트 바인딩, 뷰 제어 및 유틸리티 기능 + */ + import { state, productsData, saveSelection } from './state.js'; -import { applyFilters, renderStatusChips, renderCategoryChips, bindCategoryFilter, renderTagChips } from './filters.js'; +import { applyFilters, renderStatusChips, renderCategoryChips, renderTagChips, setupTagToggle } from './filters.js'; import { ITEMS_PER_PAGE, STATUS_META } from './config.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); +// ========================================================================== +// 1. 전역 설정 및 윈도우 객체 등록 (Global Setup) +// ========================================================================== -let lastThumbnailIndex = -1; - -// HTML onclick에서 사용하기 위한 전역 등록 +// HTML 내 inline onclick 이벤트를 위한 전역 등록 window.openModal = openModal; window.closeModal = closeModal; window.changePage = changePage; window.scrollToImage = scrollToImage; +let lastThumbnailIndex = -1; +let currentHoverIndex = -1; let fadeTimers = {}; +let touchStartX = 0; +let isDragging = false; -// 뷰 전환 이벤트 -// 뷰 전환 이벤트 바인딩 -document.getElementById('view-grid').onclick = () => { - state.viewMode = 'grid'; - updateViewButtons(); - renderProducts(state.currentPage); -}; -document.getElementById('view-table').onclick = () => { - state.viewMode = 'table'; - updateViewButtons(); - renderProducts(state.currentPage); -}; +// 로그용 유니크 ID 생성 +const sessionId = Math.random().toString(36).substring(2, 10); +console.log(`%c[SESSION]: ${sessionId}`, 'color: #137fec; font-weight: bold; border: 1px solid #137fec; padding: 2px 5px; border-radius: 4px;'); +// ========================================================================== +// 2. 뷰(모드) 제어 로직 (View Mode) +// ========================================================================== + +/** + * 뷰 전환 버튼 상태 업데이트 (Grid/Table) + */ function updateViewButtons() { const isGrid = state.viewMode === 'grid'; const gridBtn = document.getElementById('view-grid'); @@ -54,15 +60,25 @@ function updateViewButtons() { } } -// [전역 함수] 체크박스 토글 및 합계 업데이트 -window.toggleSelectItem = (id) => { - if (state.selectedIds.has(id)) state.selectedIds.delete(id); - else state.selectedIds.add(id); - - updateSummary(); +document.getElementById('view-grid').onclick = () => { + state.viewMode = 'grid'; + updateViewButtons(); + renderProducts(state.currentPage); }; -/** 선택 요약 바 업데이트 (모바일 레이아웃 대응) */ +document.getElementById('view-table').onclick = () => { + state.viewMode = 'table'; + updateViewButtons(); + renderProducts(state.currentPage); +}; + +// ========================================================================== +// 3. 선택 및 요약 로직 (Selection & Summary) +// ========================================================================== + +/** + * 선택 요약 바 업데이트 (개수 및 총 가격) + */ export function updateSummary() { const summaryBar = document.getElementById('selection-summary'); const countEl = document.getElementById('selected-count'); @@ -70,7 +86,7 @@ export function updateSummary() { if (state.selectedIds.size > 0) { summaryBar.classList.remove('hidden'); - summaryBar.classList.add('flex'); // flex-wrap은 HTML에 이미 적용됨 + summaryBar.classList.add('flex'); countEl.textContent = state.selectedIds.size; const total = Array.from(state.selectedIds).reduce((sum, id) => { @@ -84,125 +100,23 @@ export function updateSummary() { } } -// 검색 입력 -const searchInput = document.getElementById('search-input'); -if (searchInput) { - searchInput.addEventListener('input', (e) => { - state.searchKeyword = e.target.value.trim().toLowerCase(); - applyFilters(); - }); -} +/** + * 개별 아이템 선택 토글 + */ +window.toggleSelectItem = (id) => { + const product = productsData.find((p) => p.id === id); + if (!product || STATUS_META[product.status]?.selectable === false) return; -// Escape로 모달 닫기 -document.addEventListener('keydown', (e) => { - if (e.key !== 'Escape') return; - const modal = document.getElementById('product-modal'); - if (!modal || modal.classList.contains('hidden')) return; - closeModal(); -}); + if (state.selectedIds.has(id)) state.selectedIds.delete(id); + else state.activeStatuses.has(product.status) && state.selectedIds.add(id); -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(); -}); - -document.addEventListener('DOMContentLoaded', () => { - console.log('Total products loaded:', productsData.length); - - // 1. UI 컴포넌트 렌더링 - renderCategoryChips(productsData); - bindCategoryFilter(productsData); - renderStatusChips(); - renderTagChips(); - - // 2. 초기 데이터 계산 및 첫 페이지 렌더링 (순서 중요) - applyFilters(); - renderProducts(state.currentPage); - updateViewButtons(); + saveSelection(); updateSummary(); +}; - // 테마 설정 (기존 로직 유지) - 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. 태그 토글 로직 (SVG 회전 포함) - const toggleBtn = document.getElementById('toggle-tags'); - const tagContainer = document.getElementById('tag-container'); - if (toggleBtn && tagContainer) { - toggleBtn.onclick = () => { - const isExpanded = tagContainer.classList.contains('expanded'); - const svgIcon = toggleBtn.querySelector('svg'); - if (isExpanded) { - tagContainer.style.maxHeight = tagContainer.scrollHeight + 'px'; - requestAnimationFrame(() => { - tagContainer.style.maxHeight = '34px'; - tagContainer.classList.remove('expanded'); - if (svgIcon) svgIcon.style.transform = 'rotate(0deg)'; - }); - } else { - tagContainer.style.maxHeight = tagContainer.scrollHeight + 'px'; - tagContainer.classList.add('expanded'); - if (svgIcon) svgIcon.style.transform = 'rotate(180deg)'; - setTimeout(() => { - if (tagContainer.classList.contains('expanded')) tagContainer.style.maxHeight = 'none'; - }, 300); - } - }; - } -}); - -// 데이터용 새 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;'); - -/** 현재 페이지의 '선택 가능한' 항목들만 선택/해제 */ +/** + * 현재 페이지 전체 선택/해제 + */ window.toggleSelectAll = (isChecked) => { const startIndex = (state.currentPage - 1) * ITEMS_PER_PAGE; const currentPageProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE); @@ -210,126 +124,87 @@ window.toggleSelectAll = (isChecked) => { currentPageProducts.forEach((p) => { const isSelectable = STATUS_META[p.status]?.selectable !== false; if (isSelectable) { - if (isChecked) state.selectedIds.add(p.id); - else state.selectedIds.delete(p.id); + isChecked ? state.selectedIds.add(p.id) : state.selectedIds.delete(p.id); } }); - saveSelection(); // 변경사항 저장 + saveSelection(); updateSummary(); renderProducts(state.currentPage); }; -/** 선택 리셋 */ -// [수정] 기존 resetSelection을 모달 오픈으로 변경 +/** + * 선택 초기화 모달 및 실행 + */ window.resetSelection = () => { const modal = document.getElementById('selection-reset-modal'); - modal.classList.remove('hidden'); - modal.classList.add('flex'); + modal.classList.replace('hidden', 'flex'); }; -// [추가] 모달 닫기 window.closeSelectionResetModal = () => { const modal = document.getElementById('selection-reset-modal'); - modal.classList.add('hidden'); - modal.classList.remove('flex'); + modal.classList.replace('flex', 'hidden'); }; -// [추가] 실제 초기화 실행 (기존 로직 그대로) window.confirmSelectionReset = () => { state.selectedIds.clear(); - saveSelection(); // 스토리지 동기화 + saveSelection(); updateSummary(); renderProducts(state.currentPage); window.closeSelectionResetModal(); }; -/** 선택 토글 시 스토리지 저장 추가 */ -window.toggleSelectItem = (id) => { - const product = productsData.find((p) => p.id === id); - if (!product || STATUS_META[product.status]?.selectable === false) return; +// ========================================================================== +// 4. 테마 및 검색 이벤트 (Theme & Search) +// ========================================================================== - if (state.selectedIds.has(id)) state.selectedIds.delete(id); - else state.selectedIds.add(id); +/** + * 다크/라이트 테마 초기화 및 버튼 바인딩 + */ +export function initTheme() { + const themeToggleBtn = document.getElementById('theme-toggle'); + if (!themeToggleBtn) return; - saveSelection(); // 변경사항 저장 - updateSummary(); -}; + const isDark = localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches); -/** 선택된 항목들을 CSV 파일로 내보내기 */ -window.exportToExcel = () => { - if (state.selectedIds.size === 0) { - alert('내보낼 상품을 선택해 주세요.'); - return; - } + document.documentElement.classList.toggle('dark', isDark); + updateThemeIcons(); - // 1. 선택된 데이터 추출 및 계산 - let totalCount = 0; - let totalPrice = 0; + themeToggleBtn.onclick = () => { + const willBeDark = !document.documentElement.classList.contains('dark'); + document.documentElement.classList.toggle('dark', willBeDark); + localStorage.setItem('color-theme', willBeDark ? 'dark' : 'light'); + updateThemeIcons(); + }; +} - const rows = Array.from(state.selectedIds) - .map((id) => { - const p = productsData.find((item) => item.id === id); - if (p) { - totalCount += 1; - totalPrice += p.price; - return [p.id, `"${p.title.replace(/"/g, '""')}"`, p.category, p.price, p.status, `"${p.description.replace(/"/g, '""')}"`]; - } - return null; - }) - .filter((row) => row !== null); +function updateThemeIcons() { + const darkIcon = document.getElementById('theme-toggle-dark-icon'); + const lightIcon = document.getElementById('theme-toggle-light-icon'); + if (!darkIcon || !lightIcon) return; - // 2. 헤더 및 푸터(합계) 설정 - const headers = ['상품 ID', '상품명', '카테고리', '가격', '상태', '상세설명']; + const isDark = document.documentElement.classList.contains('dark'); + lightIcon.classList.toggle('hidden', !isDark); + darkIcon.classList.toggle('hidden', isDark); +} - // 영수증 느낌을 위한 하단 합계 줄 - const footerEmpty = ['', '', '', '', '', '']; // 빈 줄 - const footerTotal = ['TOTAL', `"총 ${totalCount}건의 항목"`, '', totalPrice, '', `"발행일: ${new Date().toLocaleString()}"`]; +// 검색어 입력 실시간 반영 +document.getElementById('search-input')?.addEventListener('input', (e) => { + state.searchKeyword = e.target.value.trim().toLowerCase(); + applyFilters(); +}); - // 3. CSV 포맷 생성 (한글 깨짐 방지 BOM 추가) - const csvContent = - '\uFEFF' + - [ - headers.join(','), - ...rows.map((row) => row.join(',')), - footerEmpty.join(','), // 간격 조절용 빈 줄 - footerTotal.join(','), // 합계 라인 - ].join('\n'); +// ESC 키로 모달 닫기 +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeModal(); +}); - // 4. 다운로드 실행 - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); +// ========================================================================== +// 5. 썸네일 애니메이션 및 인터랙션 (Thumbnail Logic) +// ========================================================================== - const timestamp = new Date().toISOString().split('T')[0]; - link.setAttribute('href', url); - link.setAttribute('download', `inventory_receipt_${timestamp}.csv`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -}; - -let currentHoverIndex = -1; - -window.handleThumbnailHover = (e, productId) => { - if (window.isDragging) return; - - const product = productsData.find((p) => p.id === productId); - if (!product || !product.images || product.images.length <= 1) return; - - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - let index = Math.floor((x / rect.width) * product.images.length); - index = Math.max(0, Math.min(product.images.length - 1, index)); - - if (currentHoverIndex !== index) { - currentHoverIndex = index; - updateThumbnailWithFade(productId, product.images[index], index); - } -}; - -/** * 썸네일을 즉시 업데이트하는 함수 - * 페이드 애니메이션을 제거하여 반응성을 높이고 깜빡임을 방지합니다. +/** + * 썸네일 이미지/인디케이터 즉시 업데이트 (마우스 호버용) */ function updateThumbnailWithFade(productId, newImageUrl, index) { const mainThumb = document.getElementById(`thumb-${productId}`); @@ -338,32 +213,22 @@ function updateThumbnailWithFade(productId, newImageUrl, index) { if (!mainThumb) return; - // 1. 기존 페이드 타이머가 있다면 즉시 제거 (충돌 방지) if (fadeTimers[productId]) { clearTimeout(fadeTimers[productId]); delete fadeTimers[productId]; } - // 2. 페이드 레이어(뒷배경)는 즉시 숨기고 메인 이미지만 즉시 교체 - // transition 없이 즉시 교체되도록 인라인 스타일로 제어합니다. if (fadeThumb) { fadeThumb.style.transition = 'none'; fadeThumb.style.opacity = '0'; } - - mainThumb.style.backgroundImage = `url("${newImageUrl}")`; - // 3. 인디케이터 UI 업데이트 + mainThumb.style.backgroundImage = `url("${newImageUrl}")`; if (indicator) updateIndicatorUI(indicator, index); } -window.handleThumbnailLeave = (productId) => { - currentHoverIndex = -1; // 인덱스 초기화 - - resetThumbnail(productId); -}; - -/** * 마우스가 나갔을 때 썸네일을 첫 번째 이미지로 복구하는 함수 +/** + * 썸네일 상태 복구 */ function resetThumbnail(productId) { if (fadeTimers[productId]) { @@ -380,21 +245,17 @@ function resetThumbnail(productId) { if (mainThumb) { const firstImgUrl = `url("${product.images[0]}")`; - - // 즉시 첫 번째 이미지로 복구 mainThumb.style.backgroundImage = firstImgUrl; - + if (fadeThumb) { fadeThumb.style.transition = 'none'; fadeThumb.style.opacity = '0'; fadeThumb.style.backgroundImage = firstImgUrl; } } - if (indicator) updateIndicatorUI(indicator, 0); } -// 중복 코드를 방지하기 위한 UI 업데이트 헬퍼 function updateIndicatorUI(indicator, activeIndex) { Array.from(indicator.children).forEach((dot, i) => { const isActive = i === activeIndex; @@ -404,10 +265,29 @@ function updateIndicatorUI(indicator, activeIndex) { }); } -// 터치 상태 관리를 위한 변수 -let touchStartX = 0; -let isDragging = false; +// 데스크탑 호버 이벤트 +window.handleThumbnailHover = (e, productId) => { + if (window.isDragging) return; + const product = productsData.find((p) => p.id === productId); + if (!product || !product.images || product.images.length <= 1) return; + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + let index = Math.floor((x / rect.width) * product.images.length); + index = Math.max(0, Math.min(product.images.length - 1, index)); + + if (currentHoverIndex !== index) { + currentHoverIndex = index; + updateThumbnailWithFade(productId, product.images[index], index); + } +}; + +window.handleThumbnailLeave = (productId) => { + currentHoverIndex = -1; + resetThumbnail(productId); +}; + +// 모바일 터치 스와이프 이벤트 window.handleTouchStart = (e) => { touchStartX = e.touches[0].clientX; isDragging = false; @@ -417,10 +297,8 @@ window.handleTouchMove = (e, productId) => { const product = productsData.find((p) => p.id === productId); if (!product || !product.images || product.images.length <= 1) return; - const cardElement = e.currentTarget; - const cardWidth = cardElement.offsetWidth; - const touchCurrentX = e.touches[0].clientX; - const diffX = touchStartX - touchCurrentX; + const cardWidth = e.currentTarget.offsetWidth; + const diffX = touchStartX - e.touches[0].clientX; if (Math.abs(diffX) > 10) { isDragging = true; @@ -428,48 +306,87 @@ window.handleTouchMove = (e, productId) => { } if (isDragging) { - const product = productsData.find((p) => p.id === productId); const step = cardWidth / product.images.length; let index = Math.floor(Math.abs(diffX) / step); index = Math.max(0, Math.min(product.images.length - 1, index)); - // [수정 핵심] 인덱스가 이전과 같으면 함수를 종료하여 불필요한 리렌더링 방지 if (lastThumbnailIndex === index) return; lastThumbnailIndex = index; const mainThumb = document.getElementById(`thumb-${productId}`); const fadeThumb = document.getElementById(`thumb-fade-${productId}`); - // [수정 핵심] 인덱스가 실제로 변했을 때만 스타일을 바꿉니다. - if (mainThumb && lastThumbnailIndex !== index) { - lastThumbnailIndex = index; // 새 인덱스 저장 - + if (mainThumb) { mainThumb.style.backgroundImage = `url("${product.images[index]}")`; if (fadeThumb) fadeThumb.style.opacity = '0'; - updateIndicator(productId, index); + updateIndicatorUI(document.getElementById(`indicator-${productId}`), index); } } }; window.handleTouchEnd = (e, productId) => { - if (e.cancelable) e.preventDefault(); - if (!isDragging) { window.openModal(productId); } else { - resetThumbnail(productId); // 드래그 종료 시 확실한 리셋 + resetThumbnail(productId); } - lastThumbnailIndex = -1; // 초기화 + lastThumbnailIndex = -1; isDragging = false; }; -// 인디케이터 업데이트 헬퍼 함수 -function updateIndicator(productId, index) { - const indicator = document.getElementById(`indicator-${productId}`); - if (indicator) { - Array.from(indicator.children).forEach((dot, i) => { - dot.style.backgroundColor = i === index ? 'white' : 'rgba(255, 255, 255, 0.4)'; - dot.style.opacity = i === index ? '1' : '0.7'; - }); +// ========================================================================== +// 6. 데이터 내보내기 (Export) +// ========================================================================== + +/** + * 선택된 항목 CSV 다운로드 + */ +window.exportToExcel = () => { + if (state.selectedIds.size === 0) { + alert('내보낼 상품을 선택해 주세요.'); + return; } -} + + let totalCount = 0; + let totalPrice = 0; + + const rows = Array.from(state.selectedIds) + .map((id) => { + const p = productsData.find((item) => item.id === id); + if (!p) return null; + totalCount += 1; + totalPrice += p.price; + return [p.id, `"${p.title.replace(/"/g, '""')}"`, p.category, p.price, p.status, `"${p.description.replace(/"/g, '""')}"`]; + }) + .filter(Boolean); + + const headers = ['상품 ID', '상품명', '카테고리', '가격', '상태', '상세설명']; + const footerTotal = ['TOTAL', `"총 ${totalCount}건"`, '', totalPrice, '', `"발행: ${new Date().toLocaleString()}"`]; + + const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((row) => row.join(',')), ['', '', '', '', '', ''].join(','), footerTotal.join(',')].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `inventory_receipt_${new Date().toISOString().split('T')[0]}.csv`; + link.click(); +}; + +// ========================================================================== +// 7. 초기화 (App Initialization) +// ========================================================================== + +document.addEventListener('DOMContentLoaded', () => { + console.log('App initialized. Products:', productsData.length); + + initTheme(); + renderCategoryChips(productsData); + renderStatusChips(); + renderTagChips(); + setupTagToggle(); + + applyFilters(); + updateViewButtons(); + updateSummary(); +}); diff --git a/scripts/modal.js b/scripts/modal.js index 886c36a..22c8fba 100644 --- a/scripts/modal.js +++ b/scripts/modal.js @@ -1,72 +1,127 @@ -/** 상품 상세 모달 (열기/닫기·콘텐츠 채우기·링크 복사) */ +/** + * modal.js + * 상품 상세 모달의 제어(열기/닫기), 데이터 렌더링 및 캐러셀 초기화 + */ + import { productsData } from './state.js'; import { initBetterCarousel } from './carousel.js'; import { TAG_STYLES, TAG_DEFAULT_STYLE, PRODUCT_CONDITIONS } from './config.js'; +// ========================================================================== +// 1. 모달 메인 제어 (Open / Close) +// ========================================================================== + +/** + * 특정 상품의 상세 모달을 오픈 + * @param {string} id - 상품 고유 ID + */ export function openModal(id) { const product = productsData.find((p) => p.id === id); if (!product) return; const modal = document.getElementById('product-modal'); + modal.classList.replace('hidden', 'flex'); - modal.classList.remove('hidden'); - modal.classList.add('flex'); + // [1] UI 콘텐츠 채우기 + renderModalImages(product); + renderModalInfo(product); + setupCopyLink(product); + // [2] 히스토리 및 브라우저 상태 제어 + window.history.pushState({ modalOpen: true }, '', ''); + document.body.classList.add('modal-open'); + + // [3] 캐러셀 초기화 + const container = document.getElementById('modal-main-carousel-container'); const images = product.images; + container.style.scrollBehavior = 'auto'; + container.scrollLeft = container.clientWidth; // 루프 캐러셀을 위한 초기 위치 설정 + initBetterCarousel(container, images.length); +} + +/** 모달 닫기 및 상태 초기화 */ +export function closeModal() { + const modal = document.getElementById('product-modal'); + + // 스크롤 위치 초기화 + const carouselContainer = document.getElementById('modal-main-carousel-container'); + if (carouselContainer) carouselContainer.scrollLeft = 0; + + const contentScroll = document.getElementById('modal-content-scroll'); + if (contentScroll) contentScroll.scrollTo(0, 0); + + // UI 닫기 + modal.classList.add('hidden'); + document.body.classList.remove('modal-open'); + + // 히스토리 정리 + if (window.history.state && window.history.state.modalOpen) { + window.history.back(); + } + const cleanUrl = window.location.origin + window.location.pathname; + window.history.replaceState(null, '', cleanUrl); +} + +// ========================================================================== +// 2. 상세 데이터 렌더링 (Data Rendering) +// ========================================================================== + +/** 모달 내 이미지 영역(메인, 썸네일, 인디케이터) 생성 */ +function renderModalImages(product) { + const images = product.images; const loopImages = [images[images.length - 1], ...images, images[0]]; - const mainImagesHtml = loopImages - .map( - (img) => ` -
-
- -
+ + // 메인 캐러셀 HTML + const mainImagesHtml = loopImages.map(img => ` +
+
+
- `, - ) - .join(''); +
+ `).join(''); - const thumbnailsHtml = product.images - .map( - (img, idx) => ` - - `, - ) - .join(''); + // 하단 썸네일 HTML + const thumbnailsHtml = images.map((img, idx) => ` + + `).join(''); - const dotsHtml = product.images - .map( - (_, idx) => ` - - `, - ) - .join(''); + // 인디케이터 도트 HTML + const dotsHtml = images.map((_, idx) => ` + + `).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; +} +/** 상품 텍스트 정보 및 가격/상태 렌더링 */ +function renderModalInfo(product) { + document.getElementById('modal-title').textContent = product.title; + + // 카테고리 const modalCategory = document.getElementById('modal-category'); if (modalCategory) modalCategory.textContent = product.category; + // 상품 상태(Status) 뱃지 스타일 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.textContent = product.status; 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) { @@ -77,81 +132,13 @@ export function openModal(id) { 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'); - } - } + // 상세 스펙 (구매일자, 제품상태) + renderSpecs(product); - // 제품 상태(specs.condition): 값이 있을 때만 행 노출 - const conditionKey = product.specs?.condition; - const isVerified = product.specs?.isVerified; + // 가격 표시 로직 + renderPrice(product); - // PRODUCT_CONDITIONS에서 라벨을 가져오되, 없으면 원본 키 표시 - const conditionData = PRODUCT_CONDITIONS[conditionKey]; - // 데이터가 객체라면 .label을 쓰고, 아니면 원본 키를 씁니다. - const conditionLabel = conditionData?.label || conditionKey || ''; - - 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 (conditionKey && String(conditionKey).trim() !== '') { - if (conditionValueEl) conditionValueEl.textContent = conditionLabel; - 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 priceValueEl = document.getElementById('modal-price'); - const priceRowEl = document.getElementById('modal-price-row'); - const priceRowWrap = priceRowEl?.parentElement; - - if (priceValueEl && priceRowWrap) { - // [중요] 새로운 상품을 열 때마다 일단 hidden을 제거하여 초기화합니다. - priceRowWrap.classList.remove('hidden'); - - // 1. 최우선 순위: 미판매 상태 체크 - if (product.status === '미판매') { - priceValueEl.textContent = 'NOT FOR SALE'; - priceValueEl.classList.add('text-gray-500'); - } - // 2. 가격 데이터가 아예 없는 경우 (null, undefined, 빈 문자열) - // 숫자 0은 가격으로 인정하고 싶다면 (product.price === null || product.price === undefined)로 씁니다. - else if (product.price === null || product.price === undefined || product.price === '') { - priceRowWrap.classList.add('hidden'); - } - // 3. 가격이 존재하는 경우 (0원을 포함하여 값이 있는 경우) - else { - const currency = product.currency || '₩'; - priceValueEl.textContent = `${currency}${Number(product.price).toLocaleString()}`; - priceValueEl.classList.remove('text-gray-500'); - } - } - - // (선택 사항) 미판매 상품일 때 상세 설명 부분에 대체 텍스트를 넣거나 숨기고 싶다면: + // 상세 설명 const modalDesc = document.getElementById('modal-desc'); if (modalDesc) { if (product.status === '미판매') { @@ -160,72 +147,83 @@ export function openModal(id) { modalDesc.innerHTML = Array.isArray(product.fullDescription) ? product.fullDescription.join('
') : product.fullDescription || ''; } } +} +/** 상품 스펙 행(Row) 노출 제어 */ +function renderSpecs(product) { + // 구매일자 + const modalDateRow = document.getElementById('modal-date-row'); + const pDate = product.specs?.purchaseDate; + if (pDate && String(pDate).trim() !== '' && String(pDate) !== 'null') { + document.getElementById('modal-date').textContent = pDate; + modalDateRow?.classList.replace('hidden', 'flex'); + } else { + modalDateRow?.classList.replace('flex', 'hidden'); + } + + // 제품 상태(Condition) + const conditionKey = product.specs?.condition; + const conditionRowWrap = document.getElementById('modal-condition-row')?.parentElement; + if (conditionKey) { + const conditionLabel = PRODUCT_CONDITIONS[conditionKey]?.label || conditionKey; + document.getElementById('modal-condition').textContent = conditionLabel; + conditionRowWrap?.classList.replace('hidden', 'flex'); + + const verifiedIcon = document.getElementById('modal-verified-icon'); + if (verifiedIcon) verifiedIcon.classList.toggle('hidden', !product.specs?.isVerified); + } else { + conditionRowWrap?.classList.replace('flex', 'hidden'); + } +} + +/** 가격 정보 렌더링 정책 적용 */ +function renderPrice(product) { + const priceValueEl = document.getElementById('modal-price'); + const priceRowWrap = document.getElementById('modal-price-row')?.parentElement; + if (!priceValueEl || !priceRowWrap) return; + + priceRowWrap.classList.remove('hidden'); + + if (product.status === '미판매') { + priceValueEl.textContent = 'NOT FOR SALE'; + priceValueEl.classList.add('text-gray-500'); + } else if (product.price === null || product.price === undefined || product.price === '') { + priceRowWrap.classList.add('hidden'); + } else { + const currency = product.currency || '₩'; + priceValueEl.textContent = `${currency}${Number(product.price).toLocaleString()}`; + priceValueEl.classList.remove('text-gray-500'); + } +} + +// ========================================================================== +// 3. 유틸리티 및 이벤트 리스너 (Utils) +// ========================================================================== + +/** 상품 공유 링크 복사 로직 설정 */ +function setupCopyLink(product) { 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); - }); - }; - } + if (!copyBtn) return; - window.history.pushState({ modalOpen: true }, '', ''); - - modal.classList.remove('hidden'); - // document.body.style.overflow = 'hidden'; - document.body.classList.add('modal-open'); - - const container = document.getElementById('modal-main-carousel-container'); - container.style.scrollBehavior = 'auto'; - container.scrollLeft = container.clientWidth; - - initBetterCarousel(container, images.length); + 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); + }); + }; } -export function closeModal() { +/** 뒤로가기(브라우저/제스처) 시 모달 닫기 처리 */ +window.addEventListener('popstate', () => { const modal = document.getElementById('product-modal'); - - // 1. 이미지 캐러셀 영역 초기화 - const carouselContainer = document.getElementById('modal-main-carousel-container'); - if (carouselContainer) { - carouselContainer.scrollLeft = 0; - } - - // 2. 우측 상세 정보 스크롤 영역 초기화 (추가된 부분) - const contentScroll = document.getElementById('modal-content-scroll'); - if (contentScroll) { - contentScroll.scrollTo(0, 0); // 스크롤을 맨 위로! - } - - // 모달 닫기 로직 - modal.classList.add('hidden'); - document.body.classList.remove('modal-open'); - - // 히스토리 및 URL 정리 - if (window.history.state && window.history.state.modalOpen) { - window.history.back(); - } - const cleanUrl = window.location.origin + window.location.pathname; - window.history.replaceState(null, '', cleanUrl); -} - -// --- 뒤로가기 감지 이벤트 리스너 --- -// 사용자가 브라우저 뒤로가기 버튼(또는 모바일 뒤로가기 제스처)을 누를 때 실행됩니다. -window.addEventListener('popstate', (event) => { - const modal = document.getElementById('product-modal'); - // 모달이 열려있는 상태에서 뒤로가기가 발생했다면 모달만 닫음 if (!modal.classList.contains('hidden')) { - // 이때 closeModal()을 호출하되, 이미 뒤로 이동한 상태이므로 - // closeModal 내부의 history.back()이 중복 실행되지 않게 주의 modal.classList.add('hidden'); document.body.classList.remove('modal-open'); } -}); +}); \ No newline at end of file diff --git a/scripts/productList.js b/scripts/productList.js index ebd9106..8f8d621 100644 --- a/scripts/productList.js +++ b/scripts/productList.js @@ -1,40 +1,42 @@ -/** 상품 그리드·페이지네이션 렌더링 */ +/** + * productList.js + * 상품 그리드/테이블 렌더링, 지연 로딩 및 페이지네이션 제어 + */ + import { state, saveSelection } from './state.js'; import { ITEMS_PER_PAGE, STATUS_META, STATUS_COLOR, PRODUCT_CONDITIONS } from './config.js'; import { updateSummary } from './main.js'; -// --- 터치 및 드래그 관련 전역 변수 및 핸들러 --- +// ========================================================================== +// 1. 터치 및 호버 인터랙션 (Interaction) +// ========================================================================== + window.isDragging = false; let touchStartX = 0; let touchStartY = 0; - -// 터치 기기 여부 확인 const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; +/** 터치 시작: 드래그 여부 판단 초기화 */ window.handleTouchStart = function (e) { window.isDragging = false; touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; }; +/** 터치 이동: 일정 거리 이상 움직이면 클릭이 아닌 드래그로 간주 */ window.handleTouchMove = function (e) { const touchX = e.touches[0].clientX; const touchY = e.touches[0].clientY; - - // 10px 이상 움직이면 드래그(스크롤)로 간주 if (Math.abs(touchX - touchStartX) > 10 || Math.abs(touchY - touchStartY) > 10) { window.isDragging = true; } }; -window.handleTouchEnd = function (e) { - // 필요한 경우 추가 로직 작성 가능 (현재는 isDragging 상태 유지만으로 충분) -}; +window.handleTouchEnd = function (e) {}; -// 썸네일 호버 핸들러 (PC에서만 동작하도록 수정) +/** 썸네일 호버: 마우스 위치에 따른 이미지 교체 및 인디케이터 업데이트 */ window.handleThumbnailHover = function (e, id) { - if (isTouchDevice) return; // 모바일에서는 호버 로직 실행 안 함 - + if (isTouchDevice) return; const product = state.visibleProducts.find((p) => p.id === id); if (!product || !product.images || product.images.length <= 1) return; @@ -49,290 +51,267 @@ window.handleThumbnailHover = function (e, id) { if (thumb && product.images[index]) { thumb.style.backgroundImage = `url("${product.images[index]}")`; } - if (indicators) { Array.from(indicators).forEach((dot, i) => { - if (i === index) { - dot.classList.add('bg-white', 'scale-125'); - dot.classList.remove('bg-white/40'); - } else { - dot.classList.remove('bg-white', 'scale-125'); - dot.classList.add('bg-white/40'); - } + dot.classList.toggle('bg-white', i === index); + dot.classList.toggle('scale-125', i === index); + dot.classList.toggle('bg-white/40', i !== index); }); } }; +/** 호버 해제: 첫 번째 이미지로 복구 */ window.handleThumbnailLeave = function (id) { if (isTouchDevice) return; - const product = state.visibleProducts.find((p) => p.id === id); const thumb = document.getElementById(`thumb-${id}`); const indicators = document.querySelector(`#indicator-${id}`)?.children; - if (thumb && product) { - thumb.style.backgroundImage = `url("${product.images[0]}")`; - } - + if (thumb && product) thumb.style.backgroundImage = `url("${product.images[0]}")`; if (indicators) { Array.from(indicators).forEach((dot, i) => { - if (i === 0) { - dot.classList.add('bg-white', 'scale-125'); - dot.classList.remove('bg-white/40'); - } else { - dot.classList.remove('bg-white', 'scale-125'); - dot.classList.add('bg-white/40'); - } + dot.classList.toggle('bg-white', i === 0); + dot.classList.toggle('scale-125', i === 0); + dot.classList.toggle('bg-white/40', i !== 0); }); } }; -// 1. 체크박스 전역 핸들러 등록 +/** 개별 체크박스 토글 */ window.toggleSelectItem = function (id) { - if (state.selectedIds.has(id)) { - state.selectedIds.delete(id); - } else { - state.selectedIds.add(id); - } + if (state.selectedIds.has(id)) state.selectedIds.delete(id); + else state.selectedIds.add(id); + saveSelection(); renderProducts(state.currentPage); updateSummary(); }; +// ========================================================================== +// 2. 메인 렌더링 컨트롤러 (Main Rendering) +// ========================================================================== + +/** + * 상태에 따른 상품 목록 렌더링 실행 + * @param {number} page - 현재 페이지 번호 + */ export function renderProducts(page = 1) { const grid = document.getElementById('product-grid'); const tableWrapper = document.getElementById('product-table-wrapper'); - const tableBody = document.getElementById('product-table-body'); - const summaryBar = document.getElementById('selection-summary'); const paginationContainer = document.getElementById('pagination'); if (!grid || !tableWrapper) return; + // [1] 결과 없음 처리 if (state.visibleProducts.length === 0) { - grid.classList.remove('grid'); - grid.classList.add('hidden'); - tableWrapper.classList.add('hidden'); - const emptyMsg = ` -
- - - -

검색 결과가 없습니다

-

입력하신 검색어나 선택한 필터를 확인해 주세요.

-
`; - grid.innerHTML = emptyMsg; - grid.classList.remove('hidden'); - if (paginationContainer) paginationContainer.innerHTML = ''; + renderEmpty(grid, tableWrapper, paginationContainer); return; } - if (state.viewMode === 'grid') { - grid.classList.remove('hidden'); - grid.classList.add('grid'); - tableWrapper.classList.add('hidden'); - if (summaryBar) { - summaryBar.classList.remove('flex'); - summaryBar.classList.add('hidden'); - } - } else { - grid.classList.remove('grid'); - grid.classList.add('hidden'); - tableWrapper.classList.remove('hidden'); - updateSummary(); - } - + // [2] 페이지 데이터 슬라이싱 const startIndex = (page - 1) * ITEMS_PER_PAGE; const pagedProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE); + // [3] 뷰 모드에 따른 렌더링 if (state.viewMode === 'grid') { - grid.innerHTML = ''; - pagedProducts.forEach((product) => { - const isSold = STATUS_META[product.status]?.soldOut === true; - const isNonSale = product.status === '미판매'; - const conditionKey = product.specs?.condition; - const conditionConfig = PRODUCT_CONDITIONS[conditionKey]; - const conditionDisplay = conditionConfig ? conditionConfig.label : conditionKey || ''; - - grid.insertAdjacentHTML( - 'beforeend', - ` -
- -
-
-
- -
-
- -
- ${product.status} -
- - ${ - !isSold && product.images?.length > 1 - ? ` - ` - : '' - } -
- -
-
-

${product.title}

-

- ${isNonSale ? 'Not for Sale' : `${product.currency || '₩'}${product.price.toLocaleString()}`} -

-
-
- ${conditionDisplay ? `${conditionDisplay}` : ''} -

${product.description}

-
-
-
`, - ); - }); - setupLazyLoading(); + renderGridView(grid, tableWrapper, pagedProducts); } else { - // 테이블 렌더링 로직 (생략 없이 유지) - tableBody.innerHTML = pagedProducts - .map((product) => { - const meta = STATUS_META[product.status]; - const isSold = meta?.soldOut === true; - const isSelectable = meta?.selectable !== false; - const conditionKey = product.specs.condition; - const conditionConfig = PRODUCT_CONDITIONS[conditionKey]; - let conditionDisplay = conditionConfig ? conditionConfig.label : conditionKey || '상세 설명 참고 ℹ️'; - let conditionClass = conditionConfig ? conditionConfig.color : 'text-slate-500'; - - return ` - - - - - ${product.title} - ${conditionDisplay} - ₩${product.price.toLocaleString()} - - ${product.status} - - `; - }) - .join(''); + renderTableView(grid, tableWrapper, pagedProducts); } - const selectAllCheck = document.getElementById('select-all-current'); - if (selectAllCheck) { - const startIndex = (page - 1) * ITEMS_PER_PAGE; - const currentSelectableItems = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE).filter((p) => STATUS_META[p.status]?.selectable !== false); - selectAllCheck.checked = currentSelectableItems.length > 0 && currentSelectableItems.every((p) => state.selectedIds.has(p.id)); - } - - if (typeof renderPagination === 'function') renderPagination(); + updateSelectAllCheckbox(page); + renderPagination(); } +/** 검색 결과가 없을 때의 UI */ +function renderEmpty(grid, tableWrapper, paginationContainer) { + grid.classList.remove('grid'); + grid.classList.add('hidden'); + tableWrapper.classList.add('hidden'); + + const emptyMsg = ` +
+ + + +

검색 결과가 없습니다

+

입력하신 검색어나 선택한 필터를 확인해 주세요.

+
`; + + grid.innerHTML = emptyMsg; + grid.classList.remove('hidden'); + if (paginationContainer) paginationContainer.innerHTML = ''; +} + +// ========================================================================== +// 3. 그리드 & 테이블 뷰 상세 (View Details) +// ========================================================================== + +/** 그리드 뷰 렌더링 */ +function renderGridView(grid, tableWrapper, products) { + grid.classList.replace('hidden', 'grid'); + tableWrapper.classList.add('hidden'); + + const summaryBar = document.getElementById('selection-summary'); + if (summaryBar) summaryBar.classList.add('hidden'); + + grid.innerHTML = products.map(product => createProductCardHTML(product)).join(''); + setupLazyLoading(); +} + +/** 그리드 개별 카드 HTML */ +function createProductCardHTML(product) { + const isSold = STATUS_META[product.status]?.soldOut === true; + const isNonSale = product.status === '미판매'; + const conditionConfig = PRODUCT_CONDITIONS[product.specs?.condition]; + const conditionDisplay = conditionConfig ? conditionConfig.label : product.specs?.condition || ''; + + return ` +
+ +
+
+
+ +
+ ${product.status} +
+ + ${!isSold && product.images?.length > 1 ? ` + ` : ''} +
+ +
+
+

${product.title}

+

+ ${isNonSale ? 'Not for Sale' : `${product.currency || '₩'}${product.price.toLocaleString()}`} +

+
+
+ ${conditionDisplay ? `${conditionDisplay}` : ''} +

${product.description}

+
+
+
`; +} + +/** 테이블 뷰 렌더링 */ +function renderTableView(grid, tableWrapper, products) { + grid.classList.add('hidden'); + tableWrapper.classList.remove('hidden'); + + const tableBody = document.getElementById('product-table-body'); + tableBody.innerHTML = products.map(product => createTableRowHTML(product)).join(''); + updateSummary(); +} + +/** 테이블 개별 행 HTML */ +function createTableRowHTML(product) { + const meta = STATUS_META[product.status]; + const isSold = meta?.soldOut === true; + const isSelectable = meta?.selectable !== false; + const conditionConfig = PRODUCT_CONDITIONS[product.specs.condition]; + const conditionDisplay = conditionConfig ? conditionConfig.label : '상세 설명 참고 ℹ️'; + const conditionClass = conditionConfig ? conditionConfig.color : 'text-slate-500'; + + return ` + + + + + ${product.title} + ${conditionDisplay} + ₩${product.price.toLocaleString()} + + ${product.status} + + `; +} + +// ========================================================================== +// 4. 유틸리티 및 페이지네이션 (Utils) +// ========================================================================== + +/** 전체 선택 체크박스 상태 동기화 */ +function updateSelectAllCheckbox(page) { + const selectAllCheck = document.getElementById('select-all-current'); + if (!selectAllCheck) return; + + const startIndex = (page - 1) * ITEMS_PER_PAGE; + const currentSelectableItems = state.visibleProducts + .slice(startIndex, startIndex + ITEMS_PER_PAGE) + .filter((p) => STATUS_META[p.status]?.selectable !== false); + + selectAllCheck.checked = currentSelectableItems.length > 0 && + currentSelectableItems.every((p) => state.selectedIds.has(p.id)); +} + +/** Intersection Observer를 이용한 썸네일 지연 로딩 */ function setupLazyLoading() { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const card = entry.target; - const productId = card.getAttribute('data-id'); - // state.js 등에서 가져온 데이터 활용 - const product = state.visibleProducts.find((p) => p.id === productId); - const thumb = document.getElementById(`thumb-${productId}`); + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const card = entry.target; + const productId = card.getAttribute('data-id'); + const product = state.visibleProducts.find((p) => p.id === productId); + const thumb = document.getElementById(`thumb-${productId}`); - if (product && thumb) { - // 1. 첫 번째 이미지 로드 - thumb.style.backgroundImage = `url("${product.images[0]}")`; - - // 2. 나머지 이미지 프리로드 (반짝임 방지) - if (!STATUS_META[product.status]?.soldOut && product.images.length > 1) { - product.images.slice(1).forEach((url) => { - const img = new Image(); - img.src = url; - }); - } + if (product && thumb) { + thumb.style.backgroundImage = `url("${product.images[0]}")`; + // 마우스 호버를 대비해 나머지 이미지 미리 로드 + if (!STATUS_META[product.status]?.soldOut && product.images.length > 1) { + product.images.slice(1).forEach(url => { const img = new Image(); img.src = url; }); } - observer.unobserve(card); } - }); - }, - { threshold: 0.1 }, - ); + observer.unobserve(card); + } + }); + }, { threshold: 0.1 }); document.querySelectorAll('.product-card').forEach((card) => observer.observe(card)); } +/** 페이지네이션 버튼 렌더링 */ 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 = ``; + for (let i = 1; i <= totalPages; i++) { html += ``; } + html += ``; + container.innerHTML = html; } +/** 페이지 변경 처리 */ export function changePage(page) { state.currentPage = page; renderProducts(page); window.scrollTo({ top: 0, behavior: 'smooth' }); -} - -/** 모든 필터를 초기 상태로 되돌리는 함수 */ -function resetAllFilters() { - state.searchKeyword = ''; - const searchInput = document.getElementById('search-input'); - if (searchInput) searchInput.value = ''; - - state.activeCategories.clear(); - state.activeCategories.add('All'); - - // 상태 필터 초기화 (config에서 defaultActive인 것만) - import('./config.js').then(({ STATUS_FILTERS }) => { - state.activeStatuses.clear(); - STATUS_FILTERS.filter((f) => f.defaultActive).forEach((f) => state.activeStatuses.add(f.key)); - - // UI 전체 갱신 - applyFilters(); - renderStatusChips(); - renderCategoryChips(productsData); - }); -} +} \ No newline at end of file diff --git a/scripts/state.js b/scripts/state.js index 8ec7a33..3a462af 100644 --- a/scripts/state.js +++ b/scripts/state.js @@ -1,18 +1,47 @@ -/** 앱 전역 상태 */ +/** + * state.js + * 앱의 전역 상태 관리 및 세션 스토리지 동기화 + */ + import products from '../data/index.js'; import { STATUS_META } from './config.js'; +// ========================================================================== +// 1. 원본 데이터 정의 +// ========================================================================== + +/** 외부 파일에서 로드한 전체 상품 데이터 */ export const productsData = products; +// ========================================================================== +// 2. 앱 전역 상태 (Global State) +// ========================================================================== + export const state = { + /** 현재 페이지 번호 */ currentPage: 1, + + /** 현재 선택된 카테고리 필터 (기본값: 'All') */ activeCategories: new Set(['All']), - visibleProducts: [], // 초기값은 빈 배열로 두고 main.js나 filter.js에서 첫 계산 + + /** 필터링이 완료되어 실제로 화면에 보여질 상품 배열 */ + visibleProducts: [], + + /** 검색창에 입력된 키워드 */ searchKeyword: '', + + /** 화면 보기 모드 ('grid' | 'table') */ viewMode: 'grid', + + /** 장바구니/내보내기 등을 위해 선택된 상품 ID 세트 (세션 스토리지 복원) */ selectedIds: new Set(JSON.parse(sessionStorage.getItem('selectedProductIds') || '[]')), - activeTags: new Set([]), // 선택된 태그들을 저장 (비어있으면 전체 노출) - // visible이 true인 상태만 초기 활성 필터로 저장 + + /** 현재 활성화된 태그 필터 (교집합 필터링용) */ + activeTags: new Set([]), + + /** * 현재 활성화된 상품 상태 필터 (판매중, 예약중 등) + * config.js의 STATUS_META 설정을 기반으로 초기값 자동 생성 + */ activeStatuses: new Set( Object.entries(STATUS_META) .filter(([_, meta]) => meta.isSystemVisible && meta.isDefaultActive) @@ -20,7 +49,15 @@ export const state = { ), }; -// 선택 내역이 변경될 때마다 세션 스토리지에 저장하는 헬퍼 함수 +// ========================================================================== +// 3. 상태 영속성 관리 (Persistence) +// ========================================================================== + +/** + * 선택된 상품 ID 목록을 세션 스토리지에 저장 + * 체크박스 조작 등 상태가 변경될 때마다 호출하여 새로고침 시에도 데이터 유지 + */ export function saveSelection() { - sessionStorage.setItem('selectedProductIds', JSON.stringify(Array.from(state.selectedIds))); -} + const idsArray = Array.from(state.selectedIds); + sessionStorage.setItem('selectedProductIds', JSON.stringify(idsArray)); +} \ No newline at end of file diff --git a/style/tailwind.css b/style/tailwind.css index da713d1..f9d19fb 100644 --- a/style/tailwind.css +++ b/style/tailwind.css @@ -553,6 +553,9 @@ .min-w-0 { min-width: calc(var(--spacing) * 0); } + .min-w-16 { + min-width: calc(var(--spacing) * 16); + } .min-w-40 { min-width: calc(var(--spacing) * 40); } @@ -2084,6 +2087,17 @@ button:disabled { cursor: default; } + .product-card { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-touch-callout: none; + } + #product-modal, input, textarea { + -webkit-user-select: text; + user-select: text; + } } @layer utilities { .no-scrollbar::-webkit-scrollbar {