diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 15a3a274..185416e5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -32,7 +32,11 @@ **상품 정렬 기능** +<<<<<<< HEAD - [ ] 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다. +======= +- [ ] 상품을 가격순/인기순으로 오름차순/내림차순 정렬을 할 수 있다. +>>>>>>> caecb93 (feat: 기본 코드 추가) - [ ] 드롭다운을 통해 정렬 기준을 선택할 수 있다 - [ ] 정렬 변경 시 즉시 목록에 반영된다 @@ -52,6 +56,10 @@ **상품 검색** - [ ] 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +<<<<<<< HEAD +======= +- [ ] 검색 버튼 클릭으로 검색이 수행된다 +>>>>>>> caecb93 (feat: 기본 코드 추가) - [ ] Enter 키로 검색이 수행된다 - [ ] 검색어와 일치하는 상품들만 목록에 표시된다 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..85e51b69 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,66 @@ +name: Deploy to GitHub Pages + +on: + push: # push trigger + branches: + - main + - feature-* # Feature 브랜치도 배포 + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "./dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + - name: Output deployment URL + run: | + REPO_NAME=$(echo ${{ github.repository }} | cut -d'/' -f2) + OWNER=$(echo ${{ github.repository }} | cut -d'/' -f1) + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 GitHub Pages Deployment Complete" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "✅ Status: Success" + echo "📄 Live URL: https://${OWNER}.github.io/${REPO_NAME}/" + echo "📌 Repository: ${{ github.repository }}" + echo "📝 Branch: ${{ github.ref_name }}" + echo "🔗 Commit: ${{ github.sha }}" + echo "👤 Author: ${{ github.actor }}" + echo "⏰ Time: $(date)" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/.prettierrc b/.prettierrc index 1d2699e4..6b3f49f3 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,6 @@ "tabWidth": 2, "semi": true, "singleQuote": false, - "printWidth": 120 + "printWidth": 120, + "trailingComma": "all" } diff --git a/package.json b/package.json index 5ec7f3f3..766ff157 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", + "packageManager": "pnpm@9.0.0", "scripts": { "dev": "vite", "dev:hash": "vite --open ./index.hash.html", @@ -40,7 +41,6 @@ "jsdom": "^25.0.1", "lint-staged": "^15.2.11", "msw": "^2.10.2", - "prettier": "^3.4.2", "vite": "npm:rolldown-vite@latest", "vitest": "latest" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8137d4c8..6cfec257 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,9 +53,6 @@ importers: msw: specifier: ^2.10.2 version: 2.10.2 - prettier: - specifier: ^3.4.2 - version: 3.5.3 vite: specifier: npm:rolldown-vite@latest version: rolldown-vite@6.3.21(esbuild@0.25.1)(yaml@2.7.0) diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index d2b72964..b1f186b6 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -100,10 +100,7 @@ addEventListener("fetch", function (event) { // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === "only-if-cached" && - event.request.mode !== "same-origin" - ) { + if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") { return; } @@ -219,9 +216,7 @@ async function getResponse(event, client, requestId) { const acceptHeader = headers.get("accept"); if (acceptHeader) { const values = acceptHeader.split(",").map((value) => value.trim()); - const filteredValues = values.filter( - (value) => value !== "msw/passthrough", - ); + const filteredValues = values.filter((value) => value !== "msw/passthrough"); if (filteredValues.length > 0) { headers.set("accept", filteredValues.join(", ")); @@ -291,10 +286,7 @@ function sendToClient(client, message, transferrables = []) { resolve(event.data); }; - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]); + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); }); } diff --git a/requirement.md b/requirement.md index d450ef17..61855590 100644 --- a/requirement.md +++ b/requirement.md @@ -20,7 +20,11 @@ ### 상품 정렬 기능 +<<<<<<< HEAD - 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다. +======= +- 상품을 가격순/인기순으로 오름차순/내림차순 정렬을 할 수 있다. +>>>>>>> caecb93 (feat: 기본 코드 추가) - 드롭다운을 통해 정렬 기준을 선택할 수 있다 - 정렬 변경 시 즉시 목록에 반영된다 diff --git a/src/components/Cart.js b/src/components/Cart.js new file mode 100644 index 00000000..55b2bdee --- /dev/null +++ b/src/components/Cart.js @@ -0,0 +1,241 @@ +import { getCartItemsFromLocalStorage, getCartSelectAllStatus } from "../utils/storage"; + +export const Cart = () => { + const cartItems = getCartItemsFromLocalStorage(); + const selectAllStatus = getCartSelectAllStatus(); + if (cartItems.length > 0) { + return /* HTML */ ` + + `; + } else { + return /* HTML */ ` + + `; + } +}; diff --git a/src/components/Footer.js b/src/components/Footer.js new file mode 100644 index 00000000..70ad6b23 --- /dev/null +++ b/src/components/Footer.js @@ -0,0 +1,9 @@ +export const Footer = () => { + return /* HTML */ ` + + `; +}; diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 00000000..8126fe4d --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,67 @@ +import { getCartCountFromLocalStorage } from "../utils/storage"; + +export const renderCartBadge = () => { + const button = document.querySelector("#cart-icon-btn"); + if (!button) return; + + const count = getCartCountFromLocalStorage(); + + let badge = button.querySelector(".cart-count-badge"); + + if (!badge) { + badge = document.createElement("span"); + badge.className = + "cart-count-badge absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"; + button.appendChild(badge); + } + + badge.textContent = count; + badge.style.display = count > 0 ? "flex" : "none"; +}; + +export const Header = ({ title = "쇼핑몰" }) => { + return /* HTML */ ` +
+
+
+ ${title === "쇼핑몰" + ? /* HTML */ ` +

+ ${title} +

+ ` + : ""} + ${title === "상품 상세" + ? /* HTML */ ` +
+ +

상품 상세

+
+ ` + : ""} + +
+ + +
+
+
+
+ `; +}; diff --git a/src/components/ProductItem.js b/src/components/ProductItem.js new file mode 100644 index 00000000..a0a665d9 --- /dev/null +++ b/src/components/ProductItem.js @@ -0,0 +1,34 @@ +export const ProductItem = ({ title, image, lprice, productId, brand }) => { + return /* HTML */ ` +
+ +
+ ${title} +
+ +
+
+

${title}

+

${brand || ""}

+

${Number(lprice).toLocaleString()}원

+
+ + +
+
+ `; +}; diff --git a/src/components/ProductList.js b/src/components/ProductList.js new file mode 100644 index 00000000..b28845b4 --- /dev/null +++ b/src/components/ProductList.js @@ -0,0 +1,60 @@ +import { ProductItem } from "./ProductItem"; + +const Skeleton = /* HTML */ ` +
+
+
+
+
+
+
+
+
+`; + +const Loading = /* HTML */ ` +
+
+ + + + + 상품을 불러오는 중... +
+
+`; + +export const ProductList = ({ isLoading, pagination, products }) => { + return /* HTML */ ` + +
+
+ ${isLoading + ? /* HTML */ ` + +
+ + ${Skeleton.repeat(4)} +
+ ${Loading} + ` + : /* HTML */ ` +
+ +
+ 총 ${pagination.total}개의 상품 +
+ +
${products.map(ProductItem).join("")}
+ +
모든 상품을 확인했습니다
+
+ `} +
+
+ `; +}; diff --git a/src/components/SearchForm.js b/src/components/SearchForm.js new file mode 100644 index 00000000..29dc13ae --- /dev/null +++ b/src/components/SearchForm.js @@ -0,0 +1,159 @@ +import { filters } from "../store/filters"; + +const CategoryButton = (category) => { + return /*HTML*/ ` + +`; +}; + +export const Category2Button = (category1, category2, isSelected = false) => { + return /* HTML */ ` + + `; +}; + +export const CategoryBreadcrumb = (category1, category2) => { + return /* HTML */ ` + ${category2 + ? /* HTML */ ` + > + ${category2} + ` + : /* HTML */ ` + > + `} + `; +}; + +const getSortLabel = (sort) => { + switch (sort) { + case "price_asc": + return "가격 낮은순"; + case "price_desc": + return "가격 높은순"; + case "name_asc": + return "이름순"; + case "name_desc": + return "이름 역순"; + default: + return "가격 낮은순"; + } +}; + +export const SearchForm = ({ categories, isLoading }) => { + const filtersState = filters.getState(); + + return /* HTML */ ` + +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + + ${filtersState.category1 ? CategoryBreadcrumb(filtersState.category1) : ""} + ${filtersState.category2 ? CategoryBreadcrumb(filtersState.category1, filtersState.category2) : ""} +
+ + ${(isLoading && + /* HTML */ `
+
카테고리 로딩 중...
+
`) || + ""} + + ${(!isLoading && + !filtersState.category1 && + /* HTML */ `
+ ${Object.keys(categories).map(CategoryButton).join("")} +
`) || + ""} + + ${(!isLoading && + filtersState.category1 && + /* HTML */ `
+ ${Object.keys(categories[filtersState.category1]) + .map((category2) => Category2Button(filtersState.category1, category2)) + .join("")} +
`) || + ""} +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ `; +}; diff --git a/src/components/Toast.js b/src/components/Toast.js new file mode 100644 index 00000000..37c06cfd --- /dev/null +++ b/src/components/Toast.js @@ -0,0 +1,35 @@ +export const AddToCartToast = () => { + return /* HTML */ ` +
+
+ + + +
+

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

+ +
+ `; +}; + +// 토스트 보여주고, 3초 후 사라지게 +export const showToast = (children) => { + const $root = document.getElementById("root"); + const $toast = document.createElement("div"); + $toast.className = "fixed bottom-0 left-0 right-0 p-4 flex justify-center"; + $toast.innerHTML = children; + $root.appendChild($toast); + + const $toastCloseBtn = $toast.querySelector("#toast-close-btn"); + $toastCloseBtn.addEventListener("click", () => { + $toast.remove(); + }); + + setTimeout(() => { + $toast.remove(); + }, 3000); +}; diff --git a/src/components/index.js b/src/components/index.js new file mode 100644 index 00000000..431721c6 --- /dev/null +++ b/src/components/index.js @@ -0,0 +1,6 @@ +export * from "./Header"; +export * from "./Footer"; +export * from "./SearchForm"; +export * from "./ProductList"; +export * from "./Cart"; +export * from "./Toast"; diff --git a/src/main.js b/src/main.js index 4b055b89..ef16030c 100644 --- a/src/main.js +++ b/src/main.js @@ -1,1147 +1,212 @@ +import { getCategories, getProduct, getProducts } from "./api/productApi.js"; +import { Cart } from "./components/Cart.js"; +import { renderCartBadge } from "./components/Header.js"; +import { AddToCartToast, showToast } from "./components/Toast.js"; +import { DetailPage } from "./pages/DetailPage.js"; +import { HomePage } from "./pages/HomePage.js"; +import { filters } from "./store/filters.js"; +import { + addCartItemToLocalStorage, + getCartItemsFromLocalStorage, + updateCartItemSelectedStatus, + updateCartSelectAllStatus, +} from "./utils/storage.js"; + +let categories = []; + const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => worker.start({ + serviceWorker: { + url: `${import.meta.env.BASE_URL}mockServiceWorker.js`, + }, onUnhandledRequest: "bypass", }), ); -function main() { - const 상품목록_레이아웃_로딩 = ` -
-
-
-
-

- 쇼핑몰 -

-
- - -
-
-
-
-
- -
- -
-
- -
- - - -
-
-
- -
- -
-
- - -
- -
-
카테고리 로딩 중...
-
- -
- -
- -
- - -
- -
- - -
-
-
-
- -
-
- -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- - - - - 상품을 불러오는 중... -
-
-
-
-
- -
- `; +const init = async () => { + const $root = document.getElementById("root"); + $root.innerHTML = HomePage({ isLoading: true }); + const data = await getProducts(); + + // 카테고리 담기 + const newCategories = await getCategories(); + categories = newCategories; + + $root.innerHTML = HomePage({ ...data, categories, isLoading: false }); + + filters.subscribe(render); + eventHandlers(); + renderCartBadge(); + renderCart(); +}; + +// Cart를 렌더링하는 함수 +const renderCart = () => { + const cartContainer = document.querySelector(".cart-modal-container"); + if (!cartContainer) return; + + // 현재 모달이 보이는 상태인지 확인 + const cartModal = cartContainer.querySelector(".cart-modal"); + const isVisible = cartModal && !cartModal.classList.contains("hidden"); + + // Cart 다시 렌더링 + cartContainer.innerHTML = Cart(); + + // 이전 상태 유지 + if (isVisible) { + cartContainer.querySelector(".cart-modal").classList.remove("hidden"); + } +}; + +const render = async () => { + const basePath = import.meta.env.BASE_URL; // vite 제공 + const pathName = window.location.pathname; + const relativePath = pathName.replace(basePath, "/").replace(/\/$/, "") || "/"; + + const $root = document.getElementById("root"); + + if (relativePath === "/") { + const data = await getProducts(filters.getState()); + $root.innerHTML = HomePage({ ...data, categories, isLoading: false }); + } else { + const productId = window.location.pathname.split("/").pop(); + $root.innerHTML = DetailPage({ isLoading: true }); + const data = await getProduct(productId); + $root.innerHTML = DetailPage({ ...data, isLoading: false }); + } + + eventHandlers(); + renderCartBadge(); + renderCart(); + + window.addEventListener("popstate", render); +}; + +const eventHandlers = () => { + document.addEventListener("click", async (event) => { + // 상품 카드 클릭 이벤트 핸들러 -> 상세 페이지로 이동 + // if (document.querySelector(".product-card") && !event.target.closest(".add-to-cart-btn")) { + // event.stopPropagation(); + // const productId = event.target.closest(".product-card").dataset.productId; + // history.pushState("", "", `/product/${productId}`); + // render(); + // } + + // breadcrumb 전체 버튼 클릭 시 필터 초기화 + if (event.target.closest("button[data-breadcrumb='reset']")) { + filters.setState({ category1: "", category2: "" }); + } + + // breadcrumb 카테고리 1 버튼 클릭 시 필터 적용 + if (event.target.closest("button[data-breadcrumb='category1']")) { + const category1 = event.target.closest("button[data-breadcrumb='category1']").dataset.category1; + filters.setState({ category1, category2: "" }); + } + + // 카테고리 1 필터 버튼 클릭 이벤트 핸들러 + if (event.target.closest(".category1-filter-btn")) { + const category1 = event.target.dataset.category1; + + filters.setState({ category1 }); + } + // 카테고리 2 버튼 클릭 이벤트 핸들러 + if (event.target.closest(".category2-filter-btn")) { + const category1 = event.target.closest(".category2-filter-btn").dataset.category1; + const category2 = event.target.closest(".category2-filter-btn").dataset.category2; - const 상품목록_레이아웃_로딩완료 = ` -
-
-
-
-

- 쇼핑몰 -

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

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

-

-

- 220원 -

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

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

-

이지웨이건축자재

-

- 230원 -

-
- - -
-
-
- -
- 모든 상품을 확인했습니다 -
-
-
-
- -
- `; + filters.setState({ category1, category2 }); + } - const 상품목록_레이아웃_카테고리_1Depth = ` -
- -
- -
-
- -
- - - -
-
-
- - -
+ // 장바구니 버튼 클릭 이벤트 핸들러 + if (event.target.closest("#cart-icon-btn")) { + const cartModal = document.querySelector(".cart-modal"); + if (!cartModal) { + console.error("❌ .cart-modal을 찾을 수 없습니다!"); + return; + } + cartModal.classList.remove("hidden"); + } - -
-
- - > -
-
-
- - - -
-
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- `; + // 장바구니 모달 닫기 버튼 클릭 이벤트 핸들러 + if (event.target.closest("#cart-modal-close-btn")) { + const cartModal = document.querySelector(".cart-modal"); + cartModal.classList.add("hidden"); + } - const 상품목록_레이아웃_카테고리_2Depth = ` -
- -
- -
-
- -
- - - -
-
-
- - -
+ // 장바구니 모달 배경 클릭 시 닫기 + if (event.target.closest(".cart-modal-overlay")) { + const cartModal = document.querySelector(".cart-modal"); + cartModal.classList.add("hidden"); + } - -
-
- - >>주방용품 -
-
-
- - - -
-
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- `; + // 아이템 중 장바구니 담기 눌렀을 때 로컬 스토리지에 추가 + if (event.target.closest(".add-to-cart-btn")) { + event.preventDefault(); + event.stopPropagation(); + const productId = event.target.closest(".add-to-cart-btn").dataset.productId; + const product = await getProduct(productId); + addCartItemToLocalStorage(productId, product); + renderCart(); + renderCartBadge(); + showToast(AddToCartToast()); + } - const 토스트 = ` -
-
-
- - - -
-

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

- -
- -
-
- - - -
-

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

- -
- -
-
- - - -
-

오류가 발생했습니다.

- -
-
- `; + // 장바구니 전체선택 체크박스 클릭 + if (event.target.id === "cart-modal-select-all-checkbox") { + event.preventDefault(); + event.stopPropagation(); - const 장바구니_비어있음 = ` -
-
- -
-

- - - - 장바구니 -

- - -
- - -
- -
-
-
- - - -
-

장바구니가 비어있습니다

-

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

-
-
-
-
-
- `; + const selectAll = event.target.checked; + updateCartSelectAllStatus(selectAll); + renderCart(); + } - const 장바구니_선택없음 = ` -
-
- -
-

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

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

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

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

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

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

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

- -
-
-
-
-
- -
- - -
- 총 금액 - 670원 -
- -
-
- - -
-
-
-
-
- `; + // 장바구니 체크박스 클릭 + if (event.target.classList.contains("cart-item-checkbox")) { + event.preventDefault(); + event.stopPropagation(); - const 장바구니_선택있음 = ` -
-
- -
-

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

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

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

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

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

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

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

- -
-
-
-
-
- -
- -
- 선택한 상품 (1개) - 440원 -
- -
- 총 금액 - 670원 -
- -
- -
- - -
-
-
-
-
- `; + const cartItems = getCartItemsFromLocalStorage(); + const cartItem = cartItems.find((item) => item.id === event.target.dataset.productId); + cartItem.selected = !cartItem.selected; + updateCartItemSelectedStatus(cartItem.id, cartItem.selected); + renderCart(); + } + }); - const 상세페이지_로딩 = ` -
-
-
-
-
- -

상품 상세

-
-
- - -
-
-
-
-
-
-
-
-

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

-
-
-
- -
- `; + // input 검색 이벤트 핸들러 + document.addEventListener("keyup", (event) => { + const target = document.getElementById("search-input"); + if (target && event.key === "Enter") { + const search = target.value; - const 상세페이지_로딩완료 = ` -
-
-
-
-
- -

상품 상세

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

-

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

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

관련 상품

-

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

-
-
-
- - -
-
-
-
- -
- `; + if (search.length > 0) { + filters.setState({ search }); + } else { + filters.setState({ search: "" }); + } + } + }); - const _404_ = ` -
-
- - - - - - - - - - - - - 404 - - - - - - - - - 페이지를 찾을 수 없습니다 - - - - - - 홈으로 -
-
- `; + document.addEventListener("change", () => { + // 개수 선택 이벤트 핸들러 + const limitSelect = document.getElementById("limit-select"); + if (limitSelect) { + const limit = limitSelect.value; + filters.setState({ limit }); + } + // 정렬 선택 이벤트 핸들러 + const sortSelect = document.getElementById("sort-select"); + if (sortSelect) { + const sort = sortSelect.value; + filters.setState({ sort }); + } + }); +}; - document.body.innerHTML = ` - ${상품목록_레이아웃_로딩} -
- ${상품목록_레이아웃_로딩완료} -
- ${상품목록_레이아웃_카테고리_1Depth} -
- ${상품목록_레이아웃_카테고리_2Depth} -
- ${토스트} -
- ${장바구니_비어있음} -
- ${장바구니_선택없음} -
- ${장바구니_선택있음} -
- ${상세페이지_로딩} -
- ${상세페이지_로딩완료} -
- ${_404_} - `; +async function main() { + init(); } // 애플리케이션 시작 diff --git a/src/pages/DetailPage.js b/src/pages/DetailPage.js new file mode 100644 index 00000000..03e1c0d3 --- /dev/null +++ b/src/pages/DetailPage.js @@ -0,0 +1,215 @@ +import { PageLayout } from "./PageLayout"; + +export const DetailPage = ({ + isLoading, + title, + image, + lprice, + mallName, + productId, + category1, + category2, + description, + rating, + reviewCount, + stock, +}) => { + // { + // "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": 422, + // "stock": 92, + // "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" + // ], + // } + + return PageLayout({ + title: "상품 상세", + children: isLoading + ? /* HTML */ `
+
+
+

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

+
+
` + : /* HTML */ ` +
+ + + +
+ +
+
+ ${title} +
+ +
+

${mallName}

+

${title}

+ +
+
+ + + + + + + + + + + + + + + +
+ ${rating} (${reviewCount}개 리뷰) +
+ +
+ ${Number(lprice).toLocaleString()}원 +
+ +
재고 ${stock}개
+ +
${description}
+
+
+ +
+
+ 수량 +
+ + + +
+
+ + +
+
+ +
+ +
+ +
+
+

관련 상품

+

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

+
+
+
+ + +
+
+
+
+ `, + }); +}; diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js new file mode 100644 index 00000000..9eb8128d --- /dev/null +++ b/src/pages/HomePage.js @@ -0,0 +1,13 @@ +import { PageLayout } from "./PageLayout"; +import { SearchForm, ProductList } from "../components"; + +export const HomePage = ({ filters, pagination, products, categories, isLoading = false }) => { + return /* HTML */ ` + ${PageLayout({ + children: ` + ${SearchForm({ filters, categories, isLoading })} + ${ProductList({ pagination, products, isLoading })} + `, + })} + `; +}; diff --git a/src/pages/PageLayout.js b/src/pages/PageLayout.js new file mode 100644 index 00000000..379f796b --- /dev/null +++ b/src/pages/PageLayout.js @@ -0,0 +1,13 @@ +import { Footer } from "../components/Footer"; +import { Header } from "../components/Header"; + +export const PageLayout = ({ children, title = "쇼핑몰" }) => { + return /* HTML */ ` +
+ ${Header({ title })} +
${children}
+
+ ${Footer()} +
+ `; +}; diff --git a/src/setupTests.js b/src/setupTests.js new file mode 100644 index 00000000..d72b8905 --- /dev/null +++ b/src/setupTests.js @@ -0,0 +1,16 @@ +import "@testing-library/jest-dom"; +import { configure } from "@testing-library/dom"; +import { afterAll, beforeAll } from "vitest"; +import { server } from "./__tests__/mockServerHandler.js"; + +configure({ + asyncUtilTimeout: 5000, +}); + +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); +}); + +afterAll(() => { + server.close(); +}); diff --git a/src/store/filters.js b/src/store/filters.js new file mode 100644 index 00000000..6de53e39 --- /dev/null +++ b/src/store/filters.js @@ -0,0 +1,9 @@ +import { createStore } from "./store"; + +export const filters = createStore({ + limit: "20", + search: "", + category1: "", + category2: "", + sort: "price_asc", +}); diff --git a/src/store/store.js b/src/store/store.js new file mode 100644 index 00000000..d1e0d462 --- /dev/null +++ b/src/store/store.js @@ -0,0 +1,18 @@ +export function createStore(initialState) { + let state = initialState; + const observers = new Set(); + + const getState = () => state; + + const setState = (rest) => { + state = { ...state, ...rest }; + observers.forEach((observer) => observer(state)); + }; + + const subscribe = (render) => { + observers.add(render); + return () => observers.delete(render); + }; + + return { getState, setState, subscribe }; +} diff --git a/src/template.js b/src/template.js new file mode 100644 index 00000000..214e63b7 --- /dev/null +++ b/src/template.js @@ -0,0 +1,1114 @@ +const 상품목록_레이아웃_로딩 = /*HTML*/ ` +
+
+
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+
+ +
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + 상품을 불러오는 중... +
+
+
+
+
+ + +
+ `; + +const 상품목록_레이아웃_로딩완료 = /*HTML*/ ` +
+
+
+
+

+ 쇼핑몰 +

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

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

+

+

+ 220원 +

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

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

+

이지웨이건축자재

+

+ 230원 +

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

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

+ +
+ +
+
+ + + +
+

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

+ +
+ +
+
+ + + +
+

오류가 발생했습니다.

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

+ + + + 장바구니 +

+ + +
+ + +
+ +
+
+
+ + + +
+

장바구니가 비어있습니다

+

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

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

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

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

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

+

+ 220원 +

+ +
+ + + +
+
+ +
+

+ 440원 +

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

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

+

+ 230원 +

+ +
+ + + +
+
+ +
+

+ 230원 +

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

+ + + + 장바구니 + (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_ = /*HTML*/ ` +
+
+ + + + + + + + + + + + + 404 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+
+ `; diff --git a/src/utils/storage.js b/src/utils/storage.js new file mode 100644 index 00000000..d1d8893e --- /dev/null +++ b/src/utils/storage.js @@ -0,0 +1,72 @@ +// items라는 배열 안에 장바구니 상품 추가 +export const addCartItemToLocalStorage = (productId, product) => { + if (!localStorage.getItem("shopping_cart")) { + localStorage.setItem("shopping_cart", JSON.stringify({ selectedAll: false, items: [] })); + } + + const productsInStorage = JSON.parse(localStorage.getItem("shopping_cart")); + const findProduct = productsInStorage.items.find((item) => item.id === productId); + + if (findProduct) { + productsInStorage.items.find((item) => item.id === productId).quantity += 1; + localStorage.setItem("shopping_cart", JSON.stringify(productsInStorage)); + } else { + const productInfo = { + id: productId, + image: product.image, + price: Number(product.lprice), + quantity: 1, + selected: false, + title: product.title, + }; + // shopping_cart 객체의 items 배열에 추가 + productsInStorage.items.push(productInfo); + localStorage.setItem("shopping_cart", JSON.stringify(productsInStorage)); + } +}; + +// 장바구니 상품 제거 +export const removeCartItemFromLocalStorage = (productId) => { + const productsInStorage = JSON.parse(localStorage.getItem("shopping_cart")); + productsInStorage.items = productsInStorage.items.filter((item) => item.id !== productId); + localStorage.setItem("shopping_cart", JSON.stringify(productsInStorage)); +}; + +// 장바구니 조회 +export const getCartItemsFromLocalStorage = () => { + return JSON.parse(localStorage.getItem("shopping_cart"))?.items || []; +}; + +// 장바구니 카운트 +export const getCartCountFromLocalStorage = () => { + return JSON.parse(localStorage.getItem("shopping_cart"))?.items?.length || 0; +}; + +// 장바구니 체크박스 상태 업데이트 +export const updateCartItemSelectedStatus = (productId, selected) => { + const productsInStorage = JSON.parse(localStorage.getItem("shopping_cart")); + const cartItem = productsInStorage.items.find((item) => item.id === productId); + cartItem.selected = selected; + + // 모든 아이템이 선택되었으면 selectedAll도 true로, 하나라도 해제되면 false로 + const allSelected = productsInStorage.items.length > 0 && productsInStorage.items.every((item) => item.selected); + productsInStorage.selectedAll = allSelected; + + localStorage.setItem("shopping_cart", JSON.stringify(productsInStorage)); +}; + +// 전체선택 상태 업데이트 +export const updateCartSelectAllStatus = (selectAll) => { + const productsInStorage = JSON.parse(localStorage.getItem("shopping_cart")); + productsInStorage.selectedAll = selectAll; + // 모든 아이템의 selected 상태를 selectAll 값으로 변경 + productsInStorage.items.forEach((item) => { + item.selected = selectAll; + }); + localStorage.setItem("shopping_cart", JSON.stringify(productsInStorage)); +}; + +// 전체선택 상태 조회 +export const getCartSelectAllStatus = () => { + return JSON.parse(localStorage.getItem("shopping_cart"))?.selectedAll || false; +}; diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 00000000..586cb802 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + base: "/front_7th_chapter2-1/", + build: { + outDir: "dist", + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/setupTests.js", + exclude: ["**/e2e/**", "**/*.e2e.spec.js", "**/node_modules/**"], + poolOptions: { + threads: { + singleThread: true, + }, + }, + }, +});