Files
zenn.inventory/scripts/productList.js

332 lines
16 KiB
JavaScript
Raw Permalink 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.
/**
* 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;
if (Math.abs(touchX - touchStartX) > 10 || Math.abs(touchY - touchStartY) > 10) {
window.isDragging = true;
}
};
window.handleTouchEnd = function (e) {};
/** 썸네일 호버: 마우스 위치에 따른 이미지 교체 및 인디케이터 업데이트 */
window.handleThumbnailHover = function (e, id) {
if (isTouchDevice) return;
const product = state.visibleProducts.find((p) => p.id === id);
if (!product || !product.images || product.images.length <= 1) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const sectionWidth = rect.width / product.images.length;
const index = Math.floor(x / sectionWidth);
const thumb = document.getElementById(`thumb-${id}`);
const indicators = document.querySelector(`#indicator-${id}`)?.children;
if (thumb && product.images[index]) {
thumb.style.backgroundImage = `url("${product.images[index]}")`;
}
if (indicators) {
Array.from(indicators).forEach((dot, i) => {
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 (indicators) {
Array.from(indicators).forEach((dot, i) => {
dot.classList.toggle('bg-white', i === 0);
dot.classList.toggle('scale-125', i === 0);
dot.classList.toggle('bg-white/40', i !== 0);
});
}
};
/** 개별 체크박스 토글 */
window.toggleSelectItem = function (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 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">
<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>`;
grid.innerHTML = emptyMsg;
grid.classList.remove('hidden');
if (paginationContainer) paginationContainer.innerHTML = '';
}
// ==========================================================================
// 3. 그리드 & 테이블 뷰 상세 (View Details)
// ==========================================================================
/** 그리드 뷰 렌더링 */
function renderGridView(grid, tableWrapper, products) {
// 1. 표시 모드 전환 (테이블 숨기고 그리드 표시)
tableWrapper.classList.add('hidden');
grid.classList.remove('hidden');
// 2. [핵심] 렌더링 시 그리드 클래스 복구 및 보장
grid.classList.add('grid');
const summaryBar = document.getElementById('selection-summary');
if (summaryBar) summaryBar.classList.add('hidden');
grid.innerHTML = products.map((product) => createProductCardHTML(product)).join('');
setupLazyLoading();
}
/** 그리드 개별 카드 HTML */
function createProductCardHTML(product) {
const isSold = STATUS_META[product.status]?.soldOut === true;
const isNonSale = product.status === '미판매';
// const conditionConfig = PRODUCT_CONDITIONS[product.specs?.condition];
// const conditionDisplay = conditionConfig ? conditionConfig.label : product.specs?.condition || '';
const conditionConfig = PRODUCT_CONDITIONS[product.condition];
const conditionDisplay = conditionConfig ? conditionConfig.label : '';
const conditionClass = conditionConfig ? conditionConfig.color : 'text-slate-400';
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}')"
ontouchstart="window.handleTouchStart(event)"
ontouchmove="window.handleTouchMove(event)"
ontouchend="window.handleTouchEnd(event)"
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 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="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
? `
<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>`
: ''
}
</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 ${conditionClass} 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>`;
}
/** 테이블 뷰 렌더링 */
function renderTableView(grid, tableWrapper, products) {
grid.classList.add('hidden');
tableWrapper.classList.remove('hidden');
const tableBody = document.getElementById('product-table-body');
tableBody.innerHTML = products.map((product) => createTableRowHTML(product)).join('');
updateSummary();
}
/** 테이블 개별 행 HTML */
function createTableRowHTML(product) {
const meta = STATUS_META[product.status];
const isSold = meta?.soldOut === true;
const isSelectable = meta?.selectable !== false;
const conditionConfig = PRODUCT_CONDITIONS[product.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}')`} }">
<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.currency || '₩'}${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>`;
}
// ==========================================================================
// 4. 유틸리티 및 페이지네이션 (Utils)
// ==========================================================================
/** 전체 선택 체크박스 상태 동기화 */
function updateSelectAllCheckbox(page) {
const selectAllCheck = document.getElementById('select-all-current');
if (!selectAllCheck) return;
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const currentSelectableItems = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE).filter((p) => STATUS_META[p.status]?.selectable !== false);
selectAllCheck.checked = currentSelectableItems.length > 0 && currentSelectableItems.every((p) => state.selectedIds.has(p.id));
}
/** Intersection Observer를 이용한 썸네일 지연 로딩 */
function setupLazyLoading() {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const card = entry.target;
const productId = card.getAttribute('data-id');
const product = state.visibleProducts.find((p) => p.id === productId);
const thumb = document.getElementById(`thumb-${productId}`);
if (product && thumb) {
thumb.style.backgroundImage = `url("${product.images[0]}")`;
// 마우스 호버를 대비해 나머지 이미지 미리 로드
if (!STATUS_META[product.status]?.soldOut && product.images.length > 1) {
product.images.slice(1).forEach((url) => {
const img = new Image();
img.src = url;
});
}
}
observer.unobserve(card);
}
});
},
{ threshold: 0.1 },
);
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>
</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' });
}