/** * 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, SORT_CONFIG } from './config.js'; import { renderProducts } from './productList.js'; // ========================================================================== // 1. 데이터 필터링 및 검색 로직 (Logic) // ========================================================================== /** * 검색어 매칭 여부 확인 (AND 조건) * @param {Object} product - 상품 객체 * @param {string} keyword - 검색 키워드 */ function checkSearchMatch(product, keyword) { if (!keyword) return true; 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 || (state.tagMode === 'AND' ? Array.from(state.activeTags).every((tag) => product.tags?.includes(tag)) : Array.from(state.activeTags).some((tag) => 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; container.innerHTML = ''; STATUS_FILTERS.forEach(({ key, label }) => { 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; chip.onclick = () => { if (state.activeStatuses.has(key)) state.activeStatuses.delete(key); else state.activeStatuses.add(key); renderStatusChips(); applyFilters(); }; container.appendChild(chip); }); } /** * 상태 칩 스타일 클래스 생성 헬퍼 */ 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`; } return `${commonSize} bg-slate-50 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 opacity-40 grayscale`; } /** * 카테고리(Category) 필터 칩 렌더링 */ export function renderCategoryChips(products = productsData) { const container = document.getElementById('filter-chips'); if (!container) return; const validCategories = products.filter((p) => STATUS_META[p.status]?.isSystemVisible).map((p) => p.category); 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 = ` `; const modeBtnHtml = ` `; container.innerHTML = modeBtnHtml + resetBtnHtml + sortedTags .map((tag) => { const isActive = state.activeTags.has(tag); return ``; }) .join(''); // [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 = () => { state.activeTags.clear(); applyFilters(); }; document.getElementById('tag-mode-btn').onclick = () => { state.tagMode = state.tagMode === 'AND' ? 'OR' : 'AND'; applyFilters(); // 필터 재적용 및 UI 갱신 }; container.querySelectorAll('.tag-chip').forEach((chip) => { chip.onclick = () => { const tag = chip.dataset.tag; state.activeTags.has(tag) ? state.activeTags.delete(tag) : state.activeTags.add(tag); applyFilters(); }; }); } // ========================================================================== // 3. 사용자 인터랙션 제어 (Interaction) // ========================================================================== /** * 카테고리 선택 토글 * @param {string} category */ export function toggleCategory(category) { if (category === 'All') { state.activeCategories.clear(); state.activeCategories.add('All'); } else { state.activeCategories.delete('All'); state.activeCategories.has(category) ? state.activeCategories.delete(category) : state.activeCategories.add(category); if (state.activeCategories.size === 0) state.activeCategories.add('All'); } renderCategoryChips(productsData); applyFilters(); } /** * 로고(타이틀) 클릭 시 모든 필터 초기화 */ document.getElementById('logo-title')?.addEventListener('click', () => { // 1. 기본 상태 초기화 state.searchKeyword = ''; state.activeTags.clear(); state.activeCategories.clear(); state.activeCategories.add('All'); state.currentPage = 1; // 2. [수정] config.js의 명칭(isDefaultActive)에 맞춰 필터 복구 state.activeStatuses.clear(); // STATUS_FILTERS를 순회하며 STATUS_META에 정의된 기본 활성 상태를 추가 STATUS_FILTERS.forEach((f) => { // config.js에서 정의한 isDefaultActive 속성을 확인합니다. if (STATUS_META[f.key]?.isDefaultActive) { state.activeStatuses.add(f.key); } }); // 만약 config 설정 실수로 아무것도 추가되지 않았다면 '판매중'이라도 강제로 넣음 (안전장치) if (state.activeStatuses.size === 0) { state.activeStatuses.add('판매중'); } // 3. UI 리셋 const searchInput = document.getElementById('search-input'); if (searchInput) searchInput.value = ''; // 4. 데이터 갱신 및 UI 렌더링 applyFilters(); renderStatusChips(); renderCategoryChips(productsData); renderTagChips(); window.scrollTo({ top: 0, behavior: 'smooth' }); console.log("필터 복구 완료:", Array.from(state.activeStatuses)); }); /** * 태그 컨테이너 확장/축소 토글 설정 (최초 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); } }; }