diff --git a/.prettierrc b/.prettierrc
index 1d2699e4..530fb076 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -2,5 +2,6 @@
"tabWidth": 2,
"semi": true,
"singleQuote": false,
- "printWidth": 120
+ "printWidth": 120,
+ "embeddedLanguageFormatting": "auto"
}
diff --git a/cart-modal.html b/cart-modal.html
deleted file mode 100644
index 66455103..00000000
--- a/cart-modal.html
+++ /dev/null
@@ -1,320 +0,0 @@
-
-
-
-
-
-
- 상품 쇼핑몰
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 총 340개의 상품
-
-
-
-
-
-
-

-
-
-
-
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
-
-
-
- 220원
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
-
-
이지웨이건축자재
-
- 230원
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
- 실리카겔 50g 습기제거제 제품 /산업 신발 의류 방습제
-
-
-
- 280원
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
- 두꺼운 고급 무지쇼핑백 종이쇼핑백 주문제작 소량 로고인쇄 선물용 종이가방 세로형1호
-
-
-
- 350원
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
- 방충망 셀프교체 미세먼지 롤 창문 모기장 알루미늄망 60cmX20cm
-
-
-
- 420원
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 장바구니
-
-
-
-
-
-
-
-
-
-
장바구니가 비어있습니다
-
원하는 상품을 담아보세요!
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/404.html b/public/404.html
new file mode 100644
index 00000000..4b6d65a8
--- /dev/null
+++ b/public/404.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ 상품 쇼핑몰
+
+
+
+
+
+
+
+
+
diff --git a/src/api/productApi.js b/src/api/productApi.js
index bbdea046..7eee0fe2 100644
--- a/src/api/productApi.js
+++ b/src/api/productApi.js
@@ -19,6 +19,7 @@ export async function getProducts(params = {}) {
// 상품 상세 조회
export async function getProduct(productId) {
+ console.log("productId", productId);
const response = await fetch(`/api/products/${productId}`);
return await response.json();
}
diff --git a/src/components/CartModal.js b/src/components/CartModal.js
new file mode 100644
index 00000000..673b6f33
--- /dev/null
+++ b/src/components/CartModal.js
@@ -0,0 +1,244 @@
+/**
+ * 모달 래퍼
+ */
+const CartModalWrapper = (content) => /* html */ `
+
+`;
+
+/**
+ * 헤더
+ */
+const CartModalHeader = (itemCount = 0) => /* html */ `
+
+
+
+ 장바구니
+ ${itemCount > 0 ? `(${itemCount})` : ""}
+
+
+
+`;
+
+/**
+ * 컨텐츠 래퍼
+ */
+const CartModalContentWrapper = (content) => /* html */ `
+
+ ${content}
+
+`;
+
+/**
+ * 전체 선택 섹션
+ */
+const CartModalSelectAll = (itemCount, isAllSelected = false) => /* html */ `
+
+
+
+`;
+
+/**
+ * 아이템 목록 래퍼
+ */
+const CartModalItemsWrapper = (items) => /* html */ `
+
+`;
+
+/**
+ * 개별 아이템 카드
+ */
+const CartModalItem = (item, isSelected = false) => /* html */ `
+
+
+
+
+
+
+

+
+
+
+
+
+ ${item.title}
+
+
+ ${item.price.toLocaleString()}원
+
+
+
+
+
+
+
+
+
+ ${(item.price * item.quantity).toLocaleString()}원
+
+
+
+
+`;
+
+/**
+ * 빈 장바구니 UI
+ */
+const CartModalEmptyState = () => /* html */ `
+
+
+
+
장바구니가 비어있습니다
+
원하는 상품을 담아보세요!
+
+
+`;
+
+/**
+ * 하단 액션 버튼 영역
+ */
+const CartModalFooter = ({ selectedCount = 0, selectedAmount = 0, totalAmount = 0 }) => /* html */ `
+
+ ${
+ selectedCount > 0
+ ? /* html */ `
+
+
+ 선택한 상품 (${selectedCount}개)
+ ${selectedAmount.toLocaleString()}원
+
+ `
+ : ""
+ }
+
+
+
+ 총 금액
+ ${totalAmount.toLocaleString()}원
+
+
+
+
+ ${
+ selectedCount > 0
+ ? /* html */ `
+
+ `
+ : ""
+ }
+
+
+
+
+
+
+`;
+
+/**
+ * 장바구니 모달 메인 컴포넌트
+ * @param {Array} cartItems - 장바구니 아이템 배열
+ * @param {Array} selectedItems - 선택된 아이템 ID 배열
+ * @returns {string} HTML 문자열
+ */
+export const CartModal = ({ cartItems = [], selectedItems = [] }) => {
+ // 장바구니가 비어있는 경우
+ if (cartItems.length === 0) {
+ return CartModalWrapper(CartModalHeader(0) + CartModalContentWrapper(CartModalEmptyState()));
+ }
+
+ // 총 금액 계산
+ const totalAmount = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
+
+ // 선택된 아이템 계산
+ const selectedCount = selectedItems.length;
+ const selectedAmount = cartItems
+ .filter((item) => selectedItems.includes(item.id))
+ .reduce((sum, item) => sum + item.price * item.quantity, 0);
+
+ // 전체 선택 여부
+ const isAllSelected = cartItems.length > 0 && selectedItems.length === cartItems.length;
+
+ // 아이템 목록 HTML 생성
+ const itemsHTML = cartItems.map((item) => CartModalItem(item, selectedItems.includes(item.id))).join("");
+
+ // 컨텐츠 조립
+ const content =
+ CartModalHeader(cartItems.length) +
+ CartModalContentWrapper(CartModalSelectAll(cartItems.length, isAllSelected) + CartModalItemsWrapper(itemsHTML)) +
+ CartModalFooter({ selectedCount, selectedAmount, totalAmount });
+
+ return CartModalWrapper(content);
+};
diff --git a/src/components/Footer.js b/src/components/Footer.js
new file mode 100644
index 00000000..6db92150
--- /dev/null
+++ b/src/components/Footer.js
@@ -0,0 +1,7 @@
+export const Footer = () => {
+ return /* html */ ``;
+};
diff --git a/src/components/Header.js b/src/components/Header.js
new file mode 100644
index 00000000..e19d6fa8
--- /dev/null
+++ b/src/components/Header.js
@@ -0,0 +1,62 @@
+import { getCartCount } from "../utils/cartStore.js";
+
+export const Header = ({ isDetailPage = false } = {}) => {
+ const cartCount = getCartCount();
+
+ if (isDetailPage) {
+ return /* html */ `
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ return /* html */ `
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/src/components/ProductDetail.js b/src/components/ProductDetail.js
new file mode 100644
index 00000000..25cd5792
--- /dev/null
+++ b/src/components/ProductDetail.js
@@ -0,0 +1,179 @@
+export const ProductDetail = ({ loading, product, relatedProducts = [] }) => {
+ console.log("product", product);
+
+ // {
+ // "title": "샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이",
+ // "link": "https://smartstore.naver.com/main/products/9396357056",
+ // "image": "https://shopping-phinf.pstatic.net/main_8694085/86940857379.1.jpg",
+ // "lprice": "230",
+ // "hprice": "",
+ // "mallName": "EASYWAY",
+ // "productId": "86940857379",
+ // "productType": "2",
+ // "brand": "이지웨이건축자재",
+ // "maker": "",
+ // "category1": "생활/건강",
+ // "category2": "생활용품",
+ // "category3": "생활잡화",
+ // "category4": "문풍지",
+ // "description": "샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이에 대한 상세 설명입니다. 이지웨이건축자재 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.",
+ // "rating": 5,
+ // "reviewCount": 1042,
+ // "stock": 91,
+ // "images": [
+ // "https://shopping-phinf.pstatic.net/main_8694085/86940857379.1.jpg",
+ // "https://shopping-phinf.pstatic.net/main_8694085/86940857379.1_2.jpg",
+ // "https://shopping-phinf.pstatic.net/main_8694085/86940857379.1_3.jpg"
+ // ]
+ // }
+
+ if (loading) {
+ return ``;
+ } else {
+ const renderStars = (rating) => {
+ const yellowStars = Math.floor(rating);
+ const grayStars = 5 - yellowStars;
+
+ let starsHTML = "";
+
+ // 노란색 별 렌더링
+ for (let i = 0; i < yellowStars; i++) {
+ starsHTML += `
+ `;
+ }
+
+ // 회색 별 렌더링
+ for (let i = 0; i < grayStars; i++) {
+ starsHTML += `
+ `;
+ }
+
+ return starsHTML;
+ };
+
+ return `
+
+
+
+
+
+
+

+
+
+
+
${product.brand}
+
${product.title}
+
+
+
+ ${renderStars(product.rating)}
+
+
${Number(product.rating).toFixed(1)} (${product.reviewCount}개 리뷰)
+
+
+
+ ${Number(product.lprice).toLocaleString()}원
+
+
+
+ 재고 ${product.stock}개
+
+
+
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
관련 상품
+
같은 카테고리의 다른 상품들
+
+
+ ${
+ relatedProducts.length > 0
+ ? `
+ ${relatedProducts
+ .map(
+ (relatedProduct) => `
+
+ `,
+ )
+ .join("")}
+
`
+ : `
+ 같은 카테고리의 다른 상품이 없습니다.
+
`
+ }
+
+
`;
+ }
+};
diff --git a/src/components/ProductList.js b/src/components/ProductList.js
new file mode 100644
index 00000000..84e33411
--- /dev/null
+++ b/src/components/ProductList.js
@@ -0,0 +1,109 @@
+const Skeleton = `
+ `;
+
+const Loading = `
+
+
+
상품을 불러오는 중...
+
+
`;
+
+const ProductItem = ({ title, productId, image, lprice, brand }) => {
+ return /* html */ `
+
+
+

+
+
+
+
+
+ ${title}
+
+
${brand}
+
+ ${Number(lprice).toLocaleString()}원
+
+
+
+
+
+
`;
+};
+
+export const ProductList = ({ loading, products, pagination, isLoadingMore }) => {
+ const hasMore = pagination.hasNext;
+
+ return `
+
+ ${
+ pagination.total
+ ? `
+ 총 ${pagination.total}개의 상품
+
+ `
+ : ""
+ }
+
+ ${products.map(ProductItem).join("")}
+
+
+ ${
+ loading && !isLoadingMore
+ ? `
+
+
+ ${Skeleton.repeat(4)}
+
+ ${Loading}
+ `
+ : ""
+ }
+ ${
+ isLoadingMore
+ ? `
+
+ ${Loading}
+ `
+ : ""
+ }
+ ${
+ hasMore
+ ? `
+
+
+ `
+ : ""
+ }
+ ${
+ !loading && !isLoadingMore && !hasMore && products.length > 0
+ ? `
+
+ 모든 상품을 불러왔습니다.
+
+ `
+ : ""
+ }
+
`;
+};
diff --git a/src/components/SearchForm.js b/src/components/SearchForm.js
new file mode 100644
index 00000000..c92999fe
--- /dev/null
+++ b/src/components/SearchForm.js
@@ -0,0 +1,160 @@
+export const CategoryLoading = `
+
+`;
+
+export const SearchForm = ({ categoriesLoading, filters = {}, categories = {} }) => {
+ const { limit = 20, search = "", sort = "price_asc", category1 = "", category2 = "" } = filters;
+
+ // 1depth 카테고리 목록
+ const category1List = Object.keys(categories);
+
+ // 선택된 1depth 카테고리의 2depth 목록
+ const category2List = category1 && categories[category1] ? Object.keys(categories[category1]) : [];
+
+ // 브레드크럼 생성
+ const renderBreadcrumb = () => {
+ let breadcrumb = `
+
+ `;
+
+ if (category1) {
+ breadcrumb += `
+ >
+
+ `;
+ }
+
+ if (category2) {
+ breadcrumb += `
+ >
+ ${category2}
+ `;
+ }
+
+ return breadcrumb;
+ };
+
+ return /* html */ `
+
+
+
+
+
+
+
+
+
+
+ ${renderBreadcrumb()}
+
+
+ ${
+ categoriesLoading
+ ? CategoryLoading
+ : category1 && category2List.length > 0
+ ? `
+
+
+
+ ${category2List
+ .map(
+ (cat2) => `
+
+ `,
+ )
+ .join("")}
+
+
+ `
+ : `
+
+
+ ${category1List
+ .map(
+ (cat1) => `
+
+ `,
+ )
+ .join("")}
+
+ `
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/src/components/Toast.js b/src/components/Toast.js
new file mode 100644
index 00000000..9e16ea5c
--- /dev/null
+++ b/src/components/Toast.js
@@ -0,0 +1,113 @@
+/**
+ * 토스트 타입별 아이콘
+ */
+const ToastIcons = {
+ success: /* html */ `
+
+ `,
+ info: /* html */ `
+
+ `,
+ error: /* html */ `
+
+ `,
+};
+
+/**
+ * 토스트 타입별 스타일
+ */
+const ToastStyles = {
+ success: "bg-green-600",
+ info: "bg-blue-600",
+ error: "bg-red-600",
+};
+
+/**
+ * 개별 토스트 컴포넌트
+ * @param {string} type - 'success' | 'info' | 'error'
+ * @param {string} message - 토스트 메시지
+ */
+export const Toast = ({ type = "success", message }) => {
+ const bgColor = ToastStyles[type] || ToastStyles.success;
+ const icon = ToastIcons[type] || ToastIcons.success;
+
+ return /* html */ `
+
+
+ ${icon}
+
+
${message}
+
+
+ `;
+};
+
+/**
+ * 토스트 표시 함수
+ * @param {string} message - 표시할 메시지
+ * @param {string} type - 'success' | 'info' | 'error'
+ * @param {number} duration - 자동 닫힘 시간 (ms), 0이면 자동으로 닫히지 않음
+ */
+export function showToast(message, type = "success", duration = 3000) {
+ // 기존 토스트 제거
+ removeToast();
+
+ const toastHTML = Toast({ type, message });
+
+ // 토스트 컨테이너 생성 또는 가져오기
+ let container = document.getElementById("toast-container");
+ if (!container) {
+ container = document.createElement("div");
+ container.id = "toast-container";
+ container.className = "fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50";
+ document.body.appendChild(container);
+ }
+
+ // 토스트 추가
+ container.innerHTML = toastHTML;
+
+ // 닫기 버튼 이벤트
+ const closeBtn = document.getElementById("toast-close-btn");
+ if (closeBtn) {
+ closeBtn.addEventListener("click", () => {
+ removeToast();
+ });
+ }
+
+ // 자동 닫힘
+ if (duration > 0) {
+ setTimeout(() => {
+ removeToast();
+ }, duration);
+ }
+}
+
+/**
+ * 토스트 제거 함수
+ */
+export function removeToast() {
+ const toast = document.getElementById("toast");
+ if (toast) {
+ toast.remove();
+ }
+}
+
+/**
+ * 모든 토스트 제거 (단일 토스트이므로 removeToast와 동일)
+ */
+export function clearAllToasts() {
+ removeToast();
+}
diff --git a/src/main.js b/src/main.js
index 4b055b89..6dae7044 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,1148 +1,348 @@
+import { router } from "./router/index.js";
+import { showToast } from "./components/Toast.js";
+import {
+ addToCart,
+ increaseQuantity,
+ decreaseQuantity,
+ removeFromCart,
+ clearCart,
+ toggleSelectItem,
+ toggleSelectAll,
+ removeSelectedItems,
+ subscribeCart,
+} from "./utils/cartStore.js";
+import { openModal, closeModal, subscribeModal } from "./utils/modalStore.js";
+
const enableMocking = () =>
import("./mocks/browser.js").then(({ worker }) =>
worker.start({
onUnhandledRequest: "bypass",
+ serviceWorker: {
+ url: import.meta.env.BASE_URL + "mockServiceWorker.js",
+ },
}),
);
+// 이벤트 리스너
+const initEventListeners = () => {
+ const $root = document.querySelector("#root");
-function main() {
- const 상품목록_레이아웃_로딩 = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
상품을 불러오는 중...
-
-
-
-
-
-
-
- `;
+ // 장바구니 변경 시 라우터 재렌더링
+ subscribeCart(() => {
+ router.rerender();
+ });
- const 상품목록_레이아웃_로딩완료 = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 총 340개의 상품
-
-
-
-
-
-
-

-
-
-
-
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
-
-
-
- 220원
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
-
-
이지웨이건축자재
-
- 230원
-
-
-
-
-
-
-
-
-
- 모든 상품을 확인했습니다
-
-
-
-
-
-
- `;
+ // 모달 상태 변경 시 라우터 재렌더링
+ subscribeModal(() => {
+ router.rerender();
+ });
- const 상품목록_레이아웃_카테고리_1Depth = `
-
-
-
-
-
-
-
-
+ // ESC 키로 모달 닫기
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ closeModal();
+ }
+ });
-
-
-
-
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
+ $root.addEventListener("click", (e) => {
+ // 장바구니 아이콘 클릭
+ if (e.target.closest("#cart-icon-btn")) {
+ e.preventDefault();
+ openModal();
+ return;
+ }
- const 상품목록_레이아웃_카테고리_2Depth = `
-
-
-
-
-
-
-
-
+ // 장바구니 모달 닫기
+ if (e.target.closest("#cart-modal-close-btn")) {
+ closeModal();
+ return;
+ }
-
-
-
-
- >>주방용품
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
+ // 모달 배경 클릭 시 닫기
+ if (e.target.classList.contains("cart-modal-overlay")) {
+ closeModal();
+ return;
+ }
- const 토스트 = `
-
-
-
-
장바구니에 추가되었습니다
-
-
-
-
-
-
선택된 상품들이 삭제되었습니다
-
-
-
-
-
-
오류가 발생했습니다.
-
-
-
- `;
+ // 전체 선택
+ if (e.target.closest("#cart-modal-select-all-checkbox")) {
+ const checkbox = e.target.closest("#cart-modal-select-all-checkbox");
+ toggleSelectAll(checkbox.checked);
+ return;
+ }
- const 장바구니_비어있음 = `
-
-
-
-
-
-
- 장바구니
-
-
-
-
-
-
-
-
-
-
-
-
장바구니가 비어있습니다
-
원하는 상품을 담아보세요!
-
-
-
-
-
- `;
+ // 개별 체크박스
+ if (e.target.classList.contains("cart-item-checkbox")) {
+ toggleSelectItem(e.target.dataset.productId, e.target.checked);
+ return;
+ }
- const 장바구니_선택없음 = `
-
-
-
-
-
-
- 장바구니
- (2)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

-
-
-
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
-
-
- 220원
-
-
-
-
-
-
-
-
-
-
-
-
-

-
-
-
-
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
-
-
- 230원
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 총 금액
- 670원
-
-
-
-
-
-
-
-
-
-
-
- `;
+ // 수량 증가
+ const increaseBtn = e.target.closest(".quantity-increase-btn");
+ if (increaseBtn) {
+ increaseQuantity(increaseBtn.dataset.productId);
+ return;
+ }
- const 장바구니_선택있음 = `
-
-
-
-
-
-
- 장바구니
- (2)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

-
-
-
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
-
-
- 220원
-
-
-
-
-
-
-
-
-
-
-
-
-

-
-
-
-
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
-
-
- 230원
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 선택한 상품 (1개)
- 440원
-
-
-
- 총 금액
- 670원
-
-
-
-
-
-
-
-
-
-
-
-
- `;
+ // 수량 감소
+ const decreaseBtn = e.target.closest(".quantity-decrease-btn");
+ if (decreaseBtn) {
+ decreaseQuantity(decreaseBtn.dataset.productId);
+ return;
+ }
- const 상세페이지_로딩 = `
-
-
-
-
-
-
-
- `;
+ // 아이템 삭제
+ if (e.target.classList.contains("cart-item-remove-btn")) {
+ removeFromCart(e.target.dataset.productId);
+ return;
+ }
- const 상세페이지_로딩완료 = `
-
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
-
-
-
-
-
-
-
-
-
-
4.0 (749개 리뷰)
-
-
-
- 220원
-
-
-
- 재고 107개
-
-
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
관련 상품
-
같은 카테고리의 다른 상품들
-
-
-
-
-
-
- `;
+ // 선택한 상품 삭제
+ if (e.target.closest("#cart-modal-remove-selected-btn")) {
+ removeSelectedItems();
+ return;
+ }
- const _404_ = `
-
-
-
-
-
홈으로
-
-
- `;
+ // 전체 비우기
+ if (e.target.closest("#cart-modal-clear-cart-btn")) {
+ clearCart();
+ return;
+ }
- document.body.innerHTML = `
- ${상품목록_레이아웃_로딩}
-
- ${상품목록_레이아웃_로딩완료}
-
- ${상품목록_레이아웃_카테고리_1Depth}
-
- ${상품목록_레이아웃_카테고리_2Depth}
-
- ${토스트}
-
- ${장바구니_비어있음}
-
- ${장바구니_선택없음}
-
- ${장바구니_선택있음}
-
- ${상세페이지_로딩}
-
- ${상세페이지_로딩완료}
-
- ${_404_}
- `;
-}
+ // 구매하기
+ if (e.target.closest("#cart-modal-checkout-btn")) {
+ showToast("구매 기능은 준비중입니다!", "info");
+ return;
+ }
+
+ // 장바구니 담기 버튼
+ if (e.target.closest(".add-to-cart-btn")) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const btn = e.target.closest(".add-to-cart-btn");
+ const productId = btn.dataset.productId;
+ const productCard = btn.closest(".product-card");
+ const productName = productCard.querySelector("h3").textContent;
+ const productPrice = parseInt(productCard.querySelector("p:last-of-type").textContent.replace(/[^0-9]/g, ""));
+ const productImage = productCard.querySelector("img").src;
+
+ addToCart({
+ id: productId,
+ name: productName,
+ price: productPrice,
+ image: productImage,
+ });
+
+ showToast("장바구니에 추가되었습니다", "success");
+ return;
+ }
+
+ // 상품 카드 클릭 (장바구니 담기 버튼 제외)
+ if (e.target.closest(".product-card") && !e.target.closest(".add-to-cart-btn")) {
+ e.preventDefault();
+ const productId = e.target.closest(".product-card").dataset.productId;
+ router.push(`/product/${productId}`);
+ return;
+ }
+
+ const link = e.target.closest("[data-link]");
+ if (link) {
+ e.preventDefault();
+ const href = link.getAttribute("href");
+ router.push(href);
+ return;
+ }
+
+ // 1depth 카테고리 클릭
+ const category1Btn = e.target.closest(".category1-btn");
+ if (category1Btn) {
+ const category1 = category1Btn.dataset.category1;
+ const currentParams = router.getQueryParams();
+
+ router.pushWithQuery("/", {
+ ...currentParams,
+ category1,
+ category2: "",
+ current: 1,
+ });
+ }
+
+ // 전체 리셋 (기존 버튼)
+ if (e.target.closest("#category-reset-btn")) {
+ const currentParams = router.getQueryParams();
+
+ router.pushWithQuery("/", {
+ ...currentParams,
+ category1: "",
+ category2: "",
+ current: 1,
+ });
+ }
+
+ // 브레드크럼 - 전체 리셋
+ const resetBtn = e.target.closest('[data-breadcrumb="reset"]');
+ if (resetBtn) {
+ const currentParams = router.getQueryParams();
+ router.pushWithQuery("/", {
+ ...currentParams,
+ category1: "",
+ category2: "",
+ current: 1,
+ });
+ }
+
+ // 브레드크럼 - category1으로 돌아가기
+ const category1Breadcrumb = e.target.closest('[data-breadcrumb="category1"]');
+ if (category1Breadcrumb) {
+ const category1 = category1Breadcrumb.dataset.category1;
+ const currentParams = router.getQueryParams();
+ router.pushWithQuery("/", {
+ ...currentParams,
+ category1,
+ category2: "",
+ current: 1,
+ });
+ }
+
+ // 2depth 카테고리 클릭
+ const category2Btn = e.target.closest(".category2-filter-btn");
+ if (category2Btn) {
+ const category1 = category2Btn.dataset.category1;
+ const category2 = category2Btn.dataset.category2;
+ const currentParams = router.getQueryParams();
+
+ router.pushWithQuery("/", {
+ ...currentParams,
+ category1,
+ category2,
+ current: 1,
+ });
+ }
+
+ // 상세 페이지 - 수량 증가
+ if (e.target.closest("#quantity-increase")) {
+ const input = document.getElementById("quantity-input");
+ if (input) {
+ const currentValue = parseInt(input.value) || 1;
+ const max = parseInt(input.getAttribute("max")) || 999;
+ if (currentValue < max) {
+ input.value = currentValue + 1;
+ }
+ }
+ return;
+ }
+
+ // 상세 페이지 - 수량 감소
+ if (e.target.closest("#quantity-decrease")) {
+ const input = document.getElementById("quantity-input");
+ if (input) {
+ const currentValue = parseInt(input.value) || 1;
+ const min = parseInt(input.getAttribute("min")) || 1;
+ if (currentValue > min) {
+ input.value = currentValue - 1;
+ }
+ }
+ return;
+ }
+
+ // 상세 페이지 - 장바구니 담기
+ if (e.target.closest("#add-to-cart-btn")) {
+ const btn = e.target.closest("#add-to-cart-btn");
+ const productId = btn.dataset.productId;
+
+ // 상세 페이지에서 상품 정보 가져오기
+ const productTitle = document.querySelector(".text-xl.font-bold.text-gray-900.mb-3").textContent;
+ const productPrice = parseInt(
+ document.querySelector(".text-2xl.font-bold.text-blue-600").textContent.replace(/[^0-9]/g, ""),
+ );
+ const productImage = document.querySelector(".product-detail-image").src;
+ const quantity = parseInt(document.getElementById("quantity-input").value) || 1;
+
+ // 수량만큼 추가
+ for (let i = 0; i < quantity; i++) {
+ addToCart({
+ id: productId,
+ name: productTitle,
+ price: productPrice,
+ image: productImage,
+ });
+ }
+
+ showToast(`${quantity}개 상품이 장바구니에 추가되었습니다`, "success");
+ return;
+ }
+
+ // 상세 페이지 - 상품 목록으로 돌아가기
+ if (e.target.closest(".go-to-product-list")) {
+ router.push("/");
+ return;
+ }
+
+ // 상세 페이지 - 관련 상품 클릭
+ if (e.target.closest(".related-product-card")) {
+ const card = e.target.closest(".related-product-card");
+ const productId = card.dataset.productId;
+ router.push(`/product/${productId}`);
+ return;
+ }
+
+ // 상세 페이지 - 브레드크럼 카테고리 클릭
+ if (e.target.closest(".breadcrumb-link")) {
+ const link = e.target.closest(".breadcrumb-link");
+ const category1 = link.dataset.category1;
+ const category2 = link.dataset.category2;
+
+ if (category2) {
+ router.pushWithQuery("/", { category1, category2, current: 1 });
+ } else if (category1) {
+ router.pushWithQuery("/", { category1, current: 1 });
+ }
+ return;
+ }
+ });
+
+ $root.addEventListener("change", (e) => {
+ // limit 변경
+ if (e.target.closest("#limit-select")) {
+ const limit = e.target.value;
+ const currentParams = router.getQueryParams();
+ router.pushWithQuery("/", {
+ ...currentParams,
+ limit,
+ current: 1,
+ });
+ }
+
+ // sort 변경
+ if (e.target.closest("#sort-select")) {
+ const sort = e.target.value;
+ const currentParams = router.getQueryParams();
+ router.pushWithQuery("/", {
+ ...currentParams,
+ sort,
+ current: 1,
+ });
+ }
+ });
+
+ // 검색어 입력 (엔터키)
+ $root.addEventListener("keypress", (e) => {
+ if (e.target.closest("#search-input") && e.key === "Enter") {
+ const search = e.target.value;
+ const currentParams = router.getQueryParams();
+ router.pushWithQuery("/", {
+ ...currentParams,
+ search,
+ current: 1,
+ });
+ }
+ });
+};
+
+const main = () => {
+ initEventListeners();
+ router.init();
+};
// 애플리케이션 시작
if (import.meta.env.MODE !== "test") {
diff --git a/src/pages/404.js b/src/pages/404.js
new file mode 100644
index 00000000..bfc71745
--- /dev/null
+++ b/src/pages/404.js
@@ -0,0 +1,30 @@
+export const _404 = `
+
+
+
홈으로
+
`;
diff --git a/src/pages/DetailPage.js b/src/pages/DetailPage.js
new file mode 100644
index 00000000..dbf49c18
--- /dev/null
+++ b/src/pages/DetailPage.js
@@ -0,0 +1,56 @@
+import { getProduct, getProducts } from "../api/productApi.js";
+import { ProductDetail } from "../components/ProductDetail.js";
+import { useState, useEffect, setCurrentComponent } from "../utils/hooks.js";
+
+export const DetailPage = (params) => {
+ setCurrentComponent("DetailPage");
+
+ const productId = params.id;
+
+ const [product, setProduct] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [relatedProducts, setRelatedProducts] = useState([]);
+
+ useEffect(() => {
+ const fetchProduct = async () => {
+ setLoading(true);
+
+ try {
+ const data = await getProduct(productId);
+ console.log(data);
+ setProduct(data);
+ setLoading(false);
+ } catch (error) {
+ console.error(error);
+ setLoading(false);
+ }
+ };
+
+ fetchProduct();
+ }, [productId]);
+
+ useEffect(() => {
+ if (!product) return;
+
+ const fetchRelatedProducts = async () => {
+ try {
+ const data = await getProducts({
+ limit: 10,
+ category1: product.category1,
+ category2: product.category2,
+ sort: "price_asc",
+ });
+
+ const filtered = data.products.filter((p) => p.productId !== product.productId).slice(0, 2);
+
+ setRelatedProducts(filtered);
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ fetchRelatedProducts();
+ }, [product]);
+
+ return ProductDetail({ loading, product, relatedProducts });
+};
diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js
new file mode 100644
index 00000000..241d1d45
--- /dev/null
+++ b/src/pages/HomePage.js
@@ -0,0 +1,182 @@
+import { getCategories, getProducts } from "../api/productApi.js";
+import { SearchForm } from "../components/SearchForm.js";
+import { ProductList } from "../components/ProductList.js";
+import { router } from "../router/index.js";
+import { useState, useEffect, setCurrentComponent } from "../utils/hooks.js";
+
+export const HomePage = () => {
+ setCurrentComponent("HomePage");
+
+ // ✅ 상태 관리
+ const [products, setProducts] = useState([]);
+ const [pagination, setPagination] = useState({});
+ const [categories, setCategories] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [categoriesLoading, setCategoriesLoading] = useState(true);
+ const [categoriesFetched, setCategoriesFetched] = useState(false);
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
+
+ // 쿼리 파라미터
+ const queryParams = router.getQueryParams();
+ const filters = {
+ current: 1, //항상 1부터 시작하도록 변경
+ limit: parseInt(queryParams.limit) || 20,
+ search: queryParams.search || "",
+ category1: queryParams.category1 || "",
+ category2: queryParams.category2 || "",
+ sort: queryParams.sort || "price_asc",
+ };
+
+ // ✅ useEffect: 필터 변경 시 상품 데이터 로드 (초기화)
+ useEffect(() => {
+ const fetchProducts = async () => {
+ setLoading(true);
+
+ try {
+ const data = await getProducts(filters);
+ setProducts(data.products);
+ setPagination(data.pagination);
+ setLoading(false);
+ // 필터 변경 시 URL에서 current 파라미터 제거
+ const searchParams = new URLSearchParams(window.location.search);
+ if (searchParams.has("current")) {
+ searchParams.delete("current");
+ const newUrl = searchParams.toString()
+ ? `${window.location.pathname}?${searchParams.toString()}`
+ : window.location.pathname;
+ window.history.replaceState({}, "", newUrl);
+ }
+ } catch (error) {
+ console.error(error);
+ setLoading(false);
+ }
+ };
+
+ fetchProducts();
+ }, [filters.limit, filters.search, filters.category1, filters.category2, filters.sort]);
+
+ useEffect(() => {
+ const fetchCategories = async () => {
+ if (categoriesFetched) return;
+
+ setCategoriesLoading(true);
+
+ try {
+ const data = await getCategories();
+ setCategories(data);
+ setCategoriesLoading(false);
+ setCategoriesFetched(true);
+ } catch (error) {
+ console.error(error);
+ setCategoriesLoading(false);
+ }
+ };
+
+ fetchCategories();
+ }, []);
+
+ // ✅ useEffect: Intersection Observer로 인피니티 스크롤 구현
+ useEffect(() => {
+ console.log("pagination!", pagination);
+ // 로딩 중이거나 추가 로딩 중이면 observer 설정 안 함
+ if (loading || isLoadingMore) return;
+
+ // 더 이상 불러올 페이지가 없으면 observer 설정 안 함
+ const hasMore = pagination.hasNext;
+
+ if (!hasMore) return;
+
+ // DOM이 완전히 렌더링될 때까지 대기
+ const setupObserver = () => {
+ const sentinel = document.querySelector("#infinite-scroll-trigger");
+ if (!sentinel) {
+ console.log("Sentinel not found, retrying...");
+ return null;
+ }
+
+ const observerCallback = async (entries) => {
+ const [entry] = entries;
+
+ // 센티널이 화면에 보이고, 추가 로딩 중이 아닐 때 다음 페이지 로드
+ if (entry.isIntersecting && !isLoadingMore) {
+ console.log("Loading more products...");
+ setIsLoadingMore(true);
+
+ try {
+ const nextPage = pagination.page + 1;
+ console.log("Next page:", nextPage, pagination.page);
+ const data = await getProducts({
+ ...filters,
+ current: nextPage,
+ });
+ console.log("Pagination data:", data.pagination); // 디버깅용
+
+ console.log("Loaded products:", data.products.length);
+
+ // 기존 상품에 새 상품 추가
+ setProducts((prevProducts) => [...prevProducts, ...data.products]);
+ setPagination(data.pagination);
+
+ console.log(window.location);
+ // URL 쿼리 파라미터 업데이트
+ const searchParams = new URLSearchParams(window.location.search);
+ searchParams.set("current", nextPage);
+ window.history.replaceState({}, "", `${window.location.pathname}?${searchParams.toString()}`);
+ } catch (error) {
+ console.error("Failed to load more products:", error);
+ } finally {
+ setIsLoadingMore(false);
+ }
+ }
+ };
+
+ const observer = new IntersectionObserver(observerCallback, {
+ root: null, // viewport를 기준으로
+ rootMargin: "100px", // 100px 전에 미리 트리거
+ threshold: 0.1, // 10%만 보여도 트리거
+ });
+
+ observer.observe(sentinel);
+ console.log("Observer setup complete");
+
+ return observer;
+ };
+
+ // setTimeout을 사용하여 DOM 렌더링 후 observer 설정
+ const timeoutId = setTimeout(() => {
+ const observer = setupObserver();
+
+ // cleanup 함수에서 사용할 수 있도록 저장
+ if (observer) {
+ return () => {
+ const sentinel = document.querySelector("#infinite-scroll-trigger");
+ if (sentinel) {
+ observer.unobserve(sentinel);
+ }
+ observer.disconnect();
+ console.log("Observer cleaned up");
+ };
+ }
+ }, 100);
+
+ // cleanup
+ return () => {
+ clearTimeout(timeoutId);
+ };
+ }, [loading, isLoadingMore, pagination.page, pagination.total, products.length]);
+
+ return /* html */ `
+ ${SearchForm({
+ categoriesLoading,
+ categories,
+ filters,
+ pagination,
+ })}
+ ${ProductList({
+ loading,
+ products,
+ pagination,
+ isLoadingMore,
+ })}
+`;
+};
diff --git a/src/pages/PageLayout.js b/src/pages/PageLayout.js
new file mode 100644
index 00000000..58a73dc3
--- /dev/null
+++ b/src/pages/PageLayout.js
@@ -0,0 +1,22 @@
+import { Footer } from "../components/Footer";
+import { Header } from "../components/Header";
+import { CartModal } from "../components/CartModal";
+import { getCartItems, getSelectedItems } from "../utils/cartStore";
+import { isModalOpen } from "../utils/modalStore";
+
+export const PageLayout = ({ children, isDetailPage = false }) => {
+ const showModal = isModalOpen();
+ const cartItems = showModal ? getCartItems() : [];
+ const selectedItems = showModal ? getSelectedItems() : [];
+
+ return /* html */ `
+
+ ${Header({ isDetailPage })}
+
+ ${children}
+
+ ${Footer()}
+ ${showModal ? CartModal({ cartItems, selectedItems }) : ""}
+
+ `;
+};
diff --git a/src/router/index.js b/src/router/index.js
new file mode 100644
index 00000000..58bb8889
--- /dev/null
+++ b/src/router/index.js
@@ -0,0 +1,8 @@
+import { createRouter } from "./router.js";
+import { setupRoutes } from "./routes.js";
+
+// 라우터 생성 및 라우트 설정
+const router = createRouter();
+setupRoutes(router);
+
+export { router };
diff --git a/src/router/router.js b/src/router/router.js
new file mode 100644
index 00000000..9bf3850c
--- /dev/null
+++ b/src/router/router.js
@@ -0,0 +1,158 @@
+import { _404 } from "../pages/404";
+import { PageLayout } from "../pages/PageLayout";
+
+// 환경에 따라 BASE_PATH 설정
+const BASE_PATH = import.meta.env.MODE === "production" ? "/front_7th_chapter2-1" : "";
+
+// 베이스 경로를 제거한 pathname 반환
+const getPathWithoutBase = (pathname) => {
+ if (BASE_PATH && pathname.startsWith(BASE_PATH)) {
+ return pathname.slice(BASE_PATH.length) || "/";
+ }
+ return pathname;
+};
+
+// 베이스 경로를 포함한 전체 경로 반환
+const getFullPath = (pathname) => {
+ if (BASE_PATH && pathname.startsWith(BASE_PATH)) {
+ return pathname;
+ }
+ return BASE_PATH + pathname;
+};
+
+export const createRouter = () => {
+ const routes = new Map();
+ let currentRoute = null;
+
+ // 라우트 매칭
+ const matchRoute = (pathname) => {
+ // 베이스 경로 제거
+ const pathWithoutBase = getPathWithoutBase(pathname);
+
+ // 쿼리 파라미터 제거 (pathname만 사용)
+ const cleanPathname = pathWithoutBase.split("?")[0];
+
+ // 정확히 일치하는 라우트
+ if (routes.has(cleanPathname)) {
+ return { handler: routes.get(cleanPathname), params: {} };
+ }
+
+ // 동적 라우트 매칭
+ for (const [path, handler] of routes) {
+ const paramNames = [];
+ const regexPath = path.replace(/:([^/]+)/g, (_, paramName) => {
+ paramNames.push(paramName);
+ return "([^/]+)";
+ });
+
+ const regex = new RegExp(`^${regexPath}$`);
+ const match = cleanPathname.match(regex);
+
+ if (match) {
+ const params = {};
+ paramNames.forEach((name, index) => {
+ params[name] = match[index + 1];
+ });
+ return { handler, params };
+ }
+ }
+
+ return null;
+ };
+
+ const render = async (pathname = window.location.pathname) => {
+ const $root = document.querySelector("#root");
+ const match = matchRoute(pathname);
+
+ if (!match) {
+ $root.innerHTML = PageLayout({ children: _404 }); // 404도 레이아웃 적용
+ return;
+ }
+
+ try {
+ const content = await match.handler(match.params);
+ // 상세 페이지 경로인지 확인 (/product/:id)
+ const pathWithoutBase = getPathWithoutBase(pathname);
+ const isDetailPage = pathWithoutBase.split("?")[0].startsWith("/product/");
+ $root.innerHTML = PageLayout({ children: content, isDetailPage });
+ currentRoute = pathname;
+ } catch (error) {
+ console.error("Render error:", error);
+ $root.innerHTML = PageLayout({ children: "Error occurred
" });
+ }
+ };
+
+ const rerender = () => {
+ return render(window.location.pathname);
+ };
+
+ // 라우트 등록
+ const addRoute = (path, handler) => {
+ routes.set(path, handler);
+ return router;
+ };
+
+ // 경로 이동
+ const push = (path) => {
+ const fullPath = getFullPath(path);
+ window.history.pushState(null, null, fullPath);
+ render(fullPath);
+ };
+
+ // 경로 교체
+ const replace = (path) => {
+ const fullPath = getFullPath(path);
+ window.history.replaceState(null, null, fullPath);
+ render(fullPath);
+ };
+
+ // 뒤로가기
+ const back = () => {
+ window.history.back();
+ };
+
+ // 쿼리 파라미터
+ const getQueryParams = () => {
+ const params = new URLSearchParams(window.location.search);
+ return Object.fromEntries(params);
+ };
+
+ const pushWithQuery = (path, query = {}) => {
+ // 빈 문자열, null, undefined인 값 제거
+ const filteredQuery = Object.entries(query).reduce((acc, [key, value]) => {
+ if (value !== "" && value !== null && value !== undefined) {
+ acc[key] = value;
+ }
+ return acc;
+ }, {});
+
+ const queryString = new URLSearchParams(filteredQuery).toString();
+ const fullPath = queryString ? `${path}?${queryString}` : path;
+ push(fullPath);
+ };
+
+ // 초기화
+ const init = () => {
+ window.addEventListener("popstate", () => {
+ render();
+ });
+ render();
+ };
+
+ // 현재 라우트 정보
+ const getCurrentRoute = () => currentRoute;
+
+ const router = {
+ addRoute,
+ push,
+ replace,
+ back,
+ getQueryParams,
+ pushWithQuery,
+ init,
+ getCurrentRoute,
+ rerender,
+ };
+
+ return router;
+};
diff --git a/src/router/routes.js b/src/router/routes.js
new file mode 100644
index 00000000..94b1e5a3
--- /dev/null
+++ b/src/router/routes.js
@@ -0,0 +1,13 @@
+import { DetailPage } from "../pages/DetailPage.js";
+import { HomePage } from "../pages/HomePage.js";
+
+export const setupRoutes = (router) => {
+ router.addRoute("/", async () => {
+ return HomePage();
+ });
+ router.addRoute("/product/:id", async (params) => {
+ return DetailPage(params);
+ });
+
+ return router;
+};
diff --git a/src/styles.css b/src/styles.css
index 3824a8de..395562c9 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -38,7 +38,7 @@
}
/* 토스트 애니메이션 */
-@keyframes slide-up {
+@keyframes slideUp {
from {
transform: translateY(100px);
opacity: 0;
@@ -49,8 +49,19 @@
}
}
+@keyframes slideDown {
+ from {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ to {
+ transform: translateY(100px);
+ opacity: 0;
+ }
+}
+
.animate-slide-up {
- animation: slide-up 0.3s ease-out;
+ animation: slideUp 0.3s ease-out;
}
/* 모달 애니메이션 */
@@ -118,10 +129,6 @@ button:disabled {
}
}
-.animate-pulse {
- animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
-}
-
/* 스크롤바 스타일링 */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
diff --git a/src/template.js b/src/template.js
new file mode 100644
index 00000000..f2a1803e
--- /dev/null
+++ b/src/template.js
@@ -0,0 +1,1112 @@
+// const 상품목록_레이아웃_로딩 = `
+//
+//
+//
+//
+//
+// 쇼핑몰
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+
+//
+//
+//
+//
상품을 불러오는 중...
+//
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 상품목록_레이아웃_로딩완료 = `
+//
+//
+//
+//
+//
+// 쇼핑몰
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 총 340개의 상품
+//
+//
+//
+//
+//
+//
+//

+//
+//
+//
+//
+//
+// PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
+//
+//
+//
+// 220원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//

+//
+//
+//
+//
+//
+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
+//
+//
이지웨이건축자재
+//
+// 230원
+//
+//
+//
+//
+//
+//
+//
+
+//
+// 모든 상품을 확인했습니다
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 상품목록_레이아웃_카테고리_1Depth = `
+//
+//
+//
+//
+//
+
+//
+//
+
+//
+//
+//
+//
+// >
+//
+//
+//
+//
+//
+//
+//
+//
+//
+
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 상품목록_레이아웃_카테고리_2Depth = `
+//
+//
+//
+//
+//
+
+//
+//
+
+//
+//
+//
+//
+// >>주방용품
+//
+//
+//
+//
+//
+//
+//
+//
+//
+
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 토스트 = `
+//
+//
+//
+//
장바구니에 추가되었습니다
+//
+//
+
+//
+//
+//
선택된 상품들이 삭제되었습니다
+//
+//
+
+//
+//
+//
오류가 발생했습니다.
+//
+//
+//
+// `;
+
+// const 장바구니_비어있음 = `
+//
+//
+//
+//
+//
+//
+// 장바구니
+//
+
+//
+//
+
+//
+//
+//
+//
+//
+//
+//
장바구니가 비어있습니다
+//
원하는 상품을 담아보세요!
+//
+//
+//
+//
+//
+// `;
+
+// const 장바구니_선택없음 = `
+//
+//
+//
+//
+//
+//
+// 장바구니
+// (2)
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//

+//
+//
+//
+//
+// PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
+//
+//
+// 220원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 440원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//

+//
+//
+//
+//
+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
+//
+//
+// 230원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 230원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 총 금액
+// 670원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 장바구니_선택있음 = `
+//
+//
+//
+//
+//
+//
+// 장바구니
+// (2)
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//

+//
+//
+//
+//
+// PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
+//
+//
+// 220원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 440원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//

+//
+//
+//
+//
+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
+//
+//
+// 230원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 230원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 선택한 상품 (1개)
+// 440원
+//
+//
+//
+// 총 금액
+// 670원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 상세페이지_로딩 = /*html*/ `
+//
+//
+//
+//
+//
+//
+//
상품 상세
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
상품 정보를 불러오는 중...
+//
+//
+//
+//
+//
+// `;
+
+// const 상세페이지_로딩완료 = /*html*/ `
+//
+//
+//
+//
+//
+//
+//
상품 상세
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//

+//
+//
+//
+//
+//
PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
4.0 (749개 리뷰)
+//
+//
+//
+// 220원
+//
+//
+//
+// 재고 107개
+//
+//
+//
+// PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.
+//
+//
+//
+//
+//
+//
+//
수량
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
관련 상품
+//
같은 카테고리의 다른 상품들
+//
+//
+//
+//
+//
+//
+// `;
+
+// const _404_ = `
+//
+//
+//
+
+//
홈으로
+//
+//
+// `;
diff --git a/src/utils/cartStore.js b/src/utils/cartStore.js
new file mode 100644
index 00000000..a1868a77
--- /dev/null
+++ b/src/utils/cartStore.js
@@ -0,0 +1,159 @@
+/**
+ * 장바구니 상태 관리
+ */
+
+const STORAGE_KEY = "shopping_cart";
+
+// 장바구니 상태
+let cartItems = [];
+let selectedAll = false;
+let listeners = [];
+
+// localStorage에서 불러오기
+export function loadCart() {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ const data = JSON.parse(saved);
+ cartItems = data.items || [];
+ selectedAll = data.selectedAll || false;
+ }
+ } catch (error) {
+ console.error("Failed to load cart:", error);
+ }
+}
+
+// localStorage에 저장
+function saveCart() {
+ try {
+ const data = {
+ items: cartItems,
+ selectedAll: selectedAll,
+ };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
+ } catch (error) {
+ console.error("Failed to save cart:", error);
+ }
+}
+
+// 변경 알림
+function notifyListeners() {
+ listeners.forEach((listener) => listener({ cartItems, selectedAll }));
+}
+
+// 리스너 등록
+export function subscribeCart(listener) {
+ listeners.push(listener);
+ return () => {
+ listeners = listeners.filter((l) => l !== listener);
+ };
+}
+
+// 장바구니 아이템 가져오기
+export function getCartItems() {
+ return [...cartItems];
+}
+
+// 선택된 아이템 가져오기
+export function getSelectedItems() {
+ return cartItems.filter((item) => item.selected).map((item) => item.id);
+}
+
+// 장바구니에 추가
+export function addToCart(product) {
+ const existingItem = cartItems.find((item) => item.id === product.id);
+
+ if (existingItem) {
+ existingItem.quantity += 1;
+ } else {
+ cartItems.push({
+ id: product.id,
+ title: product.name,
+ price: product.price,
+ image: product.image,
+ quantity: 1,
+ selected: false,
+ });
+ }
+
+ saveCart();
+ notifyListeners();
+}
+
+// 수량 증가
+export function increaseQuantity(productId) {
+ const item = cartItems.find((item) => item.id === productId);
+ if (item) {
+ item.quantity += 1;
+ saveCart();
+ notifyListeners();
+ }
+}
+
+// 수량 감소
+export function decreaseQuantity(productId) {
+ const item = cartItems.find((item) => item.id === productId);
+ if (item && item.quantity > 1) {
+ item.quantity -= 1;
+ saveCart();
+ notifyListeners();
+ }
+}
+
+// 아이템 삭제
+export function removeFromCart(productId) {
+ cartItems = cartItems.filter((item) => item.id !== productId);
+ updateSelectedAll();
+ saveCart();
+ notifyListeners();
+}
+
+// 전체 비우기
+export function clearCart() {
+ cartItems = [];
+ selectedAll = false;
+ saveCart();
+ notifyListeners();
+}
+
+// 아이템 선택/해제
+export function toggleSelectItem(productId, isSelected) {
+ const item = cartItems.find((item) => item.id === productId);
+ if (item) {
+ item.selected = isSelected;
+ updateSelectedAll();
+ saveCart();
+ notifyListeners();
+ }
+}
+
+// selectedAll 상태 업데이트
+function updateSelectedAll() {
+ selectedAll = cartItems.length > 0 && cartItems.every((item) => item.selected);
+}
+
+// 전체 선택/해제
+export function toggleSelectAll(isSelected) {
+ selectedAll = isSelected;
+ cartItems.forEach((item) => {
+ item.selected = isSelected;
+ });
+ saveCart();
+ notifyListeners();
+}
+
+// 선택된 아이템 삭제
+export function removeSelectedItems() {
+ cartItems = cartItems.filter((item) => !item.selected);
+ selectedAll = false;
+ saveCart();
+ notifyListeners();
+}
+
+// 장바구니 아이템 개수
+export function getCartCount() {
+ return cartItems.length;
+}
+
+// 초기화
+loadCart();
diff --git a/src/utils/hooks.js b/src/utils/hooks.js
new file mode 100644
index 00000000..4b264cf6
--- /dev/null
+++ b/src/utils/hooks.js
@@ -0,0 +1,109 @@
+import { router } from "../router/index.js";
+
+const states = new Map();
+const effects = new Map();
+const cleanups = new Map();
+let currentComponent = null;
+let hookIndex = 0;
+
+/**
+ * React의 useState와 유사한 API
+ */
+export function useState(initialValue) {
+ const component = currentComponent;
+ const index = hookIndex++;
+ const key = `${component}-state-${index}`;
+
+ if (!states.has(key)) {
+ states.set(key, initialValue);
+ }
+
+ const state = states.get(key);
+
+ const setState = (newValue) => {
+ const value = typeof newValue === "function" ? newValue(states.get(key)) : newValue;
+
+ if (states.get(key) !== value) {
+ states.set(key, value);
+ scheduleRender();
+ }
+ };
+
+ return [state, setState];
+}
+
+/**
+ * React의 useEffect와 유사한 API
+ */
+export function useEffect(effect, deps) {
+ const component = currentComponent;
+ const index = hookIndex++;
+ const key = `${component}-effect-${index}`;
+
+ const prevDeps = effects.get(key);
+ const hasChanged = !prevDeps || !deps || deps.some((dep, i) => dep !== prevDeps[i]);
+
+ if (hasChanged) {
+ // 이전 cleanup 실행
+ const cleanup = cleanups.get(key);
+ if (cleanup) {
+ cleanup();
+ }
+
+ // 새로운 effect 실행
+ Promise.resolve().then(() => {
+ const cleanupFn = effect();
+ if (typeof cleanupFn === "function") {
+ cleanups.set(key, cleanupFn);
+ }
+ });
+
+ effects.set(key, deps);
+ }
+}
+
+/**
+ * 컴포넌트 시작 시 호출
+ */
+export function setCurrentComponent(name) {
+ currentComponent = name;
+ hookIndex = 0;
+}
+
+/**
+ * 컴포넌트 언마운트 시 호출
+ */
+export function cleanupComponent(componentName) {
+ // cleanup 함수 실행
+ for (const [key, cleanup] of cleanups.entries()) {
+ if (key.startsWith(componentName)) {
+ cleanup();
+ cleanups.delete(key);
+ }
+ }
+
+ // 상태와 effect 정리
+ for (const key of states.keys()) {
+ if (key.startsWith(componentName)) {
+ states.delete(key);
+ }
+ }
+
+ for (const key of effects.keys()) {
+ if (key.startsWith(componentName)) {
+ effects.delete(key);
+ }
+ }
+}
+
+// 배치 렌더링
+let renderScheduled = false;
+function scheduleRender() {
+ if (renderScheduled) return;
+
+ renderScheduled = true;
+ Promise.resolve().then(() => {
+ renderScheduled = false;
+ router.rerender();
+ });
+}
diff --git a/src/utils/modalStore.js b/src/utils/modalStore.js
new file mode 100644
index 00000000..dc861356
--- /dev/null
+++ b/src/utils/modalStore.js
@@ -0,0 +1,39 @@
+/**
+ * 모달 상태 관리
+ */
+
+let isCartModalOpen = false;
+let listeners = [];
+
+export function isModalOpen() {
+ return isCartModalOpen;
+}
+
+export function openModal() {
+ isCartModalOpen = true;
+ document.body.style.overflow = "hidden";
+ notifyListeners();
+}
+
+export function closeModal() {
+ isCartModalOpen = false;
+ document.body.style.overflow = "";
+ notifyListeners();
+}
+
+export function toggleModal() {
+ isCartModalOpen = !isCartModalOpen;
+ document.body.style.overflow = isCartModalOpen ? "hidden" : "";
+ notifyListeners();
+}
+
+function notifyListeners() {
+ listeners.forEach((listener) => listener(isCartModalOpen));
+}
+
+export function subscribeModal(listener) {
+ listeners.push(listener);
+ return () => {
+ listeners = listeners.filter((l) => l !== listener);
+ };
+}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 00000000..3840fbaf
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,16 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig(({ mode }) => ({
+ test: {
+ globals: true,
+ environment: "jsdom",
+ setupFiles: "./src/setupTests.js",
+ exclude: ["**/e2e/**", "**/*.e2e.spec.js", "**/node_modules/**"],
+ poolOptions: {
+ threads: {
+ singleThread: true,
+ },
+ },
+ },
+ base: mode === "production" ? "/front_7th_chapter2-1/" : "/",
+}));