[260204] 역할별 스크립트 분리, 다크모드 추가
This commit is contained in:
@@ -1,60 +1,4 @@
|
||||
const furniture = [
|
||||
{
|
||||
id: 'h2j5k8l1',
|
||||
title: 'Artek Stool 60 Birch',
|
||||
price: 280000,
|
||||
currency: '₩',
|
||||
category: 'Furniture',
|
||||
status: '판매중',
|
||||
description: '알바 알토 디자인, 핀란드 정품, 비치 내츄럴',
|
||||
fullDescription: [
|
||||
'북유럽 디자인의 아이콘, 아르텍 스툴 60입니다.',
|
||||
'편집숍에서 구매한 핀란드 생산 정품입니다.',
|
||||
'',
|
||||
'상태:',
|
||||
'• 상판 미세한 생활 기스 외 찍힘 없음',
|
||||
'• 다리 연결부 흔들림 없이 견고함',
|
||||
'',
|
||||
'인테리어용으로만 두어 실제 앉은 횟수는 적습니다.',
|
||||
],
|
||||
images: ['/images/furniture/h2j5k8l1_01.jpg', '/images/furniture/h2j5k8l1_02.jpg'],
|
||||
customTag: '정품',
|
||||
specs: {
|
||||
purchaseDate: '2023-05',
|
||||
condition: 'Used (Good)',
|
||||
isVerified: true,
|
||||
},
|
||||
tags: ['Chair', 'Finland', 'Iconic'],
|
||||
createdAt: '2025-02-01',
|
||||
updatedAt: '2025-02-01',
|
||||
},
|
||||
{
|
||||
id: 'f9d3s7a4',
|
||||
title: 'Hermer Miller Sayl Chair',
|
||||
price: 650000,
|
||||
currency: '₩',
|
||||
category: 'Furniture',
|
||||
status: '판매중',
|
||||
description: '화이트 프레임, 오션블루 메쉬, 풀기능형',
|
||||
fullDescription: [
|
||||
'허먼밀러 세일 체어 풀옵션 모델입니다.',
|
||||
'포워드 틸팅 및 팔걸이 조절 기능이 모두 포함되어 있습니다.',
|
||||
'',
|
||||
'판매 사유:',
|
||||
'이사 후 방 분위기와 맞지 않아 다른 의자로 교체하게 되었습니다.',
|
||||
'',
|
||||
'좌판 오염 방지를 위해 항상 방석을 깔고 사용했습니다.',
|
||||
],
|
||||
images: ['/images/furniture/f9d3s7a4_01.jpg', '/images/furniture/f9d3s7a4_02.jpg'],
|
||||
customTag: '풀옵션',
|
||||
specs: {
|
||||
purchaseDate: '2022-09',
|
||||
condition: 'Used (Mint)',
|
||||
isVerified: true,
|
||||
},
|
||||
tags: ['Office', 'HermanMiller', 'Chair'],
|
||||
createdAt: '2025-02-03',
|
||||
updatedAt: '2025-02-03',
|
||||
}
|
||||
|
||||
];
|
||||
export default furniture;
|
||||
60
data/tech.js
60
data/tech.js
@@ -1,62 +1,4 @@
|
||||
const tech = [
|
||||
{
|
||||
id: 'k7r2p9xm',
|
||||
title: 'Keychron Q1 Knob Version',
|
||||
price: 185000,
|
||||
currency: '₩',
|
||||
category: 'Tech',
|
||||
status: '판매중',
|
||||
description: '풀윤활 작업 완료, 노브 버전, 블루 스위치',
|
||||
fullDescription: [
|
||||
'키크론 Q1 QMK 커스텀 기계식 키보드입니다.',
|
||||
'모든 스위치와 스테빌라이저 풀윤활 작업을 마쳐 서걱임이 없습니다.',
|
||||
'',
|
||||
'특징:',
|
||||
'• CNC 알루미늄 바디 (네이비 컬러)',
|
||||
'• 사우스페이싱 RGB LED',
|
||||
'• 가스켓 마운트 구조로 쫀득한 타건감',
|
||||
'',
|
||||
'실사용 기간이 짧아 하우징에 스크래치 전혀 없으며,',
|
||||
'풀박스 구성품 그대로 보관 중입니다.',
|
||||
],
|
||||
images: ['/images/tech/k7r2p9xm_01.jpg', '/images/tech/k7r2p9xm_02.jpg'],
|
||||
customTag: '풀윤활완료',
|
||||
specs: {
|
||||
purchaseDate: '2024-01',
|
||||
condition: 'Like New (S-Grade)',
|
||||
isVerified: true,
|
||||
},
|
||||
tags: ['Keyboard', 'Mechanical', 'Custom'],
|
||||
createdAt: '2025-01-15',
|
||||
updatedAt: '2025-01-15',
|
||||
},
|
||||
{
|
||||
id: 'n3m8v2ws',
|
||||
title: 'Sony WH-1000XM5 Black',
|
||||
price: 320000,
|
||||
currency: '₩',
|
||||
category: 'Tech',
|
||||
status: '판매완료',
|
||||
description: '정품 등록 가능, 배터리 상태 최상',
|
||||
fullDescription: [
|
||||
'소니의 최신 노이즈 캔슬링 헤드폰 XM5 블랙입니다.',
|
||||
'실내에서만 가끔 사용하여 이어패드 갈라짐이나 오염 없습니다.',
|
||||
'',
|
||||
'구성품:',
|
||||
'• 헤드폰 본체',
|
||||
'• 전용 하드 케이스',
|
||||
'• 미사용 오디오 케이블 및 충전 케이블',
|
||||
],
|
||||
images: ['/images/tech/n3m8v2ws_01.jpg', '/images/tech/n3m8v2ws_02.jpg', '/images/tech/n3m8v2ws_03.jpg'],
|
||||
customTag: '급매',
|
||||
specs: {
|
||||
purchaseDate: '2023-11',
|
||||
condition: 'Mint (A-Grade)',
|
||||
isVerified: true,
|
||||
},
|
||||
tags: ['Audio', 'Headphones', 'Sony'],
|
||||
createdAt: '2025-01-20',
|
||||
updatedAt: '2025-01-20',
|
||||
}
|
||||
|
||||
];
|
||||
export default tech;
|
||||
187
index.html
187
index.html
@@ -4,7 +4,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>Minimalist Product Catalog</title>
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
|
||||
<title>zenn's inventory</title>
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<!-- Google Fonts: Inter -->
|
||||
@@ -38,12 +39,12 @@
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
/* .material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
} */
|
||||
/* 스크롤바 완전히 숨기기 */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -57,18 +58,18 @@
|
||||
scroll-snap-type: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.filter-chip,
|
||||
/* .filter-chip,
|
||||
.status-chip {
|
||||
@apply px-4 py-2 rounded-full text-sm font-medium transition
|
||||
border border-slate-200 dark:border-slate-700
|
||||
bg-slate-50 dark:bg-slate-800
|
||||
text-slate-700 dark:text-slate-300;
|
||||
}
|
||||
} */
|
||||
|
||||
.filter-chip.active,
|
||||
/* .filter-chip.active,
|
||||
.status-chip.active {
|
||||
@apply bg-primary text-white border-primary;
|
||||
}
|
||||
} */
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background-light dark:bg-background-dark transition-colors duration-200">
|
||||
@@ -83,11 +84,6 @@
|
||||
</div>
|
||||
<h2 class="text-lg font-bold leading-tight tracking-tight">sori.inventory</h2>
|
||||
</div>
|
||||
<!-- <nav class="hidden md:flex items-center gap-8">
|
||||
<a class="text-slate-900 dark:text-slate-100 text-sm font-medium hover:text-primary transition-colors" href="#">Collection</a>
|
||||
<a class="text-slate-500 dark:text-slate-400 text-sm font-medium hover:text-primary transition-colors" href="#">Tech</a>
|
||||
<a class="text-slate-500 dark:text-slate-400 text-sm font-medium hover:text-primary transition-colors" href="#">Archive</a>
|
||||
</nav> -->
|
||||
</div>
|
||||
<div class="flex flex-1 justify-end gap-4 items-center">
|
||||
<label class="hidden sm:flex flex-col min-w-40 !h-10 max-w-64">
|
||||
@@ -98,10 +94,10 @@
|
||||
<input id="search-input" class="form-input flex w-full min-w-0 flex-1 border-none bg-transparent focus:outline-0 focus:ring-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 px-3 text-sm font-normal" placeholder="검색어 입력..." value="" />
|
||||
</div>
|
||||
</label>
|
||||
<!-- <div
|
||||
class="bg-center bg-no-repeat aspect-square bg-cover rounded-full size-9 border border-slate-200 dark:border-slate-700"
|
||||
data-alt="Portrait of a person profile picture"
|
||||
style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuBU9SHb3BY7t3kVP81gp9gRzobkneoAoEWyLZTb8OkxnYI0bwWzk_iPiExHkPUCEq1AnlBnrCBOTB0rRx0qdDYF_KML3YtftYZP4bXiA1IY5Y5u0r16HQpYhzTYmQnnY-k52Z6u1DsCkepdeftke78K45WWvluFzOif6NcHRnfe9_LTOTDEwhD4mtsWJOYI5BenKhudyufV9u1SXblfulp5JXYm4MwqYMGLD4iLzXkjiVH5C-0Ry66mfrxkOhME00b-vfj9luQIVtE");'></div> -->
|
||||
<button id="theme-toggle" class="flex items-center justify-center size-10 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
||||
<span id="theme-toggle-dark-icon" class="material-symbols-outlined hidden">dark_mode</span>
|
||||
<span id="theme-toggle-light-icon" class="material-symbols-outlined hidden">light_mode</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1">
|
||||
@@ -137,47 +133,13 @@
|
||||
|
||||
<div class="px-6 md:px-40 flex justify-center">
|
||||
<div class="flex gap-2 p-1 flex-wrap justify-center bg-slate-100 dark:bg-slate-800 rounded-xl">
|
||||
<!-- <div id="filter-chips" class="flex gap-2 flex-wrap"></div> -->
|
||||
<!-- <div id="status-chips" class="flex gap-2 flex-wrap mt-2"></div> -->
|
||||
<!-- <div class="flex h-9 px-5 shrink-0 items-center justify-center rounded-lg bg-white dark:bg-slate-700 shadow-sm cursor-pointer transition-all">
|
||||
<p class="text-slate-900 dark:text-white text-sm font-semibold">All Items</p>
|
||||
</div>
|
||||
<div class="flex h-9 px-5 shrink-0 items-center justify-center rounded-lg hover:bg-white/50 dark:hover:bg-slate-700/50 cursor-pointer transition-all">
|
||||
<p class="text-slate-500 dark:text-slate-300 text-sm font-medium">Available</p>
|
||||
</div>
|
||||
<div class="flex h-9 px-5 shrink-0 items-center justify-center rounded-lg hover:bg-white/50 dark:hover:bg-slate-700/50 cursor-pointer transition-all">
|
||||
<p class="text-slate-500 dark:text-slate-300 text-sm font-medium">Computing</p>
|
||||
</div>
|
||||
<div class="flex h-9 px-5 shrink-0 items-center justify-center rounded-lg hover:bg-white/50 dark:hover:bg-slate-700/50 cursor-pointer transition-all">
|
||||
<p class="text-slate-500 dark:text-slate-300 text-sm font-medium">Furniture</p>
|
||||
</div>
|
||||
<div class="flex h-9 px-5 shrink-0 items-center justify-center rounded-lg hover:bg-white/50 dark:hover:bg-slate-700/50 cursor-pointer transition-all">
|
||||
<p class="text-slate-500 dark:text-slate-300 text-sm font-medium">Sold</p>
|
||||
</div> -->
|
||||
<!-- filter-chips -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Product Grid -->
|
||||
<div class="px-6 md:px-40 py-10">
|
||||
<div id="product-grid" class="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-8">
|
||||
<!-- Product Item -->
|
||||
<!-- <div class="group flex flex-col gap-4">
|
||||
<div class="relative w-full aspect-[4/5] bg-slate-50 dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm group-hover:shadow-md transition-shadow">
|
||||
<div
|
||||
class="w-full h-full bg-center bg-no-repeat bg-cover transform group-hover:scale-105 transition-transform duration-500"
|
||||
data-alt="Modern smartphone against minimalist background"
|
||||
style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuCJW9hNp1uNfX6KZ1fyWZtxOEYdMb7hzRHsDrey6r63fcoisTxQTWWgL99dRcVZQJO1zZyI-HEU6cTgN-YEiXbqpbRZe0LqqC7bKp38y4i901ZuEnOdNXWyNVGNlfOGKG4z571bvFgX7qeVhH8VqsfP_ueAsCyLd9whzNZ-5KdsnR_nYvxO847cVKDMMFJpu347XFXo_QIaZK-Y4RLJAxa7Vv5E6OSrimV6zedPzkwp8yl7FvHbP-QLZ_lbQWZX-Lw6gTBAHiaoAkY");'></div>
|
||||
<div class="absolute top-3 left-3">
|
||||
<span class="px-2 py-1 text-[10px] uppercase tracking-wider font-bold rounded bg-primary/10 text-primary backdrop-blur-md border border-primary/20">Available</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between items-start">
|
||||
<h3 class="text-slate-900 dark:text-white text-base font-semibold leading-tight group-hover:text-primary transition-colors">iPhone 13 Pro</h3>
|
||||
<p class="text-slate-900 dark:text-white text-base font-bold">$750</p>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm font-normal">Sierra Blue, 256GB, Pristine</p>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
@@ -188,73 +150,78 @@
|
||||
<!-- Modal -->
|
||||
<!-- Backdrop Overlay -->
|
||||
<div id="product-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4 md:p-10 hidden" onclick="if(event.target === this) closeModal()">
|
||||
<!-- Modal Container -->
|
||||
<div class="relative w-full max-w-6xl max-h-[90vh] min-h-[60vh] bg-white dark:bg-background-dark rounded-xl shadow-2xl overflow-hidden flex flex-col md:flex-row border border-gray-200 dark:border-gray-800">
|
||||
<!-- Modal Container: 모바일에서 화면 안에 맞춤 (dvh), 이미지 영역 높이 제한 -->
|
||||
<div class="relative w-full max-w-6xl max-h-[calc(100dvh-2rem)] md:max-h-[90vh] min-h-0 md:min-h-[60vh] bg-white dark:bg-background-dark rounded-xl shadow-2xl overflow-hidden flex flex-col md:flex-row border border-gray-200 dark:border-gray-800">
|
||||
<!-- Close Button -->
|
||||
<button onclick="closeModal()" class="absolute top-4 right-4 z-50 flex items-center justify-center w-10 h-10 rounded-full bg-white/80 dark:bg-gray-800/80 backdrop-blur hover:bg-white dark:hover:bg-gray-700 transition-colors shadow-sm">
|
||||
<span class="material-symbols-outlined text-gray-900 dark:text-white">close</span>
|
||||
</button>
|
||||
<!-- Left: Gallery Section -->
|
||||
<div class="w-full md:w-3/5 bg-gray-50 dark:bg-gray-900 flex flex-col relative overflow-hidden">
|
||||
<!-- Main Carousel -->
|
||||
<div id="modal-main-carousel-container" class="flex-1 overflow-x-auto no-scrollbar flex items-center ml-0 lg:ml-24">
|
||||
<div id="modal-main-carousel" class="flex h-full w-full">
|
||||
<!-- Image -->
|
||||
<div class="flex-shrink-0 w-full h-full snap-center flex items-center justify-center p-4">
|
||||
<div
|
||||
class="w-full h-full bg-center bg-no-repeat bg-contain rounded-lg"
|
||||
data-alt="Front view of Sony WH-1000XM5 headphones in black"
|
||||
style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuCOlUeqiUla7LkapcfcrnlTRF-bvdnD2tGTS8zjFgnxr6MNEwmTehjldThx6SCikcIP4-uUK4fbm1EFxj7XKKckJnQr8AunGsdWdgOlH5Fex0ML3uFw5fCnp997fuXQa2ceXdqXfiGDM17AdqB7tx9kxLoxEUXDxROSiH7I2-KFOweIR1-dBljEbeih1fQ1y_HACne_STdXKwGfrkPsHOXz-Ls7-MBW8uD1i5Mz64d3I8z4sO036qIlTNd2Iz4pqPxp7ucNkhrtQoc");'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Carousel Navigation -->
|
||||
<div id="modal-dots" class="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
||||
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-700"></div>
|
||||
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<!-- Side Thumbnails (Visible on Large Screens) -->
|
||||
<div id="modal-thumbnails" class="hidden lg:flex absolute left-4 top-1/2 -translate-y-1/2 flex-col gap-3 max-h-[70vh] overflow-y-auto no-scrollbar pr-1"></div>
|
||||
</div>
|
||||
<!-- Right: Details Section -->
|
||||
<div class="w-full md:w-2/5 flex flex-col h-full bg-white dark:bg-background-dark p-6 md:p-10 overflow-y-auto">
|
||||
<!-- Category/Badge -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span id="modal-category" class="px-2.5 py-1 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs font-bold uppercase tracking-wider"></span>
|
||||
<span id="modal-status" class="px-2.5 py-1 rounded-lg bg-green-100 text-green-700 text-xs font-bold uppercase tracking-wider"></span>
|
||||
<span id="modal-custom-tag" class="hidden px-2.5 py-1 rounded-lg text-xs font-bold uppercase tracking-wider"></span>
|
||||
</div>
|
||||
<!-- Title -->
|
||||
<h1 id="modal-title" class="text-[#111418] dark:text-white text-3xl font-black leading-tight tracking-tight mb-2">Sony WH-1000XM5 Noise Canceling Headphones</h1>
|
||||
<!-- Price -->
|
||||
<p id="modal-price" class="text-[#111418] dark:text-white text-4xl font-bold mb-8">₩320,000</p>
|
||||
<!-- Spec List -->
|
||||
<div class="space-y-0 mb-8">
|
||||
<div id="modal-date-row" class="flex items-center justify-between py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<span class="text-gray-500 dark:text-gray-400 text-sm font-medium uppercase tracking-tight">구매일자</span>
|
||||
<span id="modal-date" class="text-[#111418] dark:text-white text-sm font-semibold">October 2023</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<span class="text-gray-500 dark:text-gray-400 text-sm font-medium uppercase tracking-tight">제품 상태</span>
|
||||
<div id="modal-condition-row" class="flex items-center justify-between border-t border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg id="modal-verified-icon" class="w-5 h-5 text-primary hidden" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11l3 3L22 4"></path>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>
|
||||
<span id="modal-condition" class="text-[#111418] dark:text-white text-sm font-semibold"></span>
|
||||
<!-- Left: Gallery Section (모바일: 상하좌우 패딩으로 이미지 영역 확실히) -->
|
||||
<div class="w-full md:w-3/5 flex flex-col relative overflow-hidden min-h-0 max-h-[40dvh] md:max-h-none p-3 md:p-0">
|
||||
<!-- 캐러셀 래퍼: dots는 여기 기준으로 고정, 스크롤은 안쪽만 -->
|
||||
<div class="relative flex-1 min-h-0 flex ml-0 md:ml-24 rounded-md">
|
||||
<div id="modal-main-carousel-container" class="flex-1 min-h-0 overflow-x-auto no-scrollbar flex items-center justify-center">
|
||||
<div id="modal-main-carousel" class="flex h-full w-full">
|
||||
<!-- Image -->
|
||||
<div class="flex-shrink-0 w-full h-full snap-center flex items-center justify-center p-4">
|
||||
<div
|
||||
class="w-full h-full bg-center bg-no-repeat bg-contain rounded-lg"
|
||||
data-alt="Front view of Sony WH-1000XM5 headphones in black"
|
||||
style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuCOlUeqiUla7LkapcfcrnlTRF-bvdnD2tGTS8zjFgnxr6MNEwmTehjldThx6SCikcIP4-uUK4fbm1EFxj7XKKckJnQr8AunGsdWdgOlH5Fex0ML3uFw5fCnp997fuXQa2ceXdqXfiGDM17AdqB7tx9kxLoxEUXDxROSiH7I2-KFOweIR1-dBljEbeih1fQ1y_HACne_STdXKwGfrkPsHOXz-Ls7-MBW8uD1i5Mz64d3I8z4sO036qIlTNd2Iz4pqPxp7ucNkhrtQoc");'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Dots: 스크롤 영역 밖에 둬서 슬라이드해도 고정 -->
|
||||
<div id="modal-dots" class="absolute bottom-6 left-1/2 -translate-x-1/2 z-10 flex md:hidden items-center gap-2 pointer-events-none">
|
||||
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
||||
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-700"></div>
|
||||
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="mb-10 min-h-80">
|
||||
<h3 class="text-[#111418] dark:text-white text-sm font-bold uppercase tracking-widest mb-3">제품 설명</h3>
|
||||
<p id="modal-desc" class="text-gray-600 dark:text-gray-300 text-base leading-relaxed"></p>
|
||||
<!-- Side Thumbnails (Visible on Large Screens) -->
|
||||
<div id="modal-thumbnails" class="hidden md:flex absolute left-4 top-1/2 -translate-y-1/2 flex-col gap-3 max-h-[70vh] overflow-y-auto no-scrollbar pr-1"></div>
|
||||
</div>
|
||||
<!-- Right: Details Section (스크롤 영역 + 하단 버튼 고정) -->
|
||||
<div class="w-full md:w-2/5 flex flex-col min-h-0 flex-1 bg-white dark:bg-background-dark overflow-hidden">
|
||||
<!-- 스크롤 영역: 뱃지~설명 (스크롤바 숨김, 설명 잘림 방지) -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto no-scrollbar p-4 sm:p-6 md:p-8 lg:p-10">
|
||||
<!-- Category/Badge: 배지 단위로만 줄바꿈, 중간 사이즈 대응 -->
|
||||
<div class="flex flex-wrap items-center gap-1.5 sm:gap-2 mb-3 sm:mb-4">
|
||||
<span
|
||||
id="modal-category"
|
||||
class="inline-flex shrink-0 px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-md sm:rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-[10px] sm:text-[11px] md:text-xs font-bold uppercase tracking-wider whitespace-nowrap"></span>
|
||||
<span id="modal-status" class="inline-flex shrink-0 px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-md sm:rounded-lg bg-green-100 text-green-700 text-[10px] sm:text-[11px] md:text-xs font-bold uppercase tracking-wider whitespace-nowrap"></span>
|
||||
<span id="modal-custom-tag" class="hidden inline-flex shrink-0 px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-md sm:rounded-lg text-[10px] sm:text-[11px] md:text-xs font-bold uppercase tracking-wider whitespace-nowrap"></span>
|
||||
</div>
|
||||
<!-- Title: 중간 사이즈까지 단계별 크기 + 자연스러운 줄바꿈 -->
|
||||
<h1 id="modal-title" class="text-[#111418] dark:text-white text-lg sm:text-xl md:text-2xl lg:text-3xl font-black leading-tight tracking-tight mb-2 break-words hyphens-auto">Sony WH-1000XM5 Noise Canceling Headphones</h1>
|
||||
<!-- Price: 중간 사이즈 대응 -->
|
||||
<p id="modal-price" class="text-[#111418] dark:text-white text-xl sm:text-2xl md:text-3xl lg:text-4xl font-bold mb-6 sm:mb-8">₩320,000</p>
|
||||
<!-- Spec List: 라벨/값 줄바꿈 정리 -->
|
||||
<div class="space-y-0 mb-6 sm:mb-8">
|
||||
<div id="modal-date-row" class="flex items-center justify-between gap-3 py-3 sm:py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<span class="shrink-0 text-gray-500 dark:text-gray-400 text-xs sm:text-sm font-medium uppercase tracking-tight">구매일자</span>
|
||||
<span id="modal-date" class="text-[#111418] dark:text-white text-xs sm:text-sm font-semibold text-right break-words min-w-0">October 2023</span>
|
||||
</div>
|
||||
<div class="flex items-start sm:items-center justify-between gap-3 py-3 sm:py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<span class="shrink-0 text-gray-500 dark:text-gray-400 text-xs sm:text-sm font-medium uppercase tracking-tight">제품 상태</span>
|
||||
<div id="modal-condition-row" class="flex items-center justify-end gap-1.5 min-w-0">
|
||||
<svg id="modal-verified-icon" class="w-4 h-4 sm:w-5 sm:h-5 text-primary shrink-0 hidden" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11l3 3L22 4"></path>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>
|
||||
<span id="modal-condition" class="text-[#111418] dark:text-white text-xs sm:text-sm font-semibold text-right break-words"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-[#111418] dark:text-white text-xs sm:text-sm font-bold uppercase tracking-widest mb-2 sm:mb-3">제품 설명</h3>
|
||||
<p id="modal-desc" class="text-gray-600 dark:text-gray-300 text-sm sm:text-base leading-relaxed break-words"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer / CTA -->
|
||||
<div class="mt-auto pt-6 border-t border-gray-100 dark:border-gray-800">
|
||||
<!-- Footer / CTA: 항상 하단 고정, 버튼이 설명 위로 겹치지 않음 -->
|
||||
<div class="flex-shrink-0 pt-4 pb-6 px-4 sm:px-6 md:px-8 lg:px-10 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-background-dark">
|
||||
<button id="copy-link-btn" class="w-full flex items-center justify-center gap-2 bg-slate-900 dark:bg-white dark:text-slate-900 text-white font-bold py-4 px-6 rounded-xl transition-all shadow-lg">
|
||||
<span class="material-symbols-outlined">link</span>
|
||||
<span id="copy-btn-text">상품 링크 복사하기</span>
|
||||
@@ -277,6 +244,6 @@
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/scripts/app.js"></script>
|
||||
<script type="module" src="/scripts/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
660
scripts/app.js
660
scripts/app.js
@@ -1,660 +0,0 @@
|
||||
// scripts/app.js 상단
|
||||
import products from '../data/index.js';
|
||||
|
||||
// 이제 products는 모든 카테고리가 합쳐지고 날짜순으로 정렬된 상태입니다.
|
||||
console.log('Total products loaded:', products.length);
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
let currentPage = 1;
|
||||
let activeCategories = new Set(['All']);
|
||||
let visibleProducts = products;
|
||||
let searchKeyword = '';
|
||||
|
||||
const VISIBILITY_CONFIG = {
|
||||
showUnlisted: false, // 🔥 미판매 노출 여부
|
||||
showSold: true, // 🔥 판매완료 노출 여부
|
||||
};
|
||||
|
||||
const STATUS_META = {
|
||||
미판매: {
|
||||
selectable: false,
|
||||
defaultVisible: false,
|
||||
soldOut: false,
|
||||
},
|
||||
판매예정: {
|
||||
selectable: true,
|
||||
defaultVisible: true,
|
||||
soldOut: false,
|
||||
},
|
||||
판매중: {
|
||||
selectable: true,
|
||||
defaultVisible: true,
|
||||
soldOut: false,
|
||||
},
|
||||
판매완료: {
|
||||
selectable: true,
|
||||
defaultVisible: false,
|
||||
soldOut: true,
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{
|
||||
key: '판매중',
|
||||
label: '판매중',
|
||||
defaultActive: true,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: '판매예정',
|
||||
label: '판매 예정',
|
||||
defaultActive: true,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: '미판매',
|
||||
label: '미판매',
|
||||
defaultActive: false,
|
||||
visible: VISIBILITY_CONFIG.showUnlisted,
|
||||
},
|
||||
{
|
||||
key: '판매완료',
|
||||
label: '판매완료',
|
||||
defaultActive: false,
|
||||
visible: VISIBILITY_CONFIG.showSold,
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_ORDER = {
|
||||
판매중: 0,
|
||||
판매예정: 1,
|
||||
미판매: 2,
|
||||
판매완료: 3, // 🔥 항상 맨 뒤
|
||||
};
|
||||
|
||||
const STATUS_COLOR = {
|
||||
판매중: 'bg-primary/10 text-primary border-primary/30',
|
||||
판매예정: 'bg-amber-400/10 text-amber-600 border-amber-400/30',
|
||||
판매완료: 'bg-slate-400/10 text-slate-500 border-slate-400/30',
|
||||
미판매: 'bg-slate-200/10 text-slate-400 border-slate-300/30',
|
||||
};
|
||||
|
||||
let activeStatuses = new Set(
|
||||
Object.entries(STATUS_META)
|
||||
.filter(([_, meta]) => meta.defaultVisible)
|
||||
.map(([status]) => status),
|
||||
);
|
||||
|
||||
// openModal 함수 내부에 추가하거나 전역으로 설정
|
||||
const copyBtn = document.getElementById('copy-link-btn');
|
||||
const copyBtnText = document.getElementById('copy-btn-text');
|
||||
|
||||
copyBtn.onclick = () => {
|
||||
// 현재 도메인 + 제품 ID 쿼리 조합
|
||||
const shareUrl = `${window.location.origin}${window.location.pathname}?id=${product.id}`;
|
||||
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
copyBtnText.textContent = '링크가 복사되었습니다!';
|
||||
copyBtn.classList.replace('bg-slate-900', 'bg-green-600');
|
||||
|
||||
setTimeout(() => {
|
||||
copyBtnText.textContent = '상품 링크 복사하기';
|
||||
copyBtn.classList.replace('bg-green-600', 'bg-slate-900');
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
function getStatusChipClass(status, isActive) {
|
||||
const base = STATUS_COLOR[status] ?? '';
|
||||
|
||||
if (isActive) {
|
||||
return `
|
||||
${base}
|
||||
opacity-100
|
||||
shadow-sm
|
||||
`;
|
||||
}
|
||||
|
||||
// 🔥 비활성
|
||||
return `
|
||||
bg-slate-50
|
||||
text-slate-400
|
||||
border-slate-200
|
||||
opacity-30
|
||||
grayscale
|
||||
hover:opacity-50
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStatusChips() {
|
||||
const container = document.getElementById('status-chips');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
STATUS_FILTERS.filter((f) => f.visible).forEach(({ key, label }) => {
|
||||
const isActive = activeStatuses.has(key);
|
||||
|
||||
const chip = document.createElement('button');
|
||||
chip.className = `
|
||||
status-chip
|
||||
px-4 py-2
|
||||
rounded-full
|
||||
text-sm font-medium
|
||||
transition-all duration-200
|
||||
border
|
||||
${getStatusChipClass(key, isActive)}
|
||||
`;
|
||||
|
||||
chip.textContent = label;
|
||||
chip.onclick = () => toggleStatusFilter(key);
|
||||
|
||||
container.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStatusFilter(status) {
|
||||
if (activeStatuses.has(status)) {
|
||||
activeStatuses.delete(status);
|
||||
} else {
|
||||
activeStatuses.add(status);
|
||||
}
|
||||
|
||||
// 최소 1개는 유지
|
||||
if (activeStatuses.size === 0) {
|
||||
STATUS_FILTERS.filter((f) => f.defaultActive).forEach((f) => activeStatuses.add(f.key));
|
||||
}
|
||||
|
||||
applyFilters();
|
||||
renderStatusChips();
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('search-input');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
searchKeyword = e.target.value.trim().toLowerCase();
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
currentPage = 1;
|
||||
|
||||
visibleProducts = products
|
||||
.filter((product) => {
|
||||
// 🔒 미판매 강제 차단
|
||||
if (product.status === '미판매' && !VISIBILITY_CONFIG.showUnlisted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🔒 판매완료 기본 숨김
|
||||
if (product.status === '판매완료' && !VISIBILITY_CONFIG.showSold) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const statusMatch = activeStatuses.has(product.status);
|
||||
const categoryMatch = activeCategories.has('All') || activeCategories.has(product.category);
|
||||
const searchMatch = searchKeyword === '' || product.title.toLowerCase().includes(searchKeyword);
|
||||
|
||||
return statusMatch && categoryMatch && searchMatch;
|
||||
})
|
||||
// 🔥 여기서 정렬
|
||||
.sort((a, b) => {
|
||||
const aOrder = STATUS_ORDER[a.status] ?? 999;
|
||||
const bOrder = STATUS_ORDER[b.status] ?? 999;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
renderProducts(currentPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. 상품 목록 렌더링
|
||||
*/
|
||||
export function renderProducts(page) {
|
||||
const grid = document.getElementById('product-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
|
||||
const startIndex = (page - 1) * ITEMS_PER_PAGE;
|
||||
const pagedProducts = visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
|
||||
pagedProducts.forEach((product) => {
|
||||
const isSold = STATUS_META[product.status]?.soldOut === true;
|
||||
const cardHtml = `
|
||||
<div class="group flex flex-col gap-4 cursor-pointer" onclick="openModal('${product.id}')">
|
||||
<div class="relative w-full aspect-[4/5] bg-slate-50 dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm group-hover:shadow-md transition-shadow">
|
||||
<div class="w-full h-full bg-center bg-no-repeat bg-cover transform ${isSold ? 'grayscale opacity-80' : 'group-hover:scale-105'} transition-transform duration-500"
|
||||
style="background-image: url('${product.images[0]}')"></div>
|
||||
<div class="absolute top-3 left-3">
|
||||
<span class="px-2 py-1 text-[10px] uppercase tracking-wider font-bold rounded ${isSold ? 'bg-slate-900/10 text-slate-500' : 'bg-primary/10 text-primary'} backdrop-blur-md border border-primary/20">
|
||||
${product.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between items-start">
|
||||
<h3 class="text-slate-900 dark:text-white text-base font-semibold ${isSold ? 'line-through text-slate-400' : ''}">${product.title}</h3>
|
||||
<p class="text-slate-900 dark:text-white text-base font-bold text-nowrap">${product.currency}${product.price.toLocaleString()}</p>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm font-normal">${product.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
grid.insertAdjacentHTML('beforeend', cardHtml);
|
||||
});
|
||||
|
||||
renderPagination();
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 모달 열기 및 데이터 채우기
|
||||
*/
|
||||
window.openModal = (id) => {
|
||||
const product = products.find((p) => p.id === id);
|
||||
if (!product) return;
|
||||
|
||||
const modal = document.getElementById('product-modal');
|
||||
const images = product.images;
|
||||
|
||||
// --- 1. 이미지 및 UI 초기화 로직 ---
|
||||
const loopImages = [images[images.length - 1], ...images, images[0]];
|
||||
const mainImagesHtml = loopImages
|
||||
.map(
|
||||
(img) => `
|
||||
<div class="flex-shrink-0 w-full h-full snap-center flex items-center justify-center p-4 select-none">
|
||||
<img src="${img}" draggable="false" class="max-w-full max-h-full object-contain pointer-events-none shadow-sm rounded-lg">
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const thumbnailsHtml = product.images
|
||||
.map(
|
||||
(img, idx) => `
|
||||
<div onclick="scrollToImage(${idx})"
|
||||
class="modal-thumb-item size-16 rounded-lg border-2 ${idx === 0 ? 'border-primary' : 'border-transparent'}
|
||||
bg-cover bg-center overflow-hidden cursor-pointer ${idx === 0 ? 'opacity-100' : 'opacity-70'}
|
||||
hover:opacity-100 transition-all flex-shrink-0"
|
||||
style="background-image: url('${img}');"></div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const dotsHtml = product.images
|
||||
.map(
|
||||
(_, idx) => `
|
||||
<div class="modal-dot-item ${idx === 0 ? 'w-4 bg-primary' : 'w-2 bg-gray-300 dark:bg-gray-700'} h-2 rounded-full transition-all"></div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
// --- 2. 데이터 주입 ---
|
||||
document.getElementById('modal-main-carousel').innerHTML = mainImagesHtml;
|
||||
document.getElementById('modal-thumbnails').innerHTML = thumbnailsHtml;
|
||||
document.getElementById('modal-dots').innerHTML = dotsHtml;
|
||||
document.getElementById('modal-title').textContent = product.title;
|
||||
document.getElementById('modal-price').textContent = `${product.currency}${product.price.toLocaleString()}`;
|
||||
|
||||
// 카테고리 및 상태
|
||||
const modalCategory = document.getElementById('modal-category');
|
||||
if (modalCategory) modalCategory.textContent = product.category;
|
||||
|
||||
const modalStatus = document.getElementById('modal-status');
|
||||
if (modalStatus) {
|
||||
modalStatus.textContent = product.status;
|
||||
const statusStyles = {
|
||||
판매중: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
판매예정: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
판매완료: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
미판매: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400',
|
||||
};
|
||||
modalStatus.className = 'px-2.5 py-1 rounded-lg text-xs font-bold uppercase tracking-wider ' + (statusStyles[product.status] || statusStyles['미판매']);
|
||||
}
|
||||
|
||||
// 커스텀 태그
|
||||
const customTagElement = document.getElementById('modal-custom-tag');
|
||||
if (product.customTag?.trim()) {
|
||||
customTagElement.textContent = product.customTag;
|
||||
customTagElement.classList.remove('hidden');
|
||||
} else {
|
||||
customTagElement.classList.add('hidden');
|
||||
}
|
||||
|
||||
// 상세 설명
|
||||
const modalDesc = document.getElementById('modal-desc');
|
||||
if (modalDesc) {
|
||||
modalDesc.innerHTML = Array.isArray(product.fullDescription) ? product.fullDescription.join('<br>') : product.fullDescription || '';
|
||||
}
|
||||
|
||||
// --- 3. [핵심] 링크 복사 버튼 이벤트 바인딩 ---
|
||||
const copyBtn = document.getElementById('copy-link-btn');
|
||||
const copyBtnText = document.getElementById('copy-btn-text');
|
||||
if (copyBtn) {
|
||||
copyBtn.onclick = () => {
|
||||
const shareUrl = `${window.location.origin}${window.location.pathname}?id=${product.id}`;
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
if (copyBtnText) copyBtnText.textContent = '링크가 복사되었습니다!';
|
||||
copyBtn.classList.add('!bg-green-600');
|
||||
setTimeout(() => {
|
||||
if (copyBtnText) copyBtnText.textContent = '상품 링크 복사하기';
|
||||
copyBtn.classList.remove('!bg-green-600');
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// --- 4. 모달 활성화 및 초기 위치 설정 ---
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const container = document.getElementById('modal-main-carousel-container');
|
||||
container.style.scrollBehavior = 'auto';
|
||||
container.scrollLeft = container.clientWidth;
|
||||
|
||||
// 캐러셀 초기화
|
||||
initBetterCarousel(container, images.length);
|
||||
};
|
||||
|
||||
/**
|
||||
* 모달 닫기 (URL 정리 기능 포함)
|
||||
*/
|
||||
window.closeModal = () => {
|
||||
document.getElementById('product-modal').classList.add('hidden');
|
||||
document.body.style.overflow = 'auto';
|
||||
|
||||
const cleanUrl = window.location.origin + window.location.pathname;
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
};
|
||||
|
||||
function initBetterCarousel(container, originalLength) {
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startScroll = 0;
|
||||
let startTime = 0;
|
||||
|
||||
const width = () => container.clientWidth;
|
||||
|
||||
container.addEventListener('mousedown', start);
|
||||
container.addEventListener('touchstart', start, { passive: true });
|
||||
|
||||
function start(e) {
|
||||
isDragging = true;
|
||||
startX = e.touches ? e.touches[0].pageX : e.pageX;
|
||||
startScroll = container.scrollLeft;
|
||||
startTime = Date.now();
|
||||
}
|
||||
|
||||
container.addEventListener('mousemove', move);
|
||||
container.addEventListener('touchmove', move, { passive: false });
|
||||
|
||||
function move(e) {
|
||||
if (!isDragging) return;
|
||||
const x = e.touches ? e.touches[0].pageX : e.pageX;
|
||||
container.scrollLeft = startScroll - (x - startX);
|
||||
}
|
||||
|
||||
container.addEventListener('mouseup', end);
|
||||
container.addEventListener('mouseleave', end);
|
||||
container.addEventListener('touchend', end);
|
||||
|
||||
function end(e) {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
|
||||
const delta = container.scrollLeft - startScroll;
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
const direction = Math.abs(delta) > width() * 0.1 || elapsed < 200 ? (delta > 0 ? 1 : -1) : 0;
|
||||
|
||||
let index = Math.round(startScroll / width()) + direction;
|
||||
|
||||
container.style.scrollBehavior = 'smooth';
|
||||
container.scrollTo({ left: index * width() });
|
||||
|
||||
// 무한 루프 보정
|
||||
setTimeout(() => {
|
||||
container.style.scrollBehavior = 'auto';
|
||||
if (index === 0) {
|
||||
container.scrollLeft = width() * originalLength;
|
||||
}
|
||||
if (index === originalLength + 1) {
|
||||
container.scrollLeft = width();
|
||||
}
|
||||
syncModalUI(originalLength);
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 슬라이드와 UI(Dots, Thumbs) 동기화
|
||||
*/
|
||||
function syncModalUI(originalLength) {
|
||||
const container = document.getElementById('modal-main-carousel-container');
|
||||
const index = getRealIndex(container, originalLength);
|
||||
|
||||
document.querySelectorAll('.modal-thumb-item').forEach((t, i) => {
|
||||
t.classList.toggle('border-primary', i === index);
|
||||
t.classList.toggle('opacity-100', i === index);
|
||||
t.classList.toggle('opacity-70', i !== index);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.modal-dot-item').forEach((d, i) => {
|
||||
d.classList.toggle('bg-primary', i === index);
|
||||
d.classList.toggle('w-4', i === index);
|
||||
d.classList.toggle('bg-gray-300', i !== index);
|
||||
d.classList.toggle('w-2', i !== index);
|
||||
});
|
||||
|
||||
ensureThumbnailVisible(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 모달 내 이미지 스크롤 및 UI 동기화
|
||||
*/
|
||||
window.scrollToImage = (index) => {
|
||||
const container = document.getElementById('modal-main-carousel-container');
|
||||
if (!container) return;
|
||||
|
||||
container.scrollTo({
|
||||
left: container.clientWidth * (index + 1), // 🔥 중요
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 5. 기타 (페이지네이션, 모달 닫기)
|
||||
*/
|
||||
function renderPagination() {
|
||||
const container = document.getElementById('pagination');
|
||||
if (!container) return;
|
||||
const totalPages = Math.ceil(visibleProducts.length / ITEMS_PER_PAGE);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
window.changePage = (page) => {
|
||||
currentPage = page;
|
||||
renderProducts(currentPage);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// 초기 실행
|
||||
document.addEventListener('DOMContentLoaded', () => renderProducts(currentPage));
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
|
||||
const modal = document.getElementById('product-modal');
|
||||
if (!modal || modal.classList.contains('hidden')) return;
|
||||
|
||||
closeModal();
|
||||
});
|
||||
|
||||
const thumbnailContainer = document.getElementById('modal-thumbnails');
|
||||
|
||||
function ensureThumbnailVisible(index) {
|
||||
const container = document.getElementById('modal-thumbnails');
|
||||
if (!container) return;
|
||||
|
||||
const thumbs = container.querySelectorAll('.modal-thumb-item');
|
||||
const active = thumbs[index];
|
||||
if (!active) return;
|
||||
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const tRect = active.getBoundingClientRect();
|
||||
|
||||
const isVisible = tRect.top >= cRect.top && tRect.bottom <= cRect.bottom;
|
||||
|
||||
if (!isVisible) {
|
||||
active.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getRealIndex(container, originalLength) {
|
||||
let rawIndex = Math.round(container.scrollLeft / container.clientWidth);
|
||||
let index = rawIndex - 1; // 클론 보정
|
||||
|
||||
if (index < 0) index = originalLength - 1;
|
||||
if (index >= originalLength) index = 0;
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
function getCategories(products) {
|
||||
return ['All', ...new Set(products.map((p) => p.category))];
|
||||
}
|
||||
|
||||
function renderCategoryChips(products) {
|
||||
const container = document.getElementById('filter-chips');
|
||||
if (!container) return;
|
||||
|
||||
const categories = ['All', ...new Set(products.map((p) => p.category))];
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
categories.forEach((cat) => {
|
||||
const isActive = activeCategories.has(cat);
|
||||
|
||||
const chip = document.createElement('button');
|
||||
chip.className = `
|
||||
filter-chip px-4 py-2 rounded-full text-sm font-medium transition
|
||||
border
|
||||
${isActive ? 'bg-primary text-white border-primary' : 'bg-slate-50 text-slate-600 border-slate-200'}
|
||||
`;
|
||||
chip.textContent = cat;
|
||||
chip.dataset.category = cat;
|
||||
|
||||
chip.onclick = () => {
|
||||
toggleCategory(cat);
|
||||
};
|
||||
|
||||
container.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCategory(category) {
|
||||
if (category === 'All') {
|
||||
activeCategories.clear();
|
||||
activeCategories.add('All');
|
||||
} else {
|
||||
activeCategories.delete('All');
|
||||
activeCategories.has(category) ? activeCategories.delete(category) : activeCategories.add(category);
|
||||
|
||||
if (activeCategories.size === 0) {
|
||||
activeCategories.add('All');
|
||||
}
|
||||
}
|
||||
|
||||
renderCategoryChips(products);
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function bindCategoryFilter(products) {
|
||||
const chips = document.querySelectorAll('.filter-chip');
|
||||
|
||||
chips.forEach((chip) => {
|
||||
chip.addEventListener('click', () => {
|
||||
const category = chip.dataset.category;
|
||||
|
||||
if (category === 'All') {
|
||||
activeCategories.clear();
|
||||
activeCategories.add('All');
|
||||
} else {
|
||||
activeCategories.delete('All');
|
||||
|
||||
if (activeCategories.has(category)) {
|
||||
activeCategories.delete(category);
|
||||
} else {
|
||||
activeCategories.add(category);
|
||||
}
|
||||
|
||||
// 아무 것도 없으면 All로 복귀
|
||||
if (activeCategories.size === 0) {
|
||||
activeCategories.add('All');
|
||||
}
|
||||
}
|
||||
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
renderCategoryChips(products);
|
||||
bindCategoryFilter(products);
|
||||
// updateChipUI();
|
||||
|
||||
// 상태 필터 (정책 기반)
|
||||
renderStatusChips();
|
||||
|
||||
// 🔥 최초 필터 적용 (이게 첫 렌더)
|
||||
applyFilters();
|
||||
|
||||
// 초기 실행 시 호출
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderProducts(currentPage);
|
||||
checkUrlAndOpenModal();
|
||||
});
|
||||
|
||||
function checkUrlAndOpenModal() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const productId = params.get('id'); // URL에서 가져온 ID (문자열)
|
||||
|
||||
if (productId) {
|
||||
// 데이터의 id와 URL의 id를 모두 문자열로 변환하여 비교
|
||||
const product = products.find((p) => String(p.id) === productId);
|
||||
|
||||
if (product) {
|
||||
// DOM 렌더링 시간을 고려해 약간의 지연 후 모달 오픈
|
||||
setTimeout(() => openModal(product.id), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [최종 ID 생성기] 매번 완전히 새로운 8자리 난수 출력
|
||||
const newId = Math.random().toString(36).substring(2, 10);
|
||||
|
||||
console.log(`%c[NEW ID for data.js]: ${newId}`, 'color: #137fec; font-weight: bold; border: 1px solid #137fec; padding: 2px 5px; border-radius: 4px;');
|
||||
116
scripts/carousel.js
Normal file
116
scripts/carousel.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/** 모달 내 이미지 캐러셀 (드래그·스와이프·인덱스 동기화) */
|
||||
export function getRealIndex(container, originalLength) {
|
||||
let rawIndex = Math.round(container.scrollLeft / container.clientWidth);
|
||||
let index = rawIndex - 1;
|
||||
if (index < 0) index = originalLength - 1;
|
||||
if (index >= originalLength) index = 0;
|
||||
return index;
|
||||
}
|
||||
|
||||
export function ensureThumbnailVisible(index) {
|
||||
const container = document.getElementById('modal-thumbnails');
|
||||
if (!container) return;
|
||||
const thumbs = container.querySelectorAll('.modal-thumb-item');
|
||||
const active = thumbs[index];
|
||||
if (!active) return;
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const tRect = active.getBoundingClientRect();
|
||||
const isVisible = tRect.top >= cRect.top && tRect.bottom <= cRect.bottom;
|
||||
if (!isVisible) {
|
||||
active.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
export function syncModalUI(originalLength) {
|
||||
const container = document.getElementById('modal-main-carousel-container');
|
||||
if (!container) return;
|
||||
const index = getRealIndex(container, originalLength);
|
||||
|
||||
document.querySelectorAll('.modal-thumb-item').forEach((t, i) => {
|
||||
t.classList.toggle('border-primary', i === index);
|
||||
t.classList.toggle('opacity-100', i === index);
|
||||
t.classList.toggle('opacity-70', i !== index);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.modal-dot-item').forEach((d, i) => {
|
||||
d.classList.toggle('bg-primary', i === index);
|
||||
d.classList.toggle('w-4', i === index);
|
||||
d.classList.toggle('bg-gray-300', i !== index);
|
||||
d.classList.toggle('w-2', i !== index);
|
||||
});
|
||||
|
||||
ensureThumbnailVisible(index);
|
||||
}
|
||||
|
||||
export function initBetterCarousel(container, originalLength) {
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startScroll = 0;
|
||||
let startTime = 0;
|
||||
const width = () => container.clientWidth;
|
||||
|
||||
container.addEventListener('mousedown', start);
|
||||
container.addEventListener('touchstart', start, { passive: true });
|
||||
|
||||
function start(e) {
|
||||
isDragging = true;
|
||||
startX = e.touches ? e.touches[0].pageX : e.pageX;
|
||||
startScroll = container.scrollLeft;
|
||||
startTime = Date.now();
|
||||
}
|
||||
|
||||
container.addEventListener('mousemove', move);
|
||||
container.addEventListener('touchmove', move, { passive: false });
|
||||
|
||||
function move(e) {
|
||||
if (!isDragging) return;
|
||||
const x = e.touches ? e.touches[0].pageX : e.pageX;
|
||||
container.scrollLeft = startScroll - (x - startX);
|
||||
}
|
||||
|
||||
container.addEventListener('mouseup', end);
|
||||
container.addEventListener('mouseleave', end);
|
||||
container.addEventListener('touchend', end);
|
||||
|
||||
function end(e) {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
const w = width();
|
||||
if (w <= 0) return;
|
||||
const endScroll = container.scrollLeft;
|
||||
const delta = endScroll - startScroll;
|
||||
const elapsed = Date.now() - startTime;
|
||||
// 클릭만 했을 때(이동 거리 거의 없음)는 슬라이드 이동 안 함. 일정 이상 드래그했을 때만 방향 적용
|
||||
const minMove = Math.min(w * 0.05, 20);
|
||||
const hasMoved = Math.abs(delta) >= minMove;
|
||||
const direction = hasMoved && (Math.abs(delta) > w * 0.1 || elapsed < 200) ? (delta > 0 ? 1 : -1) : 0;
|
||||
let index = Math.round(endScroll / w) + direction;
|
||||
index = Math.max(0, Math.min(originalLength + 1, index));
|
||||
const targetLeft = index * w;
|
||||
|
||||
// 손 뗀 직후 같은 프레임에서 스크롤 충돌 방지 + 미세 이동은 즉시 적용해서 튐 방지
|
||||
requestAnimationFrame(() => {
|
||||
const snapThreshold = w * 0.03;
|
||||
const useSmooth = Math.abs(container.scrollLeft - targetLeft) > snapThreshold;
|
||||
container.style.scrollBehavior = useSmooth ? 'smooth' : 'auto';
|
||||
container.scrollTo({ left: targetLeft });
|
||||
|
||||
const loopDelay = useSmooth ? 320 : 0;
|
||||
setTimeout(() => {
|
||||
container.style.scrollBehavior = 'auto';
|
||||
if (index === 0) container.scrollLeft = w * originalLength;
|
||||
if (index === originalLength + 1) container.scrollLeft = w;
|
||||
syncModalUI(originalLength);
|
||||
}, loopDelay);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollToImage(index) {
|
||||
const container = document.getElementById('modal-main-carousel-container');
|
||||
if (!container) return;
|
||||
container.scrollTo({
|
||||
left: container.clientWidth * (index + 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
66
scripts/config.js
Normal file
66
scripts/config.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/** 상품 목록·필터·모달 관련 상수 */
|
||||
|
||||
export const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export const VISIBILITY_CONFIG = {
|
||||
showUnlisted: false,
|
||||
showSold: true,
|
||||
};
|
||||
|
||||
export const STATUS_META = {
|
||||
미판매: {
|
||||
selectable: false,
|
||||
defaultVisible: false,
|
||||
soldOut: false,
|
||||
},
|
||||
판매예정: {
|
||||
selectable: true,
|
||||
defaultVisible: true,
|
||||
soldOut: false,
|
||||
},
|
||||
판매중: {
|
||||
selectable: true,
|
||||
defaultVisible: true,
|
||||
soldOut: false,
|
||||
},
|
||||
판매완료: {
|
||||
selectable: true,
|
||||
defaultVisible: false,
|
||||
soldOut: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const STATUS_FILTERS = [
|
||||
{ key: '판매중', label: '판매중', defaultActive: true, visible: true },
|
||||
{ key: '판매예정', label: '판매 예정', defaultActive: true, visible: true },
|
||||
{ key: '미판매', label: '미판매', defaultActive: false, visible: VISIBILITY_CONFIG.showUnlisted },
|
||||
{ key: '판매완료', label: '판매완료', defaultActive: false, visible: VISIBILITY_CONFIG.showSold },
|
||||
];
|
||||
|
||||
export const STATUS_ORDER = {
|
||||
판매중: 0,
|
||||
판매예정: 1,
|
||||
미판매: 2,
|
||||
판매완료: 3,
|
||||
};
|
||||
|
||||
export const STATUS_COLOR = {
|
||||
판매중: 'bg-primary/10 text-primary border-primary/30',
|
||||
판매예정: 'bg-amber-400/10 text-amber-600 border-amber-400/30',
|
||||
판매완료: 'bg-slate-400/10 text-slate-500 border-slate-400/30',
|
||||
미판매: 'bg-slate-200/10 text-slate-400 border-slate-300/30',
|
||||
};
|
||||
|
||||
/** 모달 커스텀 태그(customTag) 키워드별 뱃지 스타일 */
|
||||
export const TAG_STYLES = {
|
||||
완전생산한정판: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
특전포함: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400',
|
||||
미개봉: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
무료배송: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
풀윤활완료: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
|
||||
급매: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
정품: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400',
|
||||
풀옵션: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400',
|
||||
};
|
||||
|
||||
export const TAG_DEFAULT_STYLE = 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400';
|
||||
117
scripts/filters.js
Normal file
117
scripts/filters.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/** 상태·카테고리·검색 필터 로직 및 UI */
|
||||
import { state, productsData } from './state.js';
|
||||
import {
|
||||
VISIBILITY_CONFIG,
|
||||
STATUS_FILTERS,
|
||||
STATUS_ORDER,
|
||||
STATUS_COLOR,
|
||||
} from './config.js';
|
||||
import { renderProducts } from './productList.js';
|
||||
|
||||
function getStatusChipClass(status, isActive) {
|
||||
const base = STATUS_COLOR[status] ?? '';
|
||||
if (isActive) {
|
||||
return `${base} opacity-100 shadow-sm`;
|
||||
}
|
||||
return `bg-slate-50 text-slate-400 border-slate-200 opacity-30 grayscale hover:opacity-50`;
|
||||
}
|
||||
|
||||
export function renderStatusChips() {
|
||||
const container = document.getElementById('status-chips');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
STATUS_FILTERS.filter((f) => f.visible).forEach(({ key, label }) => {
|
||||
const isActive = state.activeStatuses.has(key);
|
||||
const chip = document.createElement('button');
|
||||
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.textContent = label;
|
||||
chip.onclick = () => toggleStatusFilter(key);
|
||||
container.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStatusFilter(status) {
|
||||
if (state.activeStatuses.has(status)) {
|
||||
state.activeStatuses.delete(status);
|
||||
} else {
|
||||
state.activeStatuses.add(status);
|
||||
}
|
||||
if (state.activeStatuses.size === 0) {
|
||||
STATUS_FILTERS.filter((f) => f.defaultActive).forEach((f) => state.activeStatuses.add(f.key));
|
||||
}
|
||||
applyFilters();
|
||||
renderStatusChips();
|
||||
}
|
||||
|
||||
export function applyFilters() {
|
||||
state.currentPage = 1;
|
||||
state.visibleProducts = productsData
|
||||
.filter((product) => {
|
||||
if (product.status === '미판매' && !VISIBILITY_CONFIG.showUnlisted) return false;
|
||||
if (product.status === '판매완료' && !VISIBILITY_CONFIG.showSold) return false;
|
||||
const statusMatch = state.activeStatuses.has(product.status);
|
||||
const categoryMatch = state.activeCategories.has('All') || state.activeCategories.has(product.category);
|
||||
const searchMatch = state.searchKeyword === '' || product.title.toLowerCase().includes(state.searchKeyword);
|
||||
return statusMatch && categoryMatch && searchMatch;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aOrder = STATUS_ORDER[a.status] ?? 999;
|
||||
const bOrder = STATUS_ORDER[b.status] ?? 999;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
renderProducts(state.currentPage);
|
||||
}
|
||||
|
||||
export function getCategories(products) {
|
||||
return ['All', ...new Set(products.map((p) => p.category))];
|
||||
}
|
||||
|
||||
export function renderCategoryChips(products) {
|
||||
const container = document.getElementById('filter-chips');
|
||||
if (!container) return;
|
||||
const categories = ['All', ...new Set(products.map((p) => p.category))];
|
||||
container.innerHTML = '';
|
||||
|
||||
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' : 'bg-slate-50 text-slate-600 border-slate-200'}`;
|
||||
chip.textContent = cat;
|
||||
chip.dataset.category = cat;
|
||||
chip.onclick = () => toggleCategory(cat);
|
||||
container.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export function bindCategoryFilter(products) {
|
||||
const chips = document.querySelectorAll('.filter-chip');
|
||||
chips.forEach((chip) => {
|
||||
chip.addEventListener('click', () => {
|
||||
const category = chip.dataset.category;
|
||||
if (category === 'All') {
|
||||
state.activeCategories.clear();
|
||||
state.activeCategories.add('All');
|
||||
} else {
|
||||
state.activeCategories.delete('All');
|
||||
if (state.activeCategories.has(category)) state.activeCategories.delete(category);
|
||||
else state.activeCategories.add(category);
|
||||
if (state.activeCategories.size === 0) state.activeCategories.add('All');
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
}
|
||||
99
scripts/main.js
Normal file
99
scripts/main.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/** 진입점: 이벤트 바인딩·초기 렌더·URL 모달 처리 */
|
||||
import { state, productsData } from './state.js';
|
||||
import {
|
||||
applyFilters,
|
||||
renderStatusChips,
|
||||
renderCategoryChips,
|
||||
bindCategoryFilter,
|
||||
} from './filters.js';
|
||||
import { renderProducts, changePage } from './productList.js';
|
||||
import { openModal, closeModal } from './modal.js';
|
||||
import { scrollToImage } from './carousel.js';
|
||||
|
||||
console.log('Total products loaded:', productsData.length);
|
||||
|
||||
// HTML onclick에서 사용
|
||||
window.openModal = openModal;
|
||||
window.closeModal = closeModal;
|
||||
window.changePage = changePage;
|
||||
window.scrollToImage = scrollToImage;
|
||||
|
||||
// 검색 입력
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
state.searchKeyword = e.target.value.trim().toLowerCase();
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
// Escape로 모달 닫기
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const modal = document.getElementById('product-modal');
|
||||
if (!modal || modal.classList.contains('hidden')) return;
|
||||
closeModal();
|
||||
});
|
||||
|
||||
function checkUrlAndOpenModal() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const productId = params.get('id');
|
||||
if (productId) {
|
||||
const product = productsData.find((p) => String(p.id) === productId);
|
||||
if (product) {
|
||||
setTimeout(() => openModal(product.id), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 테마 설정 확인
|
||||
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||
|
||||
// 1. 현재 테마 상태에 따라 아이콘 표시/숨김 처리
|
||||
function updateIcons() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
themeToggleLightIcon.classList.remove('hidden');
|
||||
themeToggleDarkIcon.classList.add('hidden');
|
||||
} else {
|
||||
themeToggleDarkIcon.classList.remove('hidden');
|
||||
themeToggleLightIcon.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 초기 로드 시 설정 (localStorage 또는 시스템 설정 확인)
|
||||
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. 버튼 클릭 이벤트
|
||||
themeToggleBtn.addEventListener('click', function() {
|
||||
// 테마 토글
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
}
|
||||
updateIcons();
|
||||
});
|
||||
|
||||
// 초기 렌더
|
||||
renderCategoryChips(productsData);
|
||||
bindCategoryFilter(productsData);
|
||||
renderStatusChips();
|
||||
applyFilters();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderProducts(state.currentPage);
|
||||
checkUrlAndOpenModal();
|
||||
});
|
||||
|
||||
// 데이터용 새 ID 생성기
|
||||
const newId = Math.random().toString(36).substring(2, 10);
|
||||
console.log(`%c[NUMBER]: ${newId}`, 'color: #137fec; font-weight: bold; border: 1px solid #137fec; padding: 2px 5px; border-radius: 4px;');
|
||||
154
scripts/modal.js
Normal file
154
scripts/modal.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/** 상품 상세 모달 (열기/닫기·콘텐츠 채우기·링크 복사) */
|
||||
import { productsData } from './state.js';
|
||||
import { initBetterCarousel } from './carousel.js';
|
||||
import { TAG_STYLES, TAG_DEFAULT_STYLE } from './config.js';
|
||||
|
||||
export function openModal(id) {
|
||||
const product = productsData.find((p) => p.id === id);
|
||||
if (!product) return;
|
||||
|
||||
const modal = document.getElementById('product-modal');
|
||||
const images = product.images;
|
||||
|
||||
const loopImages = [images[images.length - 1], ...images, images[0]];
|
||||
const mainImagesHtml = loopImages
|
||||
.map(
|
||||
(img) => `
|
||||
<div class="flex-shrink-0 w-full h-full snap-center flex items-center justify-center p-0 md:p-4 select-none">
|
||||
<div class="w-full h-full max-w-full max-h-full rounded-xl md:rounded-2xl overflow-hidden flex items-center justify-center">
|
||||
<img src="${img}" draggable="false" class="max-w-full max-h-full w-auto h-auto object-contain sm:object-cover pointer-events-none rounded-md">
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const thumbnailsHtml = product.images
|
||||
.map(
|
||||
(img, idx) => `
|
||||
<div onclick="scrollToImage(${idx})"
|
||||
class="modal-thumb-item size-16 rounded-lg border-2 ${idx === 0 ? 'border-primary' : 'border-transparent'}
|
||||
bg-cover bg-center overflow-hidden cursor-pointer ${idx === 0 ? 'opacity-100' : 'opacity-70'}
|
||||
hover:opacity-100 transition-all flex-shrink-0"
|
||||
style="background-image: url('${img}');"></div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const dotsHtml = product.images
|
||||
.map(
|
||||
(_, idx) => `
|
||||
<div class="modal-dot-item ${idx === 0 ? 'w-4 bg-primary' : 'w-2 bg-gray-300 dark:bg-gray-700'} h-2 rounded-full transition-all"></div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
document.getElementById('modal-main-carousel').innerHTML = mainImagesHtml;
|
||||
document.getElementById('modal-thumbnails').innerHTML = thumbnailsHtml;
|
||||
document.getElementById('modal-dots').innerHTML = dotsHtml;
|
||||
document.getElementById('modal-title').textContent = product.title;
|
||||
document.getElementById('modal-price').textContent = `${product.currency}${product.price.toLocaleString()}`;
|
||||
|
||||
const modalCategory = document.getElementById('modal-category');
|
||||
if (modalCategory) modalCategory.textContent = product.category;
|
||||
|
||||
const modalStatus = document.getElementById('modal-status');
|
||||
if (modalStatus) {
|
||||
modalStatus.textContent = product.status;
|
||||
const statusStyles = {
|
||||
판매중: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
판매예정: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
판매완료: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
미판매: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400',
|
||||
};
|
||||
modalStatus.className = 'inline-flex shrink-0 px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-md sm:rounded-lg text-[10px] sm:text-[11px] md:text-xs font-bold uppercase tracking-wider whitespace-nowrap ' + (statusStyles[product.status] || statusStyles['미판매']);
|
||||
}
|
||||
|
||||
const customTagElement = document.getElementById('modal-custom-tag');
|
||||
const tagText = product.customTag?.trim();
|
||||
if (tagText) {
|
||||
customTagElement.textContent = tagText;
|
||||
customTagElement.classList.remove('hidden');
|
||||
customTagElement.className = `inline-flex shrink-0 px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-md sm:rounded-lg text-[10px] sm:text-[11px] md:text-xs font-bold uppercase tracking-wider whitespace-nowrap ${TAG_STYLES[tagText] || TAG_DEFAULT_STYLE}`;
|
||||
} else {
|
||||
customTagElement.classList.add('hidden');
|
||||
}
|
||||
|
||||
// 구매일자: 값이 있을 때만 행 노출
|
||||
const modalDate = document.getElementById('modal-date');
|
||||
const modalDateRow = document.getElementById('modal-date-row');
|
||||
const pDate = product.specs?.purchaseDate;
|
||||
if (pDate && String(pDate).trim() !== '' && String(pDate) !== 'null') {
|
||||
if (modalDate) modalDate.textContent = pDate;
|
||||
if (modalDateRow) {
|
||||
modalDateRow.classList.remove('hidden');
|
||||
modalDateRow.classList.add('flex');
|
||||
}
|
||||
} else {
|
||||
if (modalDateRow) {
|
||||
modalDateRow.classList.add('hidden');
|
||||
modalDateRow.classList.remove('flex');
|
||||
}
|
||||
}
|
||||
|
||||
// 제품 상태(specs.condition): 값이 있을 때만 행 노출
|
||||
const conditionText = product.specs?.condition;
|
||||
const isVerified = product.specs?.isVerified;
|
||||
const conditionValueEl = document.getElementById('modal-condition');
|
||||
const conditionRowEl = document.getElementById('modal-condition-row');
|
||||
const conditionRowWrap = conditionRowEl?.parentElement; // 라벨+값 전체 행
|
||||
const verifiedIcon = document.getElementById('modal-verified-icon');
|
||||
if (conditionText && String(conditionText).trim() !== '') {
|
||||
if (conditionValueEl) conditionValueEl.textContent = conditionText;
|
||||
if (conditionRowWrap) {
|
||||
conditionRowWrap.classList.remove('hidden');
|
||||
conditionRowWrap.classList.add('flex');
|
||||
}
|
||||
if (verifiedIcon) {
|
||||
if (isVerified) verifiedIcon.classList.remove('hidden');
|
||||
else verifiedIcon.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
if (conditionRowWrap) {
|
||||
conditionRowWrap.classList.add('hidden');
|
||||
conditionRowWrap.classList.remove('flex');
|
||||
}
|
||||
}
|
||||
|
||||
const modalDesc = document.getElementById('modal-desc');
|
||||
if (modalDesc) {
|
||||
modalDesc.innerHTML = Array.isArray(product.fullDescription) ? product.fullDescription.join('<br>') : product.fullDescription || '';
|
||||
}
|
||||
|
||||
const copyBtn = document.getElementById('copy-link-btn');
|
||||
const copyBtnText = document.getElementById('copy-btn-text');
|
||||
if (copyBtn) {
|
||||
copyBtn.onclick = () => {
|
||||
const shareUrl = `${window.location.origin}${window.location.pathname}?id=${product.id}`;
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
if (copyBtnText) copyBtnText.textContent = '링크가 복사되었습니다!';
|
||||
copyBtn.classList.add('!bg-green-600');
|
||||
setTimeout(() => {
|
||||
if (copyBtnText) copyBtnText.textContent = '상품 링크 복사하기';
|
||||
copyBtn.classList.remove('!bg-green-600');
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const container = document.getElementById('modal-main-carousel-container');
|
||||
container.style.scrollBehavior = 'auto';
|
||||
container.scrollLeft = container.clientWidth;
|
||||
|
||||
initBetterCarousel(container, images.length);
|
||||
}
|
||||
|
||||
export function closeModal() {
|
||||
document.getElementById('product-modal').classList.add('hidden');
|
||||
document.body.style.overflow = 'auto';
|
||||
const cleanUrl = window.location.origin + window.location.pathname;
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
}
|
||||
71
scripts/productList.js
Normal file
71
scripts/productList.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/** 상품 그리드·페이지네이션 렌더링 */
|
||||
import { state } from './state.js';
|
||||
import { ITEMS_PER_PAGE, STATUS_META } from './config.js';
|
||||
|
||||
export function renderProducts(page) {
|
||||
const grid = document.getElementById('product-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
|
||||
const startIndex = (page - 1) * ITEMS_PER_PAGE;
|
||||
const pagedProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
|
||||
pagedProducts.forEach((product) => {
|
||||
const isSold = STATUS_META[product.status]?.soldOut === true;
|
||||
const cardHtml = `
|
||||
<div class="group flex flex-col gap-4 cursor-pointer" onclick="openModal('${product.id}')">
|
||||
<div class="relative w-full aspect-[4/5] bg-slate-50 dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm group-hover:shadow-md transition-shadow">
|
||||
<div class="w-full h-full bg-center bg-no-repeat bg-cover transform ${isSold ? 'grayscale opacity-80' : 'group-hover:scale-105'} transition-transform duration-500"
|
||||
style="background-image: url('${product.images[0]}')"></div>
|
||||
<div class="absolute top-3 left-3">
|
||||
<span class="px-2 py-1 text-[10px] uppercase tracking-wider font-bold rounded ${isSold ? 'bg-slate-900/10 text-slate-500' : 'bg-primary/10 text-primary'} backdrop-blur-md border border-primary/20">
|
||||
${product.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start">
|
||||
<h3 class="text-slate-900 dark:text-white text-base font-semibold ${isSold ? 'line-through text-slate-400' : ''}">${product.title}</h3>
|
||||
<p class="text-slate-900 dark:text-white text-base font-bold text-nowrap">${product.currency}${product.price.toLocaleString()}</p>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm font-normal">${product.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
grid.insertAdjacentHTML('beforeend', cardHtml);
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
17
scripts/state.js
Normal file
17
scripts/state.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/** 앱 전역 상태 */
|
||||
import products from '../data/index.js';
|
||||
import { STATUS_META } from './config.js';
|
||||
|
||||
export const productsData = products;
|
||||
|
||||
export const state = {
|
||||
currentPage: 1,
|
||||
activeCategories: new Set(['All']),
|
||||
visibleProducts: [...products],
|
||||
searchKeyword: '',
|
||||
activeStatuses: new Set(
|
||||
Object.entries(STATUS_META)
|
||||
.filter(([_, meta]) => meta.defaultVisible)
|
||||
.map(([status]) => status),
|
||||
),
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user