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호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-
-

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

-

-

- 220원 -

-
- - -
-
-
- -
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -
- -
-
-

- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -

-

이지웨이건축자재

-

- 230원 -

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

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

-

-

- 280원 -

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

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

-

-

- 350원 -

-
- - -
-
-
- -
- 방충망 셀프교체 미세먼지 롤 창문 모기장 알루미늄망 60cmX20cm -
- -
-
-

- 방충망 셀프교체 미세먼지 롤 창문 모기장 알루미늄망 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 */ ` +
+
+ ${content} +
+
+`; + +/** + * 헤더 + */ +const CartModalHeader = (itemCount = 0) => /* html */ ` +
+

+ + + + 장바구니 + ${itemCount > 0 ? `(${itemCount})` : ""} +

+ +
+`; + +/** + * 컨텐츠 래퍼 + */ +const CartModalContentWrapper = (content) => /* html */ ` +
+ ${content} +
+`; + +/** + * 전체 선택 섹션 + */ +const CartModalSelectAll = (itemCount, isAllSelected = false) => /* html */ ` +
+ +
+`; + +/** + * 아이템 목록 래퍼 + */ +const CartModalItemsWrapper = (items) => /* html */ ` +
+
+ ${items} +
+
+`; + +/** + * 개별 아이템 카드 + */ +const CartModalItem = (item, isSelected = false) => /* html */ ` +
+ + + + +
+ ${item.title} +
+ + +
+

+ ${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.title} +
+ +
+

${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} +
+ +
+
+

+ ${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호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-
-

- 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호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-

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

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

- -
-
-
- - - -
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -
- -
-

- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

- -
-
-
-
-
- -
- - -
- 총 금액 - 670원 -
- -
-
- - -
-
-
-
-
- `; + // 수량 증가 + const increaseBtn = e.target.closest(".quantity-increase-btn"); + if (increaseBtn) { + increaseQuantity(increaseBtn.dataset.productId); + return; + } - const 장바구니_선택있음 = ` -
-
- -
-

- - - - 장바구니 - (2) -

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

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

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

- -
-
-
- - - -
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -
- -
-

- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -

-

- 230원 -

- -
- - - -
-
- -
-

- 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호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-

-

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

- -
-
- - - - - - - - - - - - - - - -
- 4.0 (749개 리뷰) -
- -
- 220원 -
- -
- 재고 107개 -
- -
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다. -
-
-
- -
-
- 수량 -
- - - -
-
- - -
-
- -
- -
- -
-
-

관련 상품

-

같은 카테고리의 다른 상품들

-
-
-
- - -
-
-
-
- -
- `; + // 선택한 상품 삭제 + if (e.target.closest("#cart-modal-remove-selected-btn")) { + removeSelectedItems(); + return; + } - const _404_ = ` -
-
- - - - - - - - - - - - - 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 = `
+ + + + + + + + + + + + + 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호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +//
+// +//
+//
+//

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

+//

+//

+// 220원 +//

+//
+// +// +//
+//
+//
+// +//
+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +//
+// +//
+//
+//

+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +//

+//

이지웨이건축자재

+//

+// 230원 +//

+//
+// +// +//
+//
+//
+ +//
+// 모든 상품을 확인했습니다 +//
+//
+//
+//
+// +//
+// `; + +// const 상품목록_레이아웃_카테고리_1Depth = ` +//
+// +//
+// +//
+//
+// +//
+// +// +// +//
+//
+//
+ +// +//
+ +// +//
+//
+// +// > +//
+//
+//
+// +// +// +//
+//
+//
+ +// +//
+// +//
+// +// +//
+// +//
+// +// +//
+//
+//
+//
+//
+// `; + +// const 상품목록_레이아웃_카테고리_2Depth = ` +//
+// +//
+// +//
+//
+// +//
+// +// +// +//
+//
+//
+ +// +//
+ +// +//
+//
+// +// >>주방용품 +//
+//
+//
+// +// +// +//
+//
+//
+ +// +//
+// +//
+// +// +//
+// +//
+// +// +//
+//
+//
+//
+//
+// `; + +// const 토스트 = ` +//
+//
+//
+// +// +// +//
+//

장바구니에 추가되었습니다

+// +//
+ +//
+//
+// +// +// +//
+//

선택된 상품들이 삭제되었습니다

+// +//
+ +//
+//
+// +// +// +//
+//

오류가 발생했습니다.

+// +//
+//
+// `; + +// const 장바구니_비어있음 = ` +//
+//
+// +//
+//

+// +// +// +// 장바구니 +//

+ +// +//
+ +// +//
+// +//
+//
+//
+// +// +// +//
+//

장바구니가 비어있습니다

+//

원하는 상품을 담아보세요!

+//
+//
+//
+//
+//
+// `; + +// const 장바구니_선택없음 = ` +//
+//
+// +//
+//

+// +// +// +// 장바구니 +// (2) +//

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

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

+//

+// 220원 +//

+// +//
+// +// +// +//
+//
+// +//
+//

+// 440원 +//

+// +//
+//
+//
+// +// +// +//
+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +//
+// +//
+//

+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +//

+//

+// 230원 +//

+// +//
+// +// +// +//
+//
+// +//
+//

+// 230원 +//

+// +//
+//
+//
+//
+//
+// +//
+// +// +//
+// 총 금액 +// 670원 +//
+// +//
+//
+// +// +//
+//
+//
+//
+//
+// `; + +// const 장바구니_선택있음 = ` +//
+//
+// +//
+//

+// +// +// +// 장바구니 +// (2) +//

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

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

+//

+// 220원 +//

+// +//
+// +// +// +//
+//
+// +//
+//

+// 440원 +//

+// +//
+//
+//
+// +// +// +//
+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +//
+// +//
+//

+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +//

+//

+// 230원 +//

+// +//
+// +// +// +//
+//
+// +//
+//

+// 230원 +//

+// +//
+//
+//
+//
+//
+// +//
+// +//
+// 선택한 상품 (1개) +// 440원 +//
+// +//
+// 총 금액 +// 670원 +//
+// +//
+// +//
+// +// +//
+//
+//
+//
+//
+// `; + +// const 상세페이지_로딩 = /*html*/ ` +//
+//
+//
+//
+//
+// +//

상품 상세

+//
+//
+// +// +//
+//
+//
+//
+//
+//
+//
+//
+//

상품 정보를 불러오는 중...

+//
+//
+//
+// +//
+// `; + +// const 상세페이지_로딩완료 = /*html*/ ` +//
+//
+//
+//
+//
+// +//

상품 상세

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

+//

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

+// +//
+//
+// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +//
+// 4.0 (749개 리뷰) +//
+// +//
+// 220원 +//
+// +//
+// 재고 107개 +//
+// +//
+// PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다. +//
+//
+//
+// +//
+//
+// 수량 +//
+// +// +// +//
+//
+// +// +//
+//
+// +//
+// +//
+// +//
+//
+//

관련 상품

+//

같은 카테고리의 다른 상품들

+//
+//
+//
+// +// +//
+//
+//
+//
+// +//
+// `; + +// const _404_ = ` +//
+//
+// +// +// +// +// +// +// +// +// +// + +// +// 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/" : "/", +}));