`;
+const renderCartLayout = (cartProducts = []) => {
+ const products = Array.isArray(cartProducts) ? cartProducts : [];
+ if (products.length === 0) return EmptyCart;
-export const CartList = ({ cartProducts }) => {
- const products = cartProducts ? cartProducts : [];
- console.log(products);
return /*html*/ `
-
-
-
-
-
+
+
+
-
- ${products.map((product) => cartItem(product)).join("")}
-
+
+
+ ${products.map((product) => cartItem(product)).join("")}
+
+
-
-
-
-
-
- 총 금액
- 670원
-
-
-
-
-
-
-
-
-
+
+
+ 총 금액
+ 670원
+
+
+
+
+
+
- `;
+
+`;
+};
+
+const updateCartListView = (cartProducts = []) => {
+ const container = document.getElementById(CART_LIST_CONTAINER_ID);
+ if (!container) return false;
+
+ if (!container.isConnected) {
+ destroyCartList();
+ return false;
+ }
+
+ container.innerHTML = renderCartLayout(cartProducts);
+ return true;
+};
+
+const mountCartList = () => {
+ destroyCartList();
+ const mounted = updateCartListView(cartStore.state.cart);
+ if (!mounted) return false;
+
+ const unsubscribe = cartStore.subscribe((cartProducts) => {
+ updateCartListView(cartProducts);
+ });
+
+ cleanupCartList = () => {
+ unsubscribe();
+ cleanupCartList = null;
+ };
+};
+
+const scheduleCartListMount = () => {
+ if (typeof window === "undefined") return;
+
+ window.requestAnimationFrame(() => {
+ if (!mountCartList()) {
+ window.setTimeout(mountCartList, 0);
+ }
+ });
+};
+
+export const destroyCartList = () => {
+ if (cleanupCartList) {
+ cleanupCartList();
+ }
+};
+
+export const CartList = () => {
+ scheduleCartListMount();
+
+ return /*html*/ `
+
+ ${renderCartLayout(cartStore.state.cart)}
+
+`;
};
diff --git a/src/main.js b/src/main.js
index 9174613f..17b20330 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,6 +1,10 @@
import { getCategories, getProduct, getProducts } from "./api/productApi.js";
import { ProductList } from "./components/ProductList.js";
+import { destroyCartList } from "./components/cart/CartList.js";
+import { showToast } from "./components/toast/Toast.js";
+import { closeCartModal, openCartModal } from "./pages/CartModal.js";
import { NotFoundPage } from "./pages/NotFoundPage.js";
+import { cartStore } from "./store/cartStore.js";
import { updateCategoryUI } from "./utils/categoryUI.js";
import { findRoute, initRouter, push } from "./utils/router.js";
@@ -10,8 +14,10 @@ const enableMocking = () =>
onUnhandledRequest: "bypass",
}),
);
+const productCache = new Map();
const render = async () => {
+ destroyCartList();
const $root = document.querySelector("#root");
const { route, params } = findRoute(location.pathname);
@@ -25,21 +31,30 @@ const render = async () => {
if (route.path === "/") {
$root.innerHTML = route.component({ loading: true });
const query = new URLSearchParams(location.search);
+ selectedCat1 = query.get("category1") || null;
+ selectedCat2 = query.get("category2") || null;
const search = query.get("search") || "";
- const limit = Number(query.get("limit")) || 20;
+ const limit = Number(query.get("limit")) || 500;
const sort = query.get("sort") || "price_asc";
//TODO : Q. 병렬 구성은 어려울까?? (렌더링을 html태그 단위로 하게 될것같아서, 내부에서 처리해야하나???)
const [categories, products] = await Promise.all([
getCategories(),
getProducts({ search: search, category1: selectedCat1, category2: selectedCat2, limit, sort }),
]);
+
+ products.products.forEach((product) => {
+ productCache.set(product.productId, product);
+ });
$root.innerHTML = route.component({
categories,
products,
loading: false,
+ selectedCat1,
+ selectedCat2,
+ currentSearch: search,
});
} else if (route.path === "/cart") {
- $root.innerHTML = route.component({ cartProducts: ["pr"] });
+ $root.innerHTML = route.component();
} else if (route.path === "/products/:id") {
$root.innerHTML = route.component({ loading: true });
const productId = params[0];
@@ -55,7 +70,7 @@ const render = async () => {
const refreshProducts = async () => {
const query = new URLSearchParams(location.search);
const search = query.get("search") || "";
- const limit = Number(query.get("limit")) || 20;
+ const limit = Number(query.get("limit")) || 500;
const sort = query.get("sort") || "price_asc";
const [products] = await Promise.all([
getProducts({ search, category1: selectedCat1, category2: selectedCat2, limit, sort }),
@@ -71,7 +86,14 @@ const refreshProducts = async () => {
query.set("search", "");
};
-//TODO: Q. 이렇게 모든 액션에 대해 이벤트 등록을 해야한다고??
+const pushWithNoRender = ({ path, selectedCat1 = null, selectedCat2 = null }) => {
+ const isHome = location.pathname === "/";
+ push(path, { silent: isHome });
+ if (isHome) {
+ refreshProducts();
+ updateCategoryUI(selectedCat1, selectedCat2);
+ }
+};
/* 이벤트 등록 영역 */
// 카테고리 상태 관리
let selectedCat1 = null;
@@ -80,6 +102,64 @@ let selectedCat2 = null;
document.body.addEventListener("click", (e) => {
const target = e.target;
+ //장바구니 이동
+ const openCartModalBtn = target.closest("#cart-icon-btn");
+ if (openCartModalBtn) {
+ e.preventDefault();
+ openCartModal();
+ return;
+ }
+ const closeCartModalBtn = target.closest("#cart-modal-close-btn");
+ const closeCartOverlay = target.closest(".cart-modal-overlay");
+ const closeCart = closeCartModalBtn || closeCartOverlay;
+ if (closeCart) {
+ e.preventDefault();
+ closeCartModal();
+ return;
+ }
+
+ // 장바구니 추가 버튼 클릭
+ const addToCartBtnMain = target.closest(".add-to-cart-btn");
+ const addToCartBtnDetail = target.closest("#add-to-cart-btn");
+ const addToCartBtn = addToCartBtnDetail || addToCartBtnMain;
+
+ if (addToCartBtn) {
+ e.stopPropagation();
+ const product = productCache.get(addToCartBtn.dataset.productId);
+
+ const quantityInput = document.getElementById("quantity-input");
+ const quantity = quantityInput ? Math.max(1, Number(quantityInput.value) || 1) : 1;
+
+ cartStore.addToCart({ ...product, quantity });
+ showToast("addCart");
+ return;
+ }
+
+ // 장바구니 갯수 변경
+ const cartIncreaseBtn = target.closest(".quantity-increase-btn");
+ const cartDecreaseBtn = target.closest(".quantity-decrease-btn");
+ if (cartIncreaseBtn) {
+ console.log("increase clicked");
+ const productId = cartIncreaseBtn.dataset.productId;
+ cartStore.addToCart({ productId: productId });
+ }
+ if (cartDecreaseBtn) {
+ const productId = cartDecreaseBtn.dataset.productId;
+ cartStore.decreaseQuantity(productId);
+ }
+
+ const cartQuantityIncreaseBtn = target.closest("#quantity-increase");
+ const cartQuantityDecreaseBtn = target.closest("#quantity-decrease");
+ if (cartQuantityIncreaseBtn) {
+ console.log("increase clicked");
+ const quantityInput = document.getElementById("quantity-input");
+ quantityInput.value = Number(quantityInput.value) + 1;
+ }
+ if (cartQuantityDecreaseBtn) {
+ const quantityInput = document.getElementById("quantity-input");
+ quantityInput.value = Math.max(1, Number(quantityInput.value) - 1);
+ }
+
//상품 카드 클릭
const productCard = target.closest(".product-card");
if (productCard) {
@@ -90,7 +170,7 @@ document.body.addEventListener("click", (e) => {
// 카테고리 필터 클릭
const resetBtn = target.closest('[data-breadcrumb="reset"]');
const cat1Btn = target.closest(".category1-filter-btn, [data-breadcrumb='category1']");
- const cat2Btn = target.closest(".category2-filter-btn");
+ const cat2Btn = target.closest(".category2-filter-btn, [data-breadcrumb='category2']");
if (resetBtn) {
selectedCat1 = null;
@@ -99,10 +179,8 @@ document.body.addEventListener("click", (e) => {
const query = new URLSearchParams(location.search);
query.delete("category1");
query.delete("category2");
- history.pushState(null, null, query.toString() ? `/?${query}` : "/");
- updateCategoryUI(selectedCat1, selectedCat2);
- refreshProducts();
+ pushWithNoRender({ path: query.toString() ? `/?${query}` : "/" });
} else if (cat1Btn) {
selectedCat1 = cat1Btn.dataset.category1;
selectedCat2 = null;
@@ -111,9 +189,7 @@ document.body.addEventListener("click", (e) => {
query.set("category1", selectedCat1);
query.delete("category2");
- history.pushState(null, null, `/?${query}`);
- updateCategoryUI(selectedCat1, selectedCat2);
- refreshProducts();
+ pushWithNoRender({ path: `/?${query}`, selectedCat1, selectedCat2 });
} else if (cat2Btn) {
selectedCat1 = cat2Btn.dataset.category1;
selectedCat2 = cat2Btn.dataset.category2;
@@ -122,9 +198,7 @@ document.body.addEventListener("click", (e) => {
query.set("category1", selectedCat1);
query.set("category2", selectedCat2);
- history.pushState(null, null, `/?${query}`);
- updateCategoryUI(selectedCat1, selectedCat2);
- refreshProducts();
+ pushWithNoRender({ path: `/?${query}`, selectedCat1, selectedCat2 });
}
});
@@ -136,8 +210,9 @@ document.addEventListener("change", (e) => {
const query = new URLSearchParams(location.search);
if (selectedLimit) query.set("limit", selectedLimit);
if (selectedSort) query.set("sort", selectedSort);
- history.pushState(null, null, `/?${query}`);
- refreshProducts();
+ pushWithNoRender({ path: `/?${query}`, selectedCat1: null, selectedCat2: null });
+ // history.pushState(null, null, `/?${query}`);
+ // refreshProducts();
});
document.addEventListener("keydown", (e) => {
@@ -147,13 +222,25 @@ document.addEventListener("keydown", (e) => {
const query = new URLSearchParams(location.search);
if (keyword) {
query.set("search", keyword);
- history.pushState(null, null, `/?${query}`);
+ console.error(keyword);
+ // pushWithNoRender({ path: `/?${query}`, selectedCat1: null, selectedCat2: null });
+ push(`/?${query}`);
+ // history.pushState(null, null, `/?${query}`);
} else {
query.delete("search");
- history.pushState(null, null, `/`);
+ pushWithNoRender({ path: `/`, selectedCat1: null, selectedCat2: null });
+ // history.pushState(null, null, `/`);
}
- refreshProducts();
+ // refreshProducts();
+ }
+ const isCartModalOpen = () => {
+ return document.getElementById("cart-modal-root")?.hasChildNodes() !== null;
+ };
+ if (e.key === "Escape" && isCartModalOpen()) {
+ e.preventDefault();
+ closeCartModal();
+ return;
}
});
diff --git a/src/pages/CartModal.js b/src/pages/CartModal.js
new file mode 100644
index 00000000..f549f146
--- /dev/null
+++ b/src/pages/CartModal.js
@@ -0,0 +1,53 @@
+import { CartHeader } from "../components/cart/CartHeader";
+import { CartList } from "../components/cart/CartList";
+
+let isOpen = false;
+const MODAL_ROOT_ID = "cart-modal-root";
+export const CartModal = () => {
+ return /*html*/ `
+
+`;
+};
+
+const CartModalTemplate = () => /*html*/ `
+
+
+
+
+ ${CartHeader()}
+ ${CartList()}
+
+
+
+`;
+
+const getModalRoot = () => {
+ let root = document.getElementById(MODAL_ROOT_ID);
+ if (!root) {
+ root = document.createElement("div");
+ root.id = MODAL_ROOT_ID;
+ document.body.appendChild(root);
+ }
+ return root;
+};
+
+export const openCartModal = () => {
+ console.log("openCartModal called");
+ if (isOpen) return;
+ const root = getModalRoot();
+ console.log("Modal root:", root);
+ root.innerHTML = CartModalTemplate();
+ document.body.classList.add("overflow-hidden");
+ isOpen = true;
+};
+
+export const closeCartModal = () => {
+ const root = document.getElementById(MODAL_ROOT_ID);
+ if (!root) return;
+ root.innerHTML = "";
+ document.body.classList.remove("overflow-hidden");
+
+ isOpen = false;
+};
diff --git a/src/store/cartStore.js b/src/store/cartStore.js
new file mode 100644
index 00000000..836dd08b
--- /dev/null
+++ b/src/store/cartStore.js
@@ -0,0 +1,60 @@
+export const cartStore = {
+ state: { cart: [] },
+ // state: {
+ // cart: [
+ // { productId: "1", title: "샘플상품", image: "sample.jpg", lprice: 1000, quantity: 1 },
+ // { productId: "2", title: "샘플상품", image: "sample.jpg", lprice: 1000, quantity: 1 },
+ // ],
+ // },
+ observers: [],
+ subscribe(observerFn) {
+ if (typeof observerFn !== "function") return () => {};
+
+ this.observers.push(observerFn);
+ observerFn(this.state.cart);
+
+ return () => {
+ this.observers = this.observers.filter((observer) => observer !== observerFn);
+ };
+ },
+ notify() {
+ console.log("cartStore 상태 변경 알림:", this.state.cart);
+ this.observers.forEach((observerFn) => observerFn(this.state.cart));
+ },
+ getTotalCount() {
+ return this.state.cart.length;
+ },
+ addToCart(product) {
+ const existingItem = this.state.cart.find((item) => item.productId === product.productId);
+ if (existingItem) {
+ console.log("존재하는 장바구니에 추가:", existingItem);
+ existingItem.quantity += 1;
+ } else {
+ console.log("장바구니에 추가:", product);
+ const cartItem = {
+ productId: product.productId,
+ title: product.title || "",
+ image: product.image || "",
+ lprice: product.lprice || 0,
+ quantity: product.quantity || 1,
+ };
+ this.state.cart.push(cartItem);
+ }
+ this.notify();
+ },
+ decreaseQuantity(productId) {
+ const item = this.state.cart.find((item) => item.productId === productId);
+ if (item && item.quantity > 1) {
+ item.quantity -= 1;
+ this.notify();
+ }
+ },
+ removeFromCart(productId) {
+ this.state.cart = this.state.cart.filter((item) => item.productId !== productId);
+ this.notify();
+ },
+ clearCart() {
+ this.state.cart = [];
+ this.notify();
+ },
+};
diff --git a/src/utils/router.js b/src/utils/router.js
index 1953b09a..f4d13b77 100644
--- a/src/utils/router.js
+++ b/src/utils/router.js
@@ -1,10 +1,8 @@
-import { CartPage } from "../pages/CartPage";
import { DetailPage } from "../pages/DetailPage";
import { HomePage } from "../pages/HomePage";
const routes = [
{ path: "/", component: HomePage },
- { path: "/cart", component: CartPage },
{ path: "/products/:id", component: DetailPage },
];
@@ -24,13 +22,20 @@ export const findRoute = (pathname) => {
return null;
};
-export const push = (path) => {
- history.pushState(null, null, path);
+export const push = (path, { silent = false } = {}) => {
+ const current = `${location.pathname}${location.search}`;
+ if (current === path) return;
- // 화면 다시 그리기
- // main.js의 render() 함수가 호출되어야 함
- window.dispatchEvent(new Event("route-change"));
+ history.pushState(null, null, path);
+ if (!silent) {
+ window.dispatchEvent(new Event("route-change"));
+ }
};
+// history.pushState(null, null, path);
+// // 화면 다시 그리기
+// // main.js의 render() 함수가 호출되어야 함
+// window.dispatchEvent(new Event("route-change"));
+// };
export const initRouter = (renderFn) => {
renderFn();
From 1663aacceda8196bafd732808a89add85651b8a2 Mon Sep 17 00:00:00 2001
From: JiYoung Park
Date: Thu, 13 Nov 2025 02:29:57 +0900
Subject: [PATCH 07/24] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?=
=?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=9D=BC?=
=?UTF-8?q?=EB=B6=80=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/Category.js | 79 +++++++---
src/components/ProductDetail.js | 130 ++++++++++++++++
src/components/ProductList.js | 10 +-
src/components/SearchForm.js | 6 +-
src/components/index.js | 4 +-
src/components/layout/BreadCrumb.js | 1 +
src/components/{ => layout}/Footer.js | 0
src/components/layout/Header.js | 18 +++
.../{Header.js => layout/HedearDetail.js} | 23 +--
src/pages/DetailPage.js | 144 ++----------------
src/pages/HomePage.js | 4 +-
src/pages/PageLayout.js | 5 +-
12 files changed, 242 insertions(+), 182 deletions(-)
create mode 100644 src/components/ProductDetail.js
create mode 100644 src/components/layout/BreadCrumb.js
rename src/components/{ => layout}/Footer.js (100%)
create mode 100644 src/components/layout/Header.js
rename src/components/{Header.js => layout/HedearDetail.js} (53%)
diff --git a/src/components/Category.js b/src/components/Category.js
index 4853ffad..59319d82 100644
--- a/src/components/Category.js
+++ b/src/components/Category.js
@@ -1,18 +1,68 @@
-export const Category = ({ categories }) => {
+export const Category = ({ categories = {}, selectedCat1 = null, selectedCat2 = null }) => {
const category1List = Object.keys(categories);
+ const cat1ContainerClasses = ["flex", "flex-wrap", "gap-2"];
+ if (selectedCat1 || selectedCat2) cat1ContainerClasses.push("hidden");
+
+ const selectedCategory2Class =
+ "category2-filter-btn text-left px-3 py-2 text-sm rounded-md border transition-colors bg-blue-100 border-blue-300 text-blue-800";
+ const defaultCategory2Class =
+ "category2-filter-btn text-left px-3 py-2 text-sm rounded-md border transition-colors bg-white border-gray-300 text-gray-700 hover:bg-gray-50";
+
+ const renderBreadcrumb = () => /*html*/ `
+
+
+ ${
+ selectedCat1
+ ? `
+ >
+ `
+ : ""
+ }
+ ${
+ selectedCat2
+ ? `
+ >
+ ${selectedCat2}`
+ : ""
+ }
+ `;
+
+ const renderCategory2Group = (cat1Name) => {
+ const category2List = Object.keys(categories[cat1Name]);
+ const isVisible = selectedCat1 === cat1Name;
+ const groupClasses = ["category2-group", "flex", "flex-wrap", "gap-2", isVisible ? "" : "hidden"]
+ .filter(Boolean)
+ .join(" ");
+
+ return /*html*/ `
+
+ ${category2List
+ .map((cat2Name) => {
+ const isSelected = selectedCat1 === cat1Name && selectedCat2 === cat2Name;
+ const buttonClass = isSelected ? selectedCategory2Class : defaultCategory2Class;
+ return `
+ `;
+ })
+ .join("")}
+
`;
+ };
+
return /*html*/ `
-
-
+ ${renderBreadcrumb()}
-
+
${category1List
.map(
(cat1Name) => `
@@ -24,25 +74,8 @@ export const Category = ({ categories }) => {
.join("")}
-
- ${category1List
- .map((cat1Name) => {
- const category2List = Object.keys(categories[cat1Name]);
- return `
-
- ${category2List
- .map((cat2Name) => {
- return `
- `;
- })
- .join("")}
-
- `;
- })
- .join("")}
-
+
+ ${category1List.map((cat1Name) => renderCategory2Group(cat1Name)).join("")}
`;
};
diff --git a/src/components/ProductDetail.js b/src/components/ProductDetail.js
new file mode 100644
index 00000000..0a4271ae
--- /dev/null
+++ b/src/components/ProductDetail.js
@@ -0,0 +1,130 @@
+export const ProductDetail = ({ product }) => {
+ return /*html*/ `
+
+
+
+
+
+
+
+
+

+
+
+
+
+
${product.title}
+
+
+
+
+
+
+
+
+
+
${product.rating} (${product.reviewCount}개 리뷰)
+
+
+
+ ${Number(product.lprice).toLocaleString()}
+
+
+
+ 재고 ${product.stock}개
+
+
+
+ ${product.description}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
관련 상품
+
같은 카테고리의 다른 상품들
+
+
+
+
+`;
+};
diff --git a/src/components/ProductList.js b/src/components/ProductList.js
index 0a475975..e44cf309 100644
--- a/src/components/ProductList.js
+++ b/src/components/ProductList.js
@@ -20,7 +20,7 @@ const Loading = `
`;
-const ProductItem = ({ productId, title, image, lprice }) => {
+const ProductItem = ({ productId, title, image, lprice, brand }) => {
return /*html*/ `
@@ -30,17 +30,15 @@ const ProductItem = ({ productId, title, image, lprice }) => {
-
- ${title}
-
-
+
${title}
+
${brand}
${Number(lprice).toLocaleString()}원
diff --git a/src/components/SearchForm.js b/src/components/SearchForm.js
index 8fe9fe75..b4d55723 100644
--- a/src/components/SearchForm.js
+++ b/src/components/SearchForm.js
@@ -1,13 +1,13 @@
import { Category } from "./Category";
-export const SearchForm = ({ categories }) => {
+export const SearchForm = ({ categories, selectedCat1 = null, selectedCat2 = null, currentSearch = "" }) => {
return /*html*/ `
-