- 태그 AND 검색 도입
- UI/UX 디자인 개선 (칩 & 배지)
- 모바일 최적화 및 레이아웃
- 성능 및 리소스 최적화 (Zero-Dependency 아이콘)
- 데이터 안정성 및 기타
- 그 외 오류 복구
- Tailwind CDN 제거
This commit is contained in:
2026-02-12 17:25:56 +09:00
parent a7817d2113
commit 555321fe70
10 changed files with 990 additions and 428 deletions

View File

@@ -14,14 +14,14 @@ export const SORT_CONFIG = {
export const STATUS_META = {
미판매: {
selectable: false, // 체크박스 선택 불가
selectable: false, // 체크박스 선택 불가
isDefaultActive: false, // 초기 로드 시 미체크 상태
isSystemVisible: true, // 아예 리스트/필터에서 제외 (완전 숨김)
soldOut: false,
},
판매예정: {
selectable: false,
isDefaultActive: true,
isDefaultActive: false,
isSystemVisible: true,
soldOut: false,
},
@@ -39,21 +39,18 @@ export const STATUS_META = {
},
};
export const STATUS_ORDER = {
판매중: 1,
판매예정: 2,
판매완료: 3,
미판매: 4,
};
// STATUS_FILTERS를 수동으로 만들지 않고 META에서 자동으로 생성합니다.
export const STATUS_FILTERS = Object.keys(STATUS_META)
.filter(key => STATUS_META[key].isSystemVisible) // 시스템 가시성이 true인 것만 필터 칩 생성
.map(key => ({
key: key,
label: key === '판매예정' ? '판매 예정' : key,
defaultActive: STATUS_META[key].isDefaultActive
}));
export const STATUS_ORDER = {
판매중: 0,
판매예정: 1,
미판매: 2,
판매완료: 3,
};
.filter((key) => STATUS_META[key].isSystemVisible)
.map((key) => ({ key, label: key }))
.sort((a, b) => (STATUS_ORDER[a.key] || 99) - (STATUS_ORDER[b.key] || 99));
export const STATUS_COLOR = {
판매중: 'bg-primary/10 text-primary border-primary/30',
@@ -93,5 +90,5 @@ export const PRODUCT_CONDITIONS = {
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: '-' }
};
OTHER: { label: '기타 (상세설명 참고)', color: 'text-indigo-600', level: '-' },
};

View File

@@ -3,12 +3,21 @@ 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 { 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] ?? '';
if (isActive) {
return `${base} opacity-100 shadow-sm`;
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`;
}
return `bg-slate-50 text-slate-400 border-slate-200 opacity-30 grayscale hover:opacity-50`;
return `${commonSize} bg-slate-50 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 opacity-40 grayscale`;
}
export function renderStatusChips() {
@@ -16,15 +25,20 @@ export function renderStatusChips() {
if (!container) return;
container.innerHTML = '';
// 이제 config에서 자동 생성된 STATUS_FILTERS를 사용합니다.
STATUS_FILTERS.forEach(({ key, label }) => {
const isActive = state.activeStatuses.has(key);
const chip = document.createElement('button');
// getStatusChipClass 함수가 기존에 정의되어 있다면 그대로 사용하세요.
chip.className = `status-chip px-3 py-1.5 md:px-4 md:py-2 rounded-full text-xs md:text-sm font-medium transition-all duration-200 border ${getStatusChipClass(key, isActive)}`;
// 칩 공통 스타일 적용
chip.className = `status-chip flex items-center rounded-full font-bold transition-all duration-200 border ${getStatusChipClass(key, isActive)}`;
chip.textContent = label;
chip.onclick = () => toggleStatusFilter(key);
chip.onclick = () => {
if (state.activeStatuses.has(key)) state.activeStatuses.delete(key);
else state.activeStatuses.add(key);
renderStatusChips();
applyFilters();
};
container.appendChild(chip);
});
}
@@ -42,6 +56,68 @@ function toggleStatusFilter(status) {
renderStatusChips();
}
export function renderTagChips() {
const container = document.getElementById('tag-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();
// 2. 리셋 버튼 HTML (맨 앞에 배치)
// 활성화된 태그가 있을 때만 강조되거나, 항상 보이게 설정 가능합니다.
const hasActiveTags = state.activeTags.size > 0;
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>
`;
// 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>
`;
})
.join('');
// 4. 리셋 버튼 이벤트 바인딩
document.getElementById('tag-reset-btn').onclick = () => {
if (state.activeTags.size === 0) return;
state.activeTags.clear();
renderTagChips(); // UI 갱신
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();
applyFilters();
};
});
}
// [핵심] 필터 적용 함수
export function applyFilters() {
const keyword = state.searchKeyword.toLowerCase();
@@ -64,21 +140,29 @@ export function applyFilters() {
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);
return searchPool.some((text) =>
String(text || '')
.toLowerCase()
.includes(keyword),
);
// 모든 텍스트를 하나의 검색용 문자열로 결합
const combinedText = searchPool.map((text) => String(text || '').toLowerCase()).join(' ');
// [핵심] 사용자가 입력한 모든 단어(searchTerms)가 결합된 텍스트에 포함되어 있는지 확인 (AND 조건)
return searchTerms.every((term) => combinedText.includes(term));
})();
return statusMatch && categoryMatch && searchMatch;
// [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. 스위치가 모두 꺼져있다면 정렬하지 않고 원본 순서 유지
@@ -138,19 +222,17 @@ export function getCategories(products) {
}
export function renderCategoryChips(products) {
const container = document.getElementById('filter-chips');
const container = document.getElementById('filter-chips'); // 혹은 HTML 구조에 맞춰 'category-chips'
if (!container) return;
// [핵심] 시스템 가시성이 true인 상품의 카테고리만 추출합니다.
// 1. 가시성 있는 상품의 카테고리만 추출
const validCategories = products
.filter((p) => {
const meta = STATUS_META[p.status];
// 해당 상태가 정의되어 있고, 시스템에서 보여주기로 한 경우만 포함
return meta && meta.isSystemVisible;
})
.map((p) => p.category);
// 'All'은 항상 포함하고, 필터링된 카테고리들만 중복 제거하여 합침
const categories = ['All', ...new Set(validCategories)];
container.innerHTML = '';
@@ -158,10 +240,19 @@ export function renderCategoryChips(products) {
categories.forEach((cat) => {
const isActive = state.activeCategories.has(cat);
const chip = document.createElement('button');
chip.className = `filter-chip px-3 py-1.5 md:px-4 md:py-2 rounded-full text-xs md:text-sm font-medium transition border ${isActive ? 'bg-primary text-white border-primary shadow-sm' : 'bg-slate-50 text-slate-600 border-slate-200'}`;
// [수정] 태그 칩 사이즈(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);
chip.onclick = () => {
toggleCategory(cat);
};
container.appendChild(chip);
});
}
@@ -172,10 +263,16 @@ export function toggleCategory(category) {
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');
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);
renderCategoryChips(productsData); // 디자인(활성화 상태) 즉시 반영
applyFilters();
}

View File

@@ -1,6 +1,6 @@
/** 진입점: 이벤트 바인딩·초기 렌더·URL 모달 처리 */
import { state, productsData, saveSelection } from './state.js';
import { applyFilters, renderStatusChips, renderCategoryChips, bindCategoryFilter } from './filters.js';
import { applyFilters, renderStatusChips, renderCategoryChips, bindCategoryFilter, renderTagChips } from './filters.js';
import { ITEMS_PER_PAGE, STATUS_META } from './config.js';
import { renderProducts, changePage } from './productList.js';
import { openModal, closeModal } from './modal.js';
@@ -8,7 +8,7 @@ import { scrollToImage } from './carousel.js';
console.log('Total products loaded:', productsData.length);
// HTML onclick에서 사용
// HTML onclick에서 사용하기 위한 전역 등록
window.openModal = openModal;
window.closeModal = closeModal;
window.changePage = changePage;
@@ -17,12 +17,12 @@ window.scrollToImage = scrollToImage;
let fadeTimers = {};
// 뷰 전환 이벤트
// 뷰 전환 이벤트 바인딩
document.getElementById('view-grid').onclick = () => {
state.viewMode = 'grid';
updateViewButtons();
renderProducts(state.currentPage);
};
document.getElementById('view-table').onclick = () => {
state.viewMode = 'table';
updateViewButtons();
@@ -31,10 +31,25 @@ document.getElementById('view-table').onclick = () => {
function updateViewButtons() {
const isGrid = state.viewMode === 'grid';
document.getElementById('view-grid').classList.toggle('bg-white', isGrid);
document.getElementById('view-grid').classList.toggle('text-primary', isGrid);
document.getElementById('view-table').classList.toggle('bg-white', !isGrid);
document.getElementById('view-table').classList.toggle('text-primary', !isGrid);
const gridBtn = document.getElementById('view-grid');
const tableBtn = document.getElementById('view-table');
if (!gridBtn || !tableBtn) return;
const active = ['bg-white', 'dark:bg-slate-700', 'shadow-sm', 'text-primary'];
const inactive = ['text-slate-400'];
if (isGrid) {
gridBtn.classList.add(...active);
gridBtn.classList.remove(...inactive);
tableBtn.classList.add(...inactive);
tableBtn.classList.remove(...active);
} else {
tableBtn.classList.add(...active);
tableBtn.classList.remove(...inactive);
gridBtn.classList.add(...inactive);
gridBtn.classList.remove(...active);
}
}
// [전역 함수] 체크박스 토글 및 합계 업데이트
@@ -45,30 +60,25 @@ window.toggleSelectItem = (id) => {
updateSummary();
};
/** 선택 요약 바 업데이트 (모바일 레이아웃 대응) */
export function updateSummary() {
const summary = document.getElementById('selection-summary');
const summaryBar = document.getElementById('selection-summary');
const countEl = document.getElementById('selected-count');
const priceEl = document.getElementById('selected-total-price');
if (!summary) return;
// 테이블 모드이면서 선택된 항목이 있을 때만 노출
if (state.viewMode === 'table' && state.selectedIds.size > 0) {
// [수정] flex를 추가할 때 hidden은 확실히 제거
summary.classList.remove('hidden');
summary.classList.add('flex');
if (state.selectedIds.size > 0) {
summaryBar.classList.remove('hidden');
summaryBar.classList.add('flex'); // flex-wrap은 HTML에 이미 적용됨
countEl.textContent = state.selectedIds.size;
const total = Array.from(state.selectedIds).reduce((sum, id) => {
const p = productsData.find((item) => item.id === id);
return sum + (p ? p.price : 0);
}, 0);
countEl.textContent = state.selectedIds.size;
priceEl.textContent = `${total.toLocaleString()}`;
} else {
// [수정] hidden을 추가할 때 flex는 확실히 제거
summary.classList.remove('flex');
summary.classList.add('hidden');
summaryBar.classList.add('hidden');
summaryBar.classList.remove('flex');
}
}
@@ -137,15 +147,53 @@ themeToggleBtn.addEventListener('click', function () {
updateIcons();
});
// 초기 렌더
renderCategoryChips(productsData);
bindCategoryFilter(productsData);
renderStatusChips();
applyFilters();
document.addEventListener('DOMContentLoaded', () => {
console.log('Total products loaded:', productsData.length);
// 1. UI 컴포넌트 렌더링
renderCategoryChips(productsData);
bindCategoryFilter(productsData);
renderStatusChips();
renderTagChips();
// 2. 초기 데이터 계산 및 첫 페이지 렌더링 (순서 중요)
applyFilters();
renderProducts(state.currentPage);
checkUrlAndOpenModal();
updateViewButtons();
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 생성기
@@ -308,7 +356,7 @@ function updateThumbnailWithFade(productId, newImageUrl, index) {
window.handleThumbnailLeave = (productId) => {
currentHoverIndex = -1; // 인덱스 초기화
resetThumbnail(productId);
};
@@ -332,7 +380,7 @@ function resetThumbnail(productId) {
// 2. 페이드 레이어를 즉시 숨김 (transition 방해 금지)
fadeThumb.style.transition = 'none';
fadeThumb.style.opacity = '0';
// 3. 두 레이어 모두 첫 번째 이미지로 강제 일치
mainThumb.style.backgroundImage = firstImg;
fadeThumb.style.backgroundImage = firstImg;
@@ -342,7 +390,7 @@ function resetThumbnail(productId) {
fadeThumb.style.transition = 'opacity 0.3s ease-in-out';
}, 50);
}
if (indicator) updateIndicatorUI(indicator, 0);
}
@@ -356,7 +404,6 @@ function updateIndicatorUI(indicator, activeIndex) {
});
}
// 터치 상태 관리를 위한 변수
let touchStartX = 0;
let isDragging = false;
@@ -387,7 +434,7 @@ window.handleTouchMove = (e, productId) => {
const mainThumb = document.getElementById(`thumb-${productId}`);
const fadeThumb = document.getElementById(`thumb-fade-${productId}`);
if (mainThumb && fadeThumb) {
// 드래그 중에는 페이드 없이 즉시 교체 (반응성 우선)
mainThumb.style.backgroundImage = `url("${product.images[index]}")`;
@@ -418,4 +465,3 @@ function updateIndicator(productId, index) {
});
}
}

View File

@@ -2,9 +2,9 @@
import { state, saveSelection } from './state.js';
import { ITEMS_PER_PAGE, STATUS_META, STATUS_COLOR, PRODUCT_CONDITIONS } from './config.js';
import { updateSummary } from './main.js';
import { openModal } from './modal.js';
// 1. 체크박스 전역 핸들러 등록
window.toggleSelectItem = function(id) {
window.toggleSelectItem = function (id) {
if (state.selectedIds.has(id)) {
state.selectedIds.delete(id);
} else {
@@ -29,12 +29,17 @@ export function renderProducts(page = 1) {
grid.classList.remove('grid');
grid.classList.add('hidden');
tableWrapper.classList.add('hidden');
// 검색 결과 없음 메시지를 표시할 별도의 컨테이너가 없다면 grid 영역을 빌려 씁니다.
const emptyMsg = `
<div class="col-span-full flex flex-col items-center justify-center py-20 w-full text-center">
<span class="material-symbols-outlined text-6xl text-slate-300 dark:text-slate-700 mb-4">search_off</span>
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-2">검색 결과가 없습니다</h3>
<svg class="w-20 h-20 text-slate-300 dark:text-slate-700 mb-6"
viewBox="0 -960 960 960"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<path d="M138.5-138.5Q80-197 80-280t58.5-141.5Q197-480 280-480t141.5 58.5Q480-363 480-280t-58.5 141.5Q363-80 280-80t-141.5-58.5ZM824-120 568-376q-12-13-25.5-26.5T516-428q38-24 61-64t23-88q0-75-52.5-127.5T420-760q-75 0-127.5 52.5T240-580q0 6 .5 11.5T242-557q-18 2-39.5 8T164-535q-2-11-3-22t-1-23q0-109 75.5-184.5T420-840q109 0 184.5 75.5T680-580q0 43-13.5 81.5T629-428l251 252-56 56Zm-615-61 71-71 70 71 29-28-71-71 71-71-28-28-71 71-71-71-28 28 71 71-71 71 28 28Z"/>
</svg>
<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>
`;
@@ -67,93 +72,98 @@ export function renderProducts(page = 1) {
if (state.viewMode === 'grid') {
grid.innerHTML = '';
pagedProducts.forEach((product) => {
// 1. 상태 판별
const isSold = STATUS_META[product.status]?.soldOut === true;
const isNonSale = product.status === '미판매'; // 상태값이 '미판매'일 때
// 1. 상태 판별
const isSold = STATUS_META[product.status]?.soldOut === true;
const isNonSale = product.status === '미판매'; // 상태값이 '미판매'일 때
// 2. 스펙(Condition) 정보 추출
const conditionKey = product.specs?.condition;
const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
const conditionDisplay = conditionConfig ? conditionConfig.label : (conditionKey || '');
// 2. 스펙(Condition) 정보 추출
const conditionKey = product.specs?.condition;
const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
const conditionDisplay = conditionConfig ? conditionConfig.label : conditionKey || '';
grid.insertAdjacentHTML('beforeend', `
<div class="product-card group flex flex-col gap-4 cursor-pointer"
data-id="${product.id}"
onclick="if(!window.isDragging) window.openModal('${product.id}')"
${!isSold ? `
onmousemove="window.handleThumbnailHover(event, '${product.id}')"
onmouseleave="window.handleThumbnailLeave('${product.id}')"
ontouchstart="window.handleTouchStart(event)"
ontouchmove="window.handleTouchMove(event, '${product.id}')"
ontouchend="window.handleTouchEnd(event, '${product.id}')"
` : `
ontouchend="window.handleTouchEnd(event, '${product.id}')"
`}>
grid.insertAdjacentHTML(
'beforeend',
`
<div
class="product-card group flex flex-col gap-4 cursor-pointer"
data-id="${product.id}"
onclick="if(!window.isDragging) window.openModal('${product.id}')"
${
!isSold
? `
onmousemove="window.handleThumbnailHover(event, '${product.id}')"
onmouseleave="window.handleThumbnailLeave('${product.id}')"
ontouchstart="window.handleTouchStart(event)"
ontouchmove="window.handleTouchMove(event, '${product.id}')"
ontouchend="window.handleTouchEnd(event, '${product.id}')"
`
: `
ontouchend="window.handleTouchEnd(event, '${product.id}')"
`
}>
<div class="relative w-full aspect-card bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
<div id="thumb-${product.id}"
class="w-full h-full bg-center bg-no-repeat bg-cover absolute inset-0 transition-all duration-500 ${isSold ? 'grayscale opacity-60' : 'group-hover:scale-105'}"
style="background-image: none; will-change: background-image, transform;">
</div>
<div class="relative w-full aspect-card bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
<div id="thumb-${product.id}"
class="w-full h-full bg-center bg-no-repeat bg-cover absolute inset-0 transform transition-transform duration-500 ${isSold ? 'grayscale opacity-60' : 'group-hover:scale-105'}"
style="background-image: none; will-change: background-image;">
</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 id="thumb-fade-${product.id}"
class="w-full h-full bg-center bg-no-repeat bg-cover absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none transform transition-transform duration-500 ${isSold ? 'grayscale' : 'group-hover:scale-105'}"
style="background-image: none; will-change: background-image, opacity;">
</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>
<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 flex gap-1.5 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none z-10">
${product.images.map((_, i) => `
${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('')}
`,
)
.join('')}
</div>
` : ''}
</div>
`
: ''
}
</div>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col justify-between items-start sm:items-baseline gap-1">
<h3 class="text-slate-900 dark:text-white text-base font-semibold leading-tight break-keep ${isSold ? 'line-through text-slate-400' : ''}">
${product.title}
</h3>
<p class="text-base font-bold whitespace-nowrap ${isNonSale ? 'text-slate-400 font-medium text-xs uppercase' : 'text-slate-900 dark:text-white'}">
${isNonSale ? 'Not for Sale' : `${product.currency || '₩'}${product.price.toLocaleString()}`}
</p>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col justify-between items-start sm:items-baseline gap-1">
<h3 class="text-slate-900 dark:text-white text-base font-semibold leading-tight break-keep ${isSold ? 'line-through text-slate-400' : ''}">${product.title}</h3>
<p class="text-base font-bold whitespace-nowrap ${isNonSale ? 'text-slate-400 font-medium text-xs uppercase' : 'text-slate-900 dark:text-white'}">${isNonSale ? 'Not for Sale' : `${product.currency || '₩'}${product.price.toLocaleString()}`}</p>
</div>
<div class="flex flex-col">
${conditionDisplay ? `<span class="text-[11px] font-medium text-slate-400 mb-0.5">${conditionDisplay}</span>` : ''}
<p class="text-slate-500 dark:text-slate-400 text-sm font-normal line-clamp-1 italic">
${product.description}
</p>
</div>
</div>
</div>
`);
});
setupLazyLoading();
<div class="flex flex-col">
${conditionDisplay ? `<span class="text-[11px] font-medium text-slate-400 mb-0.5">${conditionDisplay}</span>` : ''}
<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) => {
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';
tableBody.innerHTML = pagedProducts
.map((product) => {
const meta = STATUS_META[product.status];
const isSold = meta?.soldOut === true;
const isSelectable = meta?.selectable !== false;
return `
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 `
<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}')`}
@@ -174,15 +184,15 @@ setupLazyLoading();
<span class="px-2 py-0.5 rounded text-[10px] font-bold border ${STATUS_COLOR[product.status]}">${product.status}</span>
</td>
</tr>`;
}).join('');
})
.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);
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));
}
@@ -193,33 +203,36 @@ setupLazyLoading();
}
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');
// 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;
});
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;
});
}
}
observer.unobserve(card);
}
observer.unobserve(card);
}
});
}, { threshold: 0.1 });
});
},
{ threshold: 0.1 },
);
document.querySelectorAll('.product-card').forEach(card => observer.observe(card));
document.querySelectorAll('.product-card').forEach((card) => observer.observe(card));
}
export function renderPagination() {

View File

@@ -11,12 +11,12 @@ export const state = {
searchKeyword: '',
viewMode: 'grid',
selectedIds: new Set(JSON.parse(sessionStorage.getItem('selectedProductIds') || '[]')),
activeTags: new Set([]), // 선택된 태그들을 저장 (비어있으면 전체 노출)
// visible이 true인 상태만 초기 활성 필터로 저장
activeStatuses: new Set(
activeStatuses: new Set(
Object.entries(STATUS_META)
.filter(([_, meta]) => meta.isSystemVisible && meta.isDefaultActive)
.map(([status]) => status)
.map(([status]) => status),
),
};