필터 로직 개선
최종 스크립트 코드 정리
This commit is contained in:
2026-02-22 13:08:50 +09:00
parent 769e0f4b96
commit c3e207e94f
8 changed files with 1013 additions and 1029 deletions

View File

@@ -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);
}

View File

@@ -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,17 +102,12 @@ 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+' },

View File

@@ -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`;
}
if (state.activeStatuses.size === 0) {
STATUS_FILTERS.filter((f) => f.defaultActive).forEach((f) => state.activeStatuses.add(f.key));
return `${commonSize} ${base} opacity-100 shadow-sm ring-1 ring-offset-1 ring-slate-200 dark:ring-slate-700`;
}
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 `<button class="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 ${activeClass}"
data-category="${cat}">${cat}</button>`;
})
.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 = `
<button id="tag-reset-btn" class="flex items-center justify-center px-2 py-1 rounded-full border transition-all duration-200
${hasActiveTags ? '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="태그 초기화">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
</button>
`;
${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="태그 초기화">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
</button>`;
// 3. 태그 칩들과 합치기
container.innerHTML =
resetBtnHtml +
sortedTags
.map((tag) => {
const isActive = state.activeTags.has(tag);
return `
<button class="tag-chip flex items-center justify-center 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' : 'bg-slate-50 dark:bg-slate-800 text-slate-500 border-slate-200 dark:border-slate-700 hover:border-primary'}"
data-tag="${tag}">
#${tag}
</button>
`;
return `<button class="tag-chip flex items-center justify-center 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' : 'bg-slate-50 dark:bg-slate-800 text-slate-500 border-slate-200 dark:border-slate-700 hover:border-primary'}"
data-tag="${tag}">#${tag}</button>`;
})
.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');
}
}
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);
state.activeCategories.has(category) ? state.activeCategories.delete(category) : state.activeCategories.add(category);
if (state.activeCategories.size === 0) state.activeCategories.add('All');
}
renderCategoryChips(productsData);
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);
}
};
}

View File

@@ -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 업데이트
if (indicator) updateIndicatorUI(indicator, index);
}
window.handleThumbnailLeave = (productId) => {
currentHoverIndex = -1; // 인덱스 초기화
resetThumbnail(productId);
};
/** * 마우스가 나갔을 때 썸네일을 첫 번째 이미지로 복구하는 함수
/**
* 썸네일 상태 복구
*/
function resetThumbnail(productId) {
if (fadeTimers[productId]) {
@@ -380,8 +245,6 @@ function resetThumbnail(productId) {
if (mainThumb) {
const firstImgUrl = `url("${product.images[0]}")`;
// 즉시 첫 번째 이미지로 복구
mainThumb.style.backgroundImage = firstImgUrl;
if (fadeThumb) {
@@ -390,11 +253,9 @@ function resetThumbnail(productId) {
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();
});

View File

@@ -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 => `
<div class="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('');
`).join('');
const thumbnailsHtml = product.images
.map(
(img, idx) => `
// 하단 썸네일 HTML
const thumbnailsHtml = 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 shrink-0"
style="background-image: url('${img}');"></div>
`,
)
.join('');
`).join('');
const dotsHtml = product.images
.map(
(_, idx) => `
// 인디케이터 도트 HTML
const dotsHtml = 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('');
`).join('');
document.getElementById('modal-main-carousel').innerHTML = mainImagesHtml;
document.getElementById('modal-thumbnails').innerHTML = thumbnailsHtml;
document.getElementById('modal-dots').innerHTML = dotsHtml;
}
/** 상품 텍스트 정보 및 가격/상태 렌더링 */
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,10 +147,65 @@ export function openModal(id) {
modalDesc.innerHTML = Array.isArray(product.fullDescription) ? product.fullDescription.join('<br>') : 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) {
if (!copyBtn) return;
copyBtn.onclick = () => {
const shareUrl = `${window.location.origin}${window.location.pathname}?id=${product.id}`;
navigator.clipboard.writeText(shareUrl).then(() => {
@@ -175,56 +217,12 @@ export function openModal(id) {
}, 2000);
});
};
}
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);
}
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');
}

View File

@@ -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,69 +51,84 @@ 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) {
renderEmpty(grid, tableWrapper, paginationContainer);
return;
}
// [2] 페이지 데이터 슬라이싱
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const pagedProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE);
// [3] 뷰 모드에 따른 렌더링
if (state.viewMode === 'grid') {
renderGridView(grid, tableWrapper, pagedProducts);
} else {
renderTableView(grid, tableWrapper, pagedProducts);
}
updateSelectAllCheckbox(page);
renderPagination();
}
/** 검색 결과가 없을 때의 UI */
function renderEmpty(grid, tableWrapper, paginationContainer) {
grid.classList.remove('grid');
grid.classList.add('hidden');
tableWrapper.classList.add('hidden');
const emptyMsg = `
<div class="col-span-full flex flex-col items-center justify-center py-20 w-full text-center">
<svg class="w-20 h-20 text-slate-300 dark:text-slate-700 mb-6" viewBox="0 -960 960 960" fill="currentColor">
@@ -120,42 +137,36 @@ export function renderProducts(page = 1) {
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">검색 결과가 없습니다</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm">입력하신 검색어나 선택한 필터를 확인해 주세요.</p>
</div>`;
grid.innerHTML = emptyMsg;
grid.classList.remove('hidden');
if (paginationContainer) paginationContainer.innerHTML = '';
return;
}
}
if (state.viewMode === 'grid') {
grid.classList.remove('hidden');
grid.classList.add('grid');
// ==========================================================================
// 3. 그리드 & 테이블 뷰 상세 (View Details)
// ==========================================================================
/** 그리드 뷰 렌더링 */
function renderGridView(grid, tableWrapper, products) {
grid.classList.replace('hidden', '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();
}
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const pagedProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE);
const summaryBar = document.getElementById('selection-summary');
if (summaryBar) summaryBar.classList.add('hidden');
if (state.viewMode === 'grid') {
grid.innerHTML = '';
pagedProducts.forEach((product) => {
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 conditionKey = product.specs?.condition;
const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
const conditionDisplay = conditionConfig ? conditionConfig.label : conditionKey || '';
const conditionConfig = PRODUCT_CONDITIONS[product.specs?.condition];
const conditionDisplay = conditionConfig ? conditionConfig.label : product.specs?.condition || '';
grid.insertAdjacentHTML(
'beforeend',
`
return `
<div class="product-card group flex flex-col gap-4 cursor-pointer"
data-id="${product.id}"
onclick="if(!window.isDragging) window.openModal('${product.id}')"
@@ -171,29 +182,14 @@ export function renderProducts(page = 1) {
style="background-image: none; will-change: background-image, transform;">
</div>
<div id="thumb-fade-${product.id}"
class="w-full h-full bg-center bg-no-repeat bg-cover absolute inset-0 opacity-0 pointer-events-none transition-all duration-500 ${isSold ? 'grayscale' : 'group-hover:scale-105'}"
style="background-image: none; will-change: background-image, opacity, transform;">
</div>
<div class="absolute top-3 left-3 z-10">
<span class="px-2 py-1 text-[10px] uppercase tracking-wider font-bold rounded ${STATUS_COLOR[product.status]} backdrop-blur-md border">${product.status}</span>
</div>
${
!isSold && product.images?.length > 1
? `
${!isSold && product.images?.length > 1 ? `
<div id="indicator-${product.id}" class="absolute bottom-3 left-1/2 -translate-x-1/2 hidden md:flex gap-1.5 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none z-10">
${product.images
.map(
(_, i) => `
<div class="w-1.5 h-1.5 rounded-full transition-all duration-300 shadow-sm ${i === 0 ? 'bg-white scale-125' : 'bg-white/40'}"></div>
`,
)
.join('')}
</div>`
: ''
}
${product.images.map((_, i) => `<div class="w-1.5 h-1.5 rounded-full transition-all duration-300 shadow-sm ${i === 0 ? 'bg-white scale-125' : 'bg-white/40'}"></div>`).join('')}
</div>` : ''}
</div>
<div class="flex flex-col gap-1.5">
@@ -208,27 +204,31 @@ export function renderProducts(page = 1) {
<p class="text-slate-500 dark:text-slate-400 text-sm font-normal line-clamp-1 italic">${product.description}</p>
</div>
</div>
</div>`,
);
});
setupLazyLoading();
} else {
// 테이블 렌더링 로직 (생략 없이 유지)
tableBody.innerHTML = pagedProducts
.map((product) => {
</div>`;
}
/** 테이블 뷰 렌더링 */
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 conditionKey = product.specs.condition;
const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
let conditionDisplay = conditionConfig ? conditionConfig.label : conditionKey || '상세 설명 참고 ';
let conditionClass = conditionConfig ? conditionConfig.color : 'text-slate-500';
const conditionConfig = PRODUCT_CONDITIONS[product.specs.condition];
const conditionDisplay = conditionConfig ? conditionConfig.label : '상세 설명 참고 ';
const conditionClass = conditionConfig ? conditionConfig.color : 'text-slate-500';
return `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors ${isSold ? 'opacity-50 grayscale cursor-not-allowed' : 'cursor-pointer'}"
onclick="if(event.target.type !== 'checkbox') {
${isSold ? "alert('판매 완료된 상품입니다.');" : `window.openModal('${product.id}')`}
}">
onclick="if(event.target.type !== 'checkbox') { ${isSold ? "alert('판매 완료된 상품입니다.');" : `window.openModal('${product.id}')`} }">
<td class="py-4 px-4 text-center" onclick="event.stopPropagation()">
<input type="checkbox" class="product-check rounded border-slate-300 w-4 h-4 ${isSelectable ? 'cursor-pointer text-primary' : 'opacity-20 cursor-not-allowed'}"
${state.selectedIds.has(product.id) ? 'checked' : ''} ${isSelectable ? '' : 'disabled'}
@@ -241,98 +241,77 @@ export function renderProducts(page = 1) {
<span class="px-2 py-0.5 rounded text-[10px] font-bold border ${STATUS_COLOR[product.status]}">${product.status}</span>
</td>
</tr>`;
})
.join('');
}
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();
}
// ==========================================================================
// 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) => {
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}`);
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;
});
product.images.slice(1).forEach(url => { const img = new Image(); img.src = url; });
}
}
observer.unobserve(card);
}
});
},
{ threshold: 0.1 },
);
}, { 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 = `<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>
<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>
<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' });
}
/** 모든 필터를 초기 상태로 되돌리는 함수 */
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);
});
}

View File

@@ -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));
}

View File

@@ -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 {