Files
zenn.inventory/scripts/productList.js
zenn fcb5e8959c [260211]
- 썸네일 슬라이드 기능 추가
- 미판매 상품 가격 표시 안함

추후 해야할일: 이미지 webp로 변환
2026-02-11 18:48:15 +09:00

255 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 상품 그리드·페이지네이션 렌더링 */
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) {
if (state.selectedIds.has(id)) {
state.selectedIds.delete(id);
} else {
state.selectedIds.add(id);
}
saveSelection();
renderProducts(state.currentPage);
updateSummary();
};
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. 결과가 0개인 경우 안내
if (state.visibleProducts.length === 0) {
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>
<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;
}
// 2. 뷰 모드 설정 및 컨테이너 노출 정리 (hidden/flex/grid 충돌 방지)
if (state.viewMode === 'grid') {
grid.classList.remove('hidden');
grid.classList.add('grid');
tableWrapper.classList.add('hidden');
if (summaryBar) {
summaryBar.classList.remove('flex');
summaryBar.classList.add('hidden');
}
} else {
grid.classList.remove('grid');
grid.classList.add('hidden');
tableWrapper.classList.remove('hidden');
updateSummary(); // 테이블일 때만 요약바 노출 여부 결정
}
// 3. 현재 페이지 데이터 계산
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const pagedProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE);
if (state.viewMode === 'grid') {
grid.innerHTML = '';
pagedProducts.forEach((product) => {
// 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 || '');
grid.insertAdjacentHTML('beforeend', `
<div class="product-card group flex flex-col gap-4 cursor-pointer"
data-id="${product.id}"
onclick="window.openModal('${product.id}')"
${!isSold ? `onmousemove="window.handleThumbnailHover(event, '${product.id}')"
onmouseleave="window.handleThumbnailLeave('${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 transform ${isSold ? 'grayscale opacity-60' : 'group-hover:scale-105 transition-transform duration-500'}"
style="background-image: none; will-change: background-image;">
</div>
<div class="absolute top-3 left-3">
<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 ? `
<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">
${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">
<div class="flex flex-col sm:flex-row 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.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>
`);
});
// 지연 로딩(Lazy Loading) 관찰자 설정
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const card = entry.target;
const productId = card.getAttribute('data-id');
const product = state.visibleProducts.find(p => p.id === productId);
const thumb = document.getElementById(`thumb-${productId}`);
if (product && thumb) {
// 1. 첫 번째 이미지 로드
thumb.style.backgroundImage = `url("${product.images[0]}")`;
// 2. 마우스 올리기 전, 나머지 이미지들 백그라운드 프리로드
if (!STATUS_META[product.status]?.soldOut && product.images.length > 1) {
product.images.slice(1).forEach(url => {
const img = new Image();
img.src = url;
});
}
}
observer.unobserve(card);
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.product-card').forEach(card => observer.observe(card));
} else {
// 테이블 렌더링
tableBody.innerHTML = pagedProducts.map((product) => {
const meta = STATUS_META[product.status];
const isSold = meta?.soldOut === true;
const isSelectable = meta?.selectable !== false;
const conditionKey = product.specs.condition;
const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
let conditionDisplay = conditionConfig ? conditionConfig.label : (conditionKey || '상세 설명 참고 ');
let conditionClass = conditionConfig ? conditionConfig.color : 'text-slate-500';
return `
<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}')`}
}">
<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'}
onchange="window.toggleSelectItem('${product.id}')">
</td>
<td class="py-4 px-4 font-semibold ${isSold ? 'line-through text-slate-400' : 'text-slate-900 dark:text-white'}">${product.title}</td>
<td class="py-4 px-4 text-xs break-keep ${conditionClass}">${conditionDisplay}</td>
<td class="py-4 px-4 text-right font-bold text-slate-900 dark:text-white">
${product.price.toLocaleString()}
</td>
<td class="hidden lg:table-cell py-4 px-4 text-center">
<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();
}
}
export function renderPagination() {
const container = document.getElementById('pagination');
if (!container) return;
const totalPages = Math.ceil(state.visibleProducts.length / ITEMS_PER_PAGE);
const { currentPage } = state;
let html = `<button onclick="changePage(${currentPage - 1})" class="size-10 flex items-center justify-center ${currentPage === 1 ? 'invisible' : ''}">
<svg viewBox="0 0 24 24" fill="none" stroke="#64748B" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>`;
for (let i = 1; i <= totalPages; i++) {
html += `<button onclick="changePage(${i})" class="size-10 font-bold rounded-lg ${i === currentPage ? 'bg-primary text-white' : 'text-slate-500'}">${i}</button>`;
}
html += `<button onclick="changePage(${currentPage + 1})" class="size-10 flex items-center justify-center ${currentPage === totalPages ? 'invisible' : ''}">
<svg viewBox="0 0 24 24" fill="none" stroke="#64748B" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
<path d="M9 18l6-6-6-6" />
</svg>
</button>`;
container.innerHTML = html;
}
export function changePage(page) {
state.currentPage = page;
renderProducts(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
/** 모든 필터를 초기 상태로 되돌리는 함수 */
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);
});
}