[260212]
- 태그 AND 검색 도입 - UI/UX 디자인 개선 (칩 & 배지) - 모바일 최적화 및 레이아웃 - 성능 및 리소스 최적화 (Zero-Dependency 아이콘) - 데이터 안정성 및 기타 - 그 외 오류 복구 - Tailwind CDN 제거
This commit is contained in:
@@ -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: '-' },
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
112
scripts/main.js
112
scripts/main.js
@@ -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) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user