From d2d3fc3fc022cf842d4952d3941e21c4e615b02f Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Sun, 6 Jul 2025 16:00:10 +0900 Subject: [PATCH 01/19] =?UTF-8?q?=EA=B3=BC=EC=A0=9C=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +++ pnpm-lock.yaml | 4 ++++ public/mockServiceWorker.js | 14 +++----------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 7b2368941..dbd258765 100644 --- a/package.json +++ b/package.json @@ -50,5 +50,8 @@ "workerDirectory": [ "public" ] + }, + "dependencies": { + "playwright": "^1.53.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8137d4c85..e76942948 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + playwright: + specifier: ^1.53.2 + version: 1.53.2 devDependencies: '@eslint/js': specifier: ^9.16.0 diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index d2b729641..b1f186b65 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)]); }); } From 2e7738e2ba88cef8e52b35f00220c36039416bd9 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Sun, 6 Jul 2025 23:00:45 +0900 Subject: [PATCH 02/19] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=91=EC=86=8D?= =?UTF-8?q?=20=EC=8B=9C=20=EB=A1=9C=EB=94=A9=20=EC=83=81=ED=83=9C=EA=B0=80?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=EB=90=98=EA=B3=A0,=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=A1=9C=EB=93=9C=20=EC=99=84=EB=A3=8C=20=ED=9B=84?= =?UTF-8?q?=20=EC=83=81=ED=92=88=20=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=EB=90=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data.js | 1112 +++++++++++++++++++++++++++++++++++++++ src/main.js | 1162 +---------------------------------------- src/pages/HomePage.js | 186 +++++++ src/setupTests.js | 2 +- 4 files changed, 1326 insertions(+), 1136 deletions(-) create mode 100644 src/data.js create mode 100644 src/pages/HomePage.js diff --git a/src/data.js b/src/data.js new file mode 100644 index 000000000..b4ecc2076 --- /dev/null +++ b/src/data.js @@ -0,0 +1,1112 @@ +// const 상품목록_레이아웃_로딩 = ` +//
+//
+//
+//
+//

+// 쇼핑몰 +//

+//
+// +// +//
+//
+//
+//
+//
+// +//
+// +//
+//
+// +//
+// +// +// +//
+//
+//
+// +//
+// +//
+//
+// +// +//
+// +//
+//
카테고리 로딩 중...
+//
+// +//
+// +//
+// +//
+// +// +//
+// +//
+// +// +//
+//
+//
+//
+// +//
+//
+// +//
+// +//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+ +//
+//
+// +// +// +// +// 상품을 불러오는 중... +//
+//
+//
+//
+//
+//
+//
+//

© 2025 항해플러스 프론트엔드 쇼핑몰

+//
+//
+//
+// `; + +// const 상품목록_레이아웃_로딩완료 = ` +//
+//
+//
+//
+//

+// 쇼핑몰 +//

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

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

+//

+//

+// 220원 +//

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

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

+//

이지웨이건축자재

+//

+// 230원 +//

+//
+// +// +//
+//
+//
+ +//
+// 모든 상품을 확인했습니다 +//
+//
+//
+//
+//
+//
+//

© 2025 항해플러스 프론트엔드 쇼핑몰

+//
+//
+//
+// `; + +// 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 상세페이지_로딩 = ` +//
+//
+//
+//
+//
+// +//

상품 상세

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

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

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

© 2025 항해플러스 프론트엔드 쇼핑몰

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

상품 상세

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

+//

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

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

관련 상품

+//

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

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

© 2025 항해플러스 프론트엔드 쇼핑몰

+//
+//
+//
+// `; + +// const _404_ = ` +//
+//
+// +// +// +// +// +// +// +// +// +// + +// +// 404 + +// +// +// +// +// + +// +// 페이지를 찾을 수 없습니다 + +// +// +// + +// 홈으로 +//
+//
+// `; diff --git a/src/main.js b/src/main.js index 4b055b89d..f28f494b2 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3 +1,6 @@ +import HomePage from "./pages/HomePage.js"; +import { getProducts, getCategories } from "./api/productApi.js"; + const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => worker.start({ @@ -5,1143 +8,32 @@ const enableMocking = () => }), ); -function main() { - const 상품목록_레이아웃_로딩 = ` -
-
-
-
-

- 쇼핑몰 -

-
- - -
-
-
-
-
- -
- -
-
- -
- - - -
-
-
- -
- -
-
- - -
- -
-
카테고리 로딩 중...
-
- -
- -
- -
- - -
- -
- - -
-
-
-
- -
-
- -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- - - - - 상품을 불러오는 중... -
-
-
-
-
-
-
-

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const 상품목록_레이아웃_로딩완료 = ` -
-
-
-
-

- 쇼핑몰 -

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

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

-

-

- 220원 -

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

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

-

이지웨이건축자재

-

- 230원 -

-
- - -
-
-
- -
- 모든 상품을 확인했습니다 -
-
-
-
-
-
-

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const 상품목록_레이아웃_카테고리_1Depth = ` -
- -
- -
-
- -
- - - -
-
-
- - -
- - -
-
- - > -
-
-
- - - -
-
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- `; - - const 상품목록_레이아웃_카테고리_2Depth = ` -
- -
- -
-
- -
- - - -
-
-
- - -
- - -
-
- - >>주방용품 -
-
-
- - - -
-
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- `; - - const 토스트 = ` -
-
-
- - - -
-

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

- -
- -
-
- - - -
-

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

- -
- -
-
- - - -
-

오류가 발생했습니다.

- -
-
- `; +let state = { + products: [], + total: 0, + loading: false, + categories: {}, +}; - 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 상세페이지_로딩 = ` -
-
-
-
-
- -

상품 상세

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

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

-
-
-
-
-
-

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const 상세페이지_로딩완료 = ` -
-
-
-
-
- -

상품 상세

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

-

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

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

관련 상품

-

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

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

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const _404_ = ` -
-
- - - - - - - - - - - - - 404 - - - - - - - - - 페이지를 찾을 수 없습니다 - - - - - - 홈으로 -
-
- `; +function render() { + document.body.querySelector("#root").innerHTML = HomePage(state); +} - document.body.innerHTML = ` - ${상품목록_레이아웃_로딩} -
- ${상품목록_레이아웃_로딩완료} -
- ${상품목록_레이아웃_카테고리_1Depth} -
- ${상품목록_레이아웃_카테고리_2Depth} -
- ${토스트} -
- ${장바구니_비어있음} -
- ${장바구니_선택없음} -
- ${장바구니_선택있음} -
- ${상세페이지_로딩} -
- ${상세페이지_로딩완료} -
- ${_404_} - `; +async function main() { + state.loading = true; + render(); + const [ + { + products, + pagination: { total }, + }, + categories, + ] = await Promise.all([getProducts({}), getCategories()]); + state.products = products; + state.total = total; + state.categories = categories; + state.loading = false; + render(); } // 애플리케이션 시작 diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js new file mode 100644 index 000000000..3eeb61a4e --- /dev/null +++ b/src/pages/HomePage.js @@ -0,0 +1,186 @@ +const LoadingCard = ` +
+
+
+
+
+
+
+
+
`; + +const LoadingCardList = LoadingCard.repeat(4); + +// { +// "title": "방충망 미세먼지 롤 창문 모기장 DIY 100cmx10cm", +// "link": "https:\/\/smartstore.naver.com\/main\/products\/668979777", +// "image": "https:\/\/shopping-phinf.pstatic.net\/main_1112415\/11124150101.10.jpg", +// "lprice": "450", +// "hprice": "", +// "mallName": "동백물산", +// "productId": "11124150101", +// "productType": "2", +// "brand": "메쉬코리아", +// "maker": "", +// "category1": "생활\/건강", +// "category2": "생활용품", +// "category3": "생활잡화", +// "category4": "모기장" +// }, +const ProductCard = (product) => ` +
+ +
+ ${product.title} +
+ +
+
+

+ ${product.title} +

+

+

+ ${product.lprice}원 +

+
+ + +
+
+`; + +export default function HomePage({ products = [], total = 0, loading = false, categories = [] }) { + return 상품목록_레이아웃_로딩(products, total, loading, categories); +} + +const 상품목록_레이아웃_로딩 = (products, total, loading) => ` +
+
+
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+
+
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+ ${loading ? `
카테고리 로딩 중...
` : ""} +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ ${ + loading + ? "" + : ` +
+ 총 의 상품 + ${total}개 +
+ ` + } + +
+ + ${loading ? LoadingCardList : products.map(ProductCard).join("")} + +
+
+ + + + + 상품을 불러오는 중... +
+
+
+
+
+
+
+

© 2025 항해플러스 프론트엔드 쇼핑몰

+
+
+
+ `; diff --git a/src/setupTests.js b/src/setupTests.js index d72b8905a..659279d1e 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -4,7 +4,7 @@ import { afterAll, beforeAll } from "vitest"; import { server } from "./__tests__/mockServerHandler.js"; configure({ - asyncUtilTimeout: 5000, + asyncUtilTimeout: 1000, }); beforeAll(() => { From 2e5ae9bb82bfc7dcbb59962d67cfb3d4690d69c6 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Mon, 7 Jul 2025 00:19:26 +0900 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.js | 366 +++++++++++++++++++++++++++++++++++++++++- src/pages/HomePage.js | 55 +++---- 2 files changed, 387 insertions(+), 34 deletions(-) diff --git a/src/main.js b/src/main.js index f28f494b2..497e4c5ab 100644 --- a/src/main.js +++ b/src/main.js @@ -12,31 +12,393 @@ let state = { products: [], total: 0, loading: false, + loadingMore: false, categories: {}, + limit: 20, + page: 1, + cart: {}, }; +function loadCart() { + try { + state.cart = JSON.parse(localStorage.getItem("shopping_cart") || "{}"); + } catch { + state.cart = {}; + } +} + +function saveCart() { + localStorage.setItem("shopping_cart", JSON.stringify(state.cart)); +} + +function showToast(message = "장바구니에 추가되었습니다.") { + const toast = document.createElement("div"); + toast.textContent = message; + toast.className = "fixed top-4 left-1/2 -translate-x-1/2 bg-green-600 text-white px-4 py-2 rounded shadow z-50"; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2500); +} + +function updateCartBadge() { + const cartIcon = document.querySelector("#cart-icon-btn"); + if (!cartIcon) return; + + const old = cartIcon.querySelector("span"); + if (old) old.remove(); + + const count = Object.keys(state.cart).length; + if (count > 0) { + const span = document.createElement("span"); + span.textContent = count; + span.className = + "absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"; + cartIcon.appendChild(span); + } +} + +function addToCart(productId, quantity = 1) { + if (!productId) return; + if (state.cart[productId]) { + state.cart[productId].quantity += quantity; + } else { + const product = state.products.find((p) => String(p.productId) === String(productId)); + state.cart[productId] = { product, quantity }; + } + + saveCart(); + updateCartBadge(); + showToast(); +} + +function formatPrice(won) { + return `${Number(won).toLocaleString()}원`; +} + +function openCartModal() { + if (document.querySelector(".cart-modal-overlay")) return; + + const overlay = document.createElement("div"); + overlay.className = + "cart-modal-overlay fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4"; + document.body.appendChild(overlay); + + const escHandler = (e) => { + if (e.key === "Escape") closeCartModal(); + }; + document.addEventListener("keydown", escHandler); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) closeCartModal(); + }); + + renderCartModal(); + + function closeCartModal() { + overlay.remove(); + document.removeEventListener("keydown", escHandler); + } + + function renderCartModal() { + overlay.innerHTML = ""; + + const cartItems = Object.values(state.cart); + if (cartItems.length === 0) { + overlay.innerHTML = ` +
+
+

+ + + + 장바구니 +

+ +
+
+
+
+ + + +
+

장바구니가 비어있습니다

+

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

+
+
+
`; + overlay.querySelector("#cart-modal-close-btn").onclick = closeCartModal; + return; + } + + const totalCount = cartItems.length; + const totalPrice = cartItems.reduce((sum, i) => sum + i.product.lprice * i.quantity, 0); + + const modal = document.createElement("div"); + modal.className = + "cart-modal relative bg-white rounded-t-lg sm:rounded-lg shadow-xl w-full max-w-md sm:max-w-lg max-h-[90vh] overflow-hidden"; + overlay.appendChild(modal); + + modal.innerHTML = ` + +
+

+ + + + 장바구니 (${totalCount}) +

+ +
+ + +
+
+ +
+
+
+ ${cartItems + .map( + ({ product, quantity }) => ` +
+ +
+ ${product.title} +
+
+

${product.title}

+

${formatPrice(product.lprice)}

+
+ + + +
+
+
+

${formatPrice(product.lprice * quantity)}

+ +
+
`, + ) + .join("")} +
+
+ + +
+
+ 총 금액 + ${formatPrice(totalPrice)} +
+
+ + +
+
+
`; + + modal.querySelector("#cart-modal-close-btn").onclick = closeCartModal; + modal.querySelectorAll(".quantity-increase-btn").forEach((btn) => { + btn.onclick = () => { + const pid = btn.dataset.productId; + state.cart[pid].quantity += 1; + saveCart(); + const qtyInput = modal.querySelector(`.quantity-input[data-product-id="${pid}"]`); + const priceField = modal.querySelector(`.price-field[data-product-id="${pid}"]`); + qtyInput.value = state.cart[pid].quantity; + priceField.textContent = formatPrice(state.cart[pid].quantity * state.cart[pid].product.lprice); + modal.querySelector("#cart-modal-total-amount").textContent = formatPrice( + Object.values(state.cart).reduce((s, c) => s + c.product.lprice * c.quantity, 0), + ); + }; + }); + modal.querySelectorAll(".quantity-decrease-btn").forEach((btn) => { + btn.onclick = () => { + const pid = btn.dataset.productId; + if (state.cart[pid].quantity === 1) return; + state.cart[pid].quantity -= 1; + saveCart(); + const qtyInput = modal.querySelector(`.quantity-input[data-product-id="${pid}"]`); + const priceField = modal.querySelector(`.price-field[data-product-id="${pid}"]`); + qtyInput.value = state.cart[pid].quantity; + priceField.textContent = formatPrice(state.cart[pid].quantity * state.cart[pid].product.lprice); + modal.querySelector("#cart-modal-total-amount").textContent = formatPrice( + Object.values(state.cart).reduce((s, c) => s + c.product.lprice * c.quantity, 0), + ); + }; + }); + modal.querySelectorAll(".cart-item-remove-btn").forEach((btn) => { + btn.onclick = () => { + const pid = btn.dataset.productId; + delete state.cart[pid]; + saveCart(); + updateCartBadge(); + renderCartModal(); + }; + }); + + const selectAllChk = modal.querySelector("#cart-modal-select-all-checkbox"); + const itemChks = modal.querySelectorAll(".cart-item-checkbox"); + const removeSelBtn = modal.querySelector("#cart-modal-remove-selected-btn"); + + const recalcSelection = () => { + const selectedCnt = [...itemChks].filter((c) => c.checked).length; + selectAllChk.checked = selectedCnt === itemChks.length; + selectAllChk.indeterminate = selectedCnt > 0 && selectedCnt < itemChks.length; + removeSelBtn.disabled = selectedCnt === 0; + removeSelBtn.textContent = `선택한 상품 삭제 (${selectedCnt}개)`; + }; + + selectAllChk.onchange = () => { + itemChks.forEach((c) => (c.checked = selectAllChk.checked)); + recalcSelection(); + }; + itemChks.forEach((c) => (c.onchange = recalcSelection)); + recalcSelection(); + + removeSelBtn.onclick = () => { + const selectedPids = [...itemChks].filter((c) => c.checked).map((c) => c.dataset.productId); + + selectedPids.forEach((pid) => delete state.cart[pid]); + saveCart(); + updateCartBadge(); + renderCartModal(); + }; + + modal.querySelector("#cart-modal-clear-cart-btn").onclick = () => { + state.cart = {}; + saveCart(); + updateCartBadge(); + renderCartModal(); + }; + } +} + +function attachEventListeners() { + const limitSelect = document.querySelector("#limit-select"); + if (limitSelect) { + limitSelect.value = String(state.limit); + + limitSelect.onchange = async (e) => { + const newLimit = Number(e.target.value); + if (state.limit === newLimit) return; + + state.limit = newLimit; + state.page = 1; + state.products = []; + state.loading = true; + render(); + + const { + products, + pagination: { total }, + } = await getProducts({ limit: state.limit, page: state.page }); + + state.products = products; + state.total = total; + state.loading = false; + render(); + }; + } + + document.querySelectorAll(".add-to-cart-btn").forEach((btn) => { + btn.onclick = () => { + const productId = btn.dataset.productId; + addToCart(productId); + }; + }); + + const cartIconBtn = document.querySelector("#cart-icon-btn"); + if (cartIconBtn) cartIconBtn.onclick = openCartModal; +} + +let scrollAttached = false; + +function setupInfiniteScroll() { + if (scrollAttached) return; + scrollAttached = true; + + window.addEventListener("scroll", () => { + const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200; + + if (nearBottom && !state.loading && !state.loadingMore && state.products.length < state.total) { + loadMore(); + } + }); +} + +async function loadMore() { + state.loadingMore = true; + render(); + + const nextPage = state.page + 1; + const { + products: newProducts, + pagination: { total }, + } = await getProducts({ limit: state.limit, page: nextPage }); + + state.products = [...state.products, ...newProducts]; + state.total = total; + state.page = nextPage; + state.loadingMore = false; + render(); +} + function render() { document.body.querySelector("#root").innerHTML = HomePage(state); + attachEventListeners(); + updateCartBadge(); } async function main() { + loadCart(); state.loading = true; render(); + const [ { products, pagination: { total }, }, categories, - ] = await Promise.all([getProducts({}), getCategories()]); + ] = await Promise.all([getProducts({ limit: state.limit, page: state.page }), getCategories()]); + state.products = products; state.total = total; state.categories = categories; state.loading = false; render(); + + setupInfiniteScroll(); } -// 애플리케이션 시작 if (import.meta.env.MODE !== "test") { enableMocking().then(main); } else { diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js index 3eeb61a4e..399918169 100644 --- a/src/pages/HomePage.js +++ b/src/pages/HomePage.js @@ -11,22 +11,6 @@ const LoadingCard = ` const LoadingCardList = LoadingCard.repeat(4); -// { -// "title": "방충망 미세먼지 롤 창문 모기장 DIY 100cmx10cm", -// "link": "https:\/\/smartstore.naver.com\/main\/products\/668979777", -// "image": "https:\/\/shopping-phinf.pstatic.net\/main_1112415\/11124150101.10.jpg", -// "lprice": "450", -// "hprice": "", -// "mallName": "동백물산", -// "productId": "11124150101", -// "productType": "2", -// "brand": "메쉬코리아", -// "maker": "", -// "category1": "생활\/건강", -// "category2": "생활용품", -// "category3": "생활잡화", -// "category4": "모기장" -// }, const ProductCard = (product) => `
@@ -50,18 +34,18 @@ const ProductCard = (product) => `
`; -export default function HomePage({ products = [], total = 0, loading = false, categories = [] }) { - return 상품목록_레이아웃_로딩(products, total, loading, categories); +export default function HomePage({ products = [], total = 0, loading = false, loadingMore = false, categories = [] }) { + return 상품목록_레이아웃_로딩(products, total, loading, loadingMore, categories); } -const 상품목록_레이아웃_로딩 = (products, total, loading) => ` +const 상품목록_레이아웃_로딩 = (products, total, loading, loadingMore) => `
@@ -161,19 +145,26 @@ const 상품목록_레이아웃_로딩 = (products, total, loading) => ` }
- - ${loading ? LoadingCardList : products.map(ProductCard).join("")} - -
-
- - - - - 상품을 불러오는 중... -
+ ${ + loading + ? LoadingCardList /* 초기 로딩 */ + : products.map(ProductCard).join("") + (loadingMore ? LoadingCardList : "") /* 추가 로딩 */ + }
+ ${ + loadingMore /* 인디케이터 */ + ? `
+
+ + + + + 상품을 불러오는 중... +
+
` + : "" + }
From f1a99c46f582933bf04285879efbedd624e19ad5 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Mon, 7 Jul 2025 00:37:04 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8,=20=EA=B8=B0=EB=8A=A5=EC=9C=BC=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LoadingCard.js | 11 ++ src/components/ProductCard.js | 30 ++++ src/features/cart/cart.modal.js | 230 +++++++++++++++++++++++++ src/features/cart/cart.state.js | 46 +++++ src/features/cart/index.js | 2 + src/main.js | 290 +------------------------------- src/pages/HomePage.js | 51 +----- 7 files changed, 331 insertions(+), 329 deletions(-) create mode 100644 src/components/LoadingCard.js create mode 100644 src/components/ProductCard.js create mode 100644 src/features/cart/cart.modal.js create mode 100644 src/features/cart/cart.state.js create mode 100644 src/features/cart/index.js diff --git a/src/components/LoadingCard.js b/src/components/LoadingCard.js new file mode 100644 index 000000000..177c079f1 --- /dev/null +++ b/src/components/LoadingCard.js @@ -0,0 +1,11 @@ +const LoadingCard = `
+
+
+
+
+
+
+
+
`; + +export const LoadingCardList = LoadingCard.repeat(4); diff --git a/src/components/ProductCard.js b/src/components/ProductCard.js new file mode 100644 index 000000000..9ebcfead9 --- /dev/null +++ b/src/components/ProductCard.js @@ -0,0 +1,30 @@ +export default function ProductCard(product) { + return ` +
+ +
+ ${product.title} +
+ +
+
+

+ ${product.title} +

+

+

+ ${product.lprice}원 +

+
+ + +
+
`; +} diff --git a/src/features/cart/cart.modal.js b/src/features/cart/cart.modal.js new file mode 100644 index 000000000..b05430fcb --- /dev/null +++ b/src/features/cart/cart.modal.js @@ -0,0 +1,230 @@ +import { cart, saveCart, updateCartBadge } from "./cart.state.js"; + +const format = (won) => `${Number(won).toLocaleString()}원`; + +export function openCartModal() { + if (document.querySelector(".cart-modal-overlay")) return; + + const overlay = document.createElement("div"); + overlay.className = + "cart-modal-overlay fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4"; + document.body.appendChild(overlay); + + const escHandler = (e) => { + if (e.key === "Escape") closeCartModal(); + }; + document.addEventListener("keydown", escHandler); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) closeCartModal(); + }); + + renderCartModal(); + + function closeCartModal() { + overlay.remove(); + document.removeEventListener("keydown", escHandler); + } + + function renderCartModal() { + overlay.innerHTML = ""; + + const cartItems = Object.values(cart); + if (cartItems.length === 0) { + overlay.innerHTML = ` +
+
+

+ + + + 장바구니 +

+ +
+
+
+
+ + + +
+

장바구니가 비어있습니다

+

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

+
+
+
`; + overlay.querySelector("#cart-modal-close-btn").onclick = closeCartModal; + return; + } + + const totalCount = cartItems.length; + const totalPrice = cartItems.reduce((sum, i) => sum + i.product.lprice * i.quantity, 0); + + const modal = document.createElement("div"); + modal.className = + "cart-modal relative bg-white rounded-t-lg sm:rounded-lg shadow-xl w-full max-w-md sm:max-w-lg max-h-[90vh] overflow-hidden"; + overlay.appendChild(modal); + + modal.innerHTML = ` + +
+

+ + + + 장바구니 (${totalCount}) +

+ +
+ + +
+
+ +
+
+
+ ${cartItems + .map( + ({ product, quantity }) => ` +
+ +
+ ${product.title} +
+
+

${product.title}

+

${format(product.lprice)}

+
+ + + +
+
+
+

${format(product.lprice * quantity)}

+ +
+
`, + ) + .join("")} +
+
+ + +
+
+ 총 금액 + ${format(totalPrice)} +
+
+ + +
+
+
`; + + modal.querySelector("#cart-modal-close-btn").onclick = closeCartModal; + modal.querySelectorAll(".quantity-increase-btn").forEach((btn) => { + btn.onclick = () => { + const pid = btn.dataset.productId; + cart[pid].quantity += 1; + saveCart(); + const qtyInput = modal.querySelector(`.quantity-input[data-product-id="${pid}"]`); + const priceField = modal.querySelector(`.price-field[data-product-id="${pid}"]`); + qtyInput.value = cart[pid].quantity; + priceField.textContent = format(cart[pid].quantity * cart[pid].product.lprice); + modal.querySelector("#cart-modal-total-amount").textContent = format( + Object.values(cart).reduce((s, c) => s + c.product.lprice * c.quantity, 0), + ); + }; + }); + modal.querySelectorAll(".quantity-decrease-btn").forEach((btn) => { + btn.onclick = () => { + const pid = btn.dataset.productId; + if (cart[pid].quantity === 1) return; + cart[pid].quantity -= 1; + saveCart(); + const qtyInput = modal.querySelector(`.quantity-input[data-product-id="${pid}"]`); + const priceField = modal.querySelector(`.price-field[data-product-id="${pid}"]`); + qtyInput.value = cart[pid].quantity; + priceField.textContent = format(cart[pid].quantity * cart[pid].product.lprice); + modal.querySelector("#cart-modal-total-amount").textContent = format( + Object.values(cart).reduce((s, c) => s + c.product.lprice * c.quantity, 0), + ); + }; + }); + modal.querySelectorAll(".cart-item-remove-btn").forEach((btn) => { + btn.onclick = () => { + const pid = btn.dataset.productId; + delete cart[pid]; + saveCart(); + updateCartBadge(); + renderCartModal(); + }; + }); + + const selectAllChk = modal.querySelector("#cart-modal-select-all-checkbox"); + const itemChks = modal.querySelectorAll(".cart-item-checkbox"); + const removeSelBtn = modal.querySelector("#cart-modal-remove-selected-btn"); + + const recalcSelection = () => { + const selectedCnt = [...itemChks].filter((c) => c.checked).length; + selectAllChk.checked = selectedCnt === itemChks.length; + selectAllChk.indeterminate = selectedCnt > 0 && selectedCnt < itemChks.length; + removeSelBtn.disabled = selectedCnt === 0; + removeSelBtn.textContent = `선택한 상품 삭제 (${selectedCnt}개)`; + }; + + selectAllChk.onchange = () => { + itemChks.forEach((c) => (c.checked = selectAllChk.checked)); + recalcSelection(); + }; + itemChks.forEach((c) => (c.onchange = recalcSelection)); + recalcSelection(); + + removeSelBtn.onclick = () => { + const selectedPids = [...itemChks].filter((c) => c.checked).map((c) => c.dataset.productId); + + selectedPids.forEach((pid) => delete cart[pid]); + saveCart(); + updateCartBadge(); + renderCartModal(); + }; + + modal.querySelector("#cart-modal-clear-cart-btn").onclick = () => { + Object.keys(cart).forEach((pid) => delete cart[pid]); + saveCart(); + updateCartBadge(); + renderCartModal(); + }; + } +} diff --git a/src/features/cart/cart.state.js b/src/features/cart/cart.state.js new file mode 100644 index 000000000..90f303af3 --- /dev/null +++ b/src/features/cart/cart.state.js @@ -0,0 +1,46 @@ +export let cart = {}; + +export function loadCart() { + try { + cart = JSON.parse(localStorage.getItem("shopping_cart") || "{}"); + } catch { + cart = {}; + } +} + +export function saveCart() { + localStorage.setItem("shopping_cart", JSON.stringify(cart)); +} + +export function showToast(msg = "장바구니에 추가되었습니다") { + const toast = document.createElement("div"); + toast.textContent = msg; + toast.className = "fixed top-4 left-1/2 -translate-x-1/2 bg-green-600 text-white px-4 py-2 rounded shadow z-50"; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2500); +} + +export function updateCartBadge() { + const btn = document.querySelector("#cart-icon-btn"); + if (!btn) return; + btn.querySelector("span")?.remove(); + + const count = Object.keys(cart).length; + if (count) { + const badge = document.createElement("span"); + badge.textContent = count; + badge.className = + "absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"; + btn.appendChild(badge); + } +} + +export function addToCart(product, qty = 1) { + const { productId } = product; + if (cart[productId]) cart[productId].quantity += qty; + else cart[productId] = { product, quantity: qty }; + + saveCart(); + updateCartBadge(); + showToast(); +} diff --git a/src/features/cart/index.js b/src/features/cart/index.js new file mode 100644 index 000000000..adf80d1ae --- /dev/null +++ b/src/features/cart/index.js @@ -0,0 +1,2 @@ +export * from "./cart.state.js"; +export { openCartModal } from "./cart.modal.js"; diff --git a/src/main.js b/src/main.js index 497e4c5ab..805e8d397 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,6 @@ import HomePage from "./pages/HomePage.js"; import { getProducts, getCategories } from "./api/productApi.js"; +import { loadCart, updateCartBadge, addToCart, openCartModal } from "./features/cart/index.js"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -19,288 +20,6 @@ let state = { cart: {}, }; -function loadCart() { - try { - state.cart = JSON.parse(localStorage.getItem("shopping_cart") || "{}"); - } catch { - state.cart = {}; - } -} - -function saveCart() { - localStorage.setItem("shopping_cart", JSON.stringify(state.cart)); -} - -function showToast(message = "장바구니에 추가되었습니다.") { - const toast = document.createElement("div"); - toast.textContent = message; - toast.className = "fixed top-4 left-1/2 -translate-x-1/2 bg-green-600 text-white px-4 py-2 rounded shadow z-50"; - document.body.appendChild(toast); - setTimeout(() => toast.remove(), 2500); -} - -function updateCartBadge() { - const cartIcon = document.querySelector("#cart-icon-btn"); - if (!cartIcon) return; - - const old = cartIcon.querySelector("span"); - if (old) old.remove(); - - const count = Object.keys(state.cart).length; - if (count > 0) { - const span = document.createElement("span"); - span.textContent = count; - span.className = - "absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"; - cartIcon.appendChild(span); - } -} - -function addToCart(productId, quantity = 1) { - if (!productId) return; - if (state.cart[productId]) { - state.cart[productId].quantity += quantity; - } else { - const product = state.products.find((p) => String(p.productId) === String(productId)); - state.cart[productId] = { product, quantity }; - } - - saveCart(); - updateCartBadge(); - showToast(); -} - -function formatPrice(won) { - return `${Number(won).toLocaleString()}원`; -} - -function openCartModal() { - if (document.querySelector(".cart-modal-overlay")) return; - - const overlay = document.createElement("div"); - overlay.className = - "cart-modal-overlay fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4"; - document.body.appendChild(overlay); - - const escHandler = (e) => { - if (e.key === "Escape") closeCartModal(); - }; - document.addEventListener("keydown", escHandler); - - overlay.addEventListener("click", (e) => { - if (e.target === overlay) closeCartModal(); - }); - - renderCartModal(); - - function closeCartModal() { - overlay.remove(); - document.removeEventListener("keydown", escHandler); - } - - function renderCartModal() { - overlay.innerHTML = ""; - - const cartItems = Object.values(state.cart); - if (cartItems.length === 0) { - overlay.innerHTML = ` -
-
-

- - - - 장바구니 -

- -
-
-
-
- - - -
-

장바구니가 비어있습니다

-

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

-
-
-
`; - overlay.querySelector("#cart-modal-close-btn").onclick = closeCartModal; - return; - } - - const totalCount = cartItems.length; - const totalPrice = cartItems.reduce((sum, i) => sum + i.product.lprice * i.quantity, 0); - - const modal = document.createElement("div"); - modal.className = - "cart-modal relative bg-white rounded-t-lg sm:rounded-lg shadow-xl w-full max-w-md sm:max-w-lg max-h-[90vh] overflow-hidden"; - overlay.appendChild(modal); - - modal.innerHTML = ` - -
-

- - - - 장바구니 (${totalCount}) -

- -
- - -
-
- -
-
-
- ${cartItems - .map( - ({ product, quantity }) => ` -
- -
- ${product.title} -
-
-

${product.title}

-

${formatPrice(product.lprice)}

-
- - - -
-
-
-

${formatPrice(product.lprice * quantity)}

- -
-
`, - ) - .join("")} -
-
- - -
-
- 총 금액 - ${formatPrice(totalPrice)} -
-
- - -
-
-
`; - - modal.querySelector("#cart-modal-close-btn").onclick = closeCartModal; - modal.querySelectorAll(".quantity-increase-btn").forEach((btn) => { - btn.onclick = () => { - const pid = btn.dataset.productId; - state.cart[pid].quantity += 1; - saveCart(); - const qtyInput = modal.querySelector(`.quantity-input[data-product-id="${pid}"]`); - const priceField = modal.querySelector(`.price-field[data-product-id="${pid}"]`); - qtyInput.value = state.cart[pid].quantity; - priceField.textContent = formatPrice(state.cart[pid].quantity * state.cart[pid].product.lprice); - modal.querySelector("#cart-modal-total-amount").textContent = formatPrice( - Object.values(state.cart).reduce((s, c) => s + c.product.lprice * c.quantity, 0), - ); - }; - }); - modal.querySelectorAll(".quantity-decrease-btn").forEach((btn) => { - btn.onclick = () => { - const pid = btn.dataset.productId; - if (state.cart[pid].quantity === 1) return; - state.cart[pid].quantity -= 1; - saveCart(); - const qtyInput = modal.querySelector(`.quantity-input[data-product-id="${pid}"]`); - const priceField = modal.querySelector(`.price-field[data-product-id="${pid}"]`); - qtyInput.value = state.cart[pid].quantity; - priceField.textContent = formatPrice(state.cart[pid].quantity * state.cart[pid].product.lprice); - modal.querySelector("#cart-modal-total-amount").textContent = formatPrice( - Object.values(state.cart).reduce((s, c) => s + c.product.lprice * c.quantity, 0), - ); - }; - }); - modal.querySelectorAll(".cart-item-remove-btn").forEach((btn) => { - btn.onclick = () => { - const pid = btn.dataset.productId; - delete state.cart[pid]; - saveCart(); - updateCartBadge(); - renderCartModal(); - }; - }); - - const selectAllChk = modal.querySelector("#cart-modal-select-all-checkbox"); - const itemChks = modal.querySelectorAll(".cart-item-checkbox"); - const removeSelBtn = modal.querySelector("#cart-modal-remove-selected-btn"); - - const recalcSelection = () => { - const selectedCnt = [...itemChks].filter((c) => c.checked).length; - selectAllChk.checked = selectedCnt === itemChks.length; - selectAllChk.indeterminate = selectedCnt > 0 && selectedCnt < itemChks.length; - removeSelBtn.disabled = selectedCnt === 0; - removeSelBtn.textContent = `선택한 상품 삭제 (${selectedCnt}개)`; - }; - - selectAllChk.onchange = () => { - itemChks.forEach((c) => (c.checked = selectAllChk.checked)); - recalcSelection(); - }; - itemChks.forEach((c) => (c.onchange = recalcSelection)); - recalcSelection(); - - removeSelBtn.onclick = () => { - const selectedPids = [...itemChks].filter((c) => c.checked).map((c) => c.dataset.productId); - - selectedPids.forEach((pid) => delete state.cart[pid]); - saveCart(); - updateCartBadge(); - renderCartModal(); - }; - - modal.querySelector("#cart-modal-clear-cart-btn").onclick = () => { - state.cart = {}; - saveCart(); - updateCartBadge(); - renderCartModal(); - }; - } -} - function attachEventListeners() { const limitSelect = document.querySelector("#limit-select"); if (limitSelect) { @@ -330,13 +49,12 @@ function attachEventListeners() { document.querySelectorAll(".add-to-cart-btn").forEach((btn) => { btn.onclick = () => { - const productId = btn.dataset.productId; - addToCart(productId); + const product = state.products.find((p) => p.productId === btn.dataset.productId); + addToCart(product); }; }); - const cartIconBtn = document.querySelector("#cart-icon-btn"); - if (cartIconBtn) cartIconBtn.onclick = openCartModal; + document.querySelector("#cart-icon-btn")?.addEventListener("click", openCartModal); } let scrollAttached = false; diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js index 399918169..035c462e1 100644 --- a/src/pages/HomePage.js +++ b/src/pages/HomePage.js @@ -1,45 +1,5 @@ -const LoadingCard = ` -
-
-
-
-
-
-
-
-
`; - -const LoadingCardList = LoadingCard.repeat(4); - -const ProductCard = (product) => ` -
- -
- ${product.title} -
- -
-
-

- ${product.title} -

-

-

- ${product.lprice}원 -

-
- - -
-
-`; +import ProductCard from "../components/ProductCard.js"; +import { LoadingCardList } from "../components/LoadingCard.js"; export default function HomePage({ products = [], total = 0, loading = false, loadingMore = false, categories = [] }) { return 상품목록_레이아웃_로딩(products, total, loading, loadingMore, categories); @@ -91,7 +51,12 @@ const 상품목록_레이아웃_로딩 = (products, total, loading, loadingMore)
- ${loading ? `
카테고리 로딩 중...
` : ""} + ${ + loading + ? `
카테고리 로딩 중...
` + : "" + }
From 526a542a4adbdaf3760b7552d971faf5226a3413 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Mon, 7 Jul 2025 00:40:13 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EA=B2=80?= =?UTF-8?q?=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.js | 81 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/src/main.js b/src/main.js index 805e8d397..7c303273c 100644 --- a/src/main.js +++ b/src/main.js @@ -18,6 +18,7 @@ let state = { limit: 20, page: 1, cart: {}, + search: "", }; function attachEventListeners() { @@ -26,31 +27,32 @@ function attachEventListeners() { limitSelect.value = String(state.limit); limitSelect.onchange = async (e) => { - const newLimit = Number(e.target.value); - if (state.limit === newLimit) return; - - state.limit = newLimit; + state.limit = Number(e.target.value); state.page = 1; - state.products = []; - state.loading = true; - render(); - - const { - products, - pagination: { total }, - } = await getProducts({ limit: state.limit, page: state.page }); - - state.products = products; - state.total = total; - state.loading = false; - render(); + fetchProductsAndRender(); + }; + } + + const searchInput = document.querySelector("#search-input"); + if (searchInput) { + searchInput.value = state.search; + + searchInput.onkeydown = (e) => { + if (e.key === "Enter") { + const keyword = searchInput.value.trim(); + if (state.search !== keyword) { + state.search = keyword; + state.page = 1; + fetchProductsAndRender(); + } + } }; } document.querySelectorAll(".add-to-cart-btn").forEach((btn) => { btn.onclick = () => { - const product = state.products.find((p) => p.productId === btn.dataset.productId); - addToCart(product); + const product = state.products.find((p) => String(p.productId) === btn.dataset.productId); + if (product) addToCart(product); }; }); @@ -72,18 +74,37 @@ function setupInfiniteScroll() { }); } +async function fetchProducts() { + const { products, pagination } = await getProducts({ + limit: state.limit, + page: state.page, + search: state.search, + }); + state.products = products; + state.total = pagination.total; +} + +async function fetchProductsAndRender() { + state.loading = true; + render(); + await fetchProducts(); + state.loading = false; + render(); +} + async function loadMore() { state.loadingMore = true; render(); const nextPage = state.page + 1; - const { - products: newProducts, - pagination: { total }, - } = await getProducts({ limit: state.limit, page: nextPage }); + const { products: newProducts, pagination } = await getProducts({ + limit: state.limit, + page: nextPage, + search: state.search, + }); state.products = [...state.products, ...newProducts]; - state.total = total; + state.total = pagination.total; state.page = nextPage; state.loadingMore = false; render(); @@ -100,20 +121,10 @@ async function main() { state.loading = true; render(); - const [ - { - products, - pagination: { total }, - }, - categories, - ] = await Promise.all([getProducts({ limit: state.limit, page: state.page }), getCategories()]); + await Promise.all([fetchProducts(), getCategories().then((c) => (state.categories = c))]); - state.products = products; - state.total = total; - state.categories = categories; state.loading = false; render(); - setupInfiniteScroll(); } From fbdbd0dc10d1fe98f35eb4adb63d4e39f1902250 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Mon, 7 Jul 2025 01:18:04 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.js | 35 +++++++++++++++++ src/pages/HomePage.js | 87 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/main.js b/src/main.js index 7c303273c..674b1734b 100644 --- a/src/main.js +++ b/src/main.js @@ -19,6 +19,8 @@ let state = { page: 1, cart: {}, search: "", + category1: "", + category2: "", }; function attachEventListeners() { @@ -57,6 +59,35 @@ function attachEventListeners() { }); document.querySelector("#cart-icon-btn")?.addEventListener("click", openCartModal); + + document.querySelectorAll(".category1-filter-btn").forEach((btn) => { + btn.onclick = () => { + state.category1 = btn.dataset.category1; + state.category2 = ""; + state.page = 1; + fetchProductsAndRender(); + }; + }); + document.querySelectorAll(".category2-filter-btn").forEach((btn) => { + btn.onclick = () => { + state.category1 = btn.dataset.category1; + state.category2 = btn.dataset.category2; + state.page = 1; + fetchProductsAndRender(); + }; + }); + document.querySelector('[data-breadcrumb="reset"]')?.addEventListener("click", () => { + state.category1 = ""; + state.category2 = ""; + state.page = 1; + fetchProductsAndRender(); + }); + document.querySelector('[data-breadcrumb="category1"]')?.addEventListener("click", (e) => { + state.category1 = e.target.dataset.category1; + state.category2 = ""; + state.page = 1; + fetchProductsAndRender(); + }); } let scrollAttached = false; @@ -79,6 +110,8 @@ async function fetchProducts() { limit: state.limit, page: state.page, search: state.search, + category1: state.category1, + category2: state.category2, }); state.products = products; state.total = pagination.total; @@ -101,6 +134,8 @@ async function loadMore() { limit: state.limit, page: nextPage, search: state.search, + category1: state.category1, + category2: state.category2, }); state.products = [...state.products, ...newProducts]; diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js index 035c462e1..e97f50931 100644 --- a/src/pages/HomePage.js +++ b/src/pages/HomePage.js @@ -1,11 +1,19 @@ import ProductCard from "../components/ProductCard.js"; import { LoadingCardList } from "../components/LoadingCard.js"; -export default function HomePage({ products = [], total = 0, loading = false, loadingMore = false, categories = [] }) { - return 상품목록_레이아웃_로딩(products, total, loading, loadingMore, categories); +export default function HomePage({ + products = [], + total = 0, + loading = false, + loadingMore = false, + categories = {}, + category1 = "", + category2 = "", +}) { + return 상품목록_레이아웃_로딩(products, total, loading, loadingMore, categories, category1, category2); } -const 상품목록_레이아웃_로딩 = (products, total, loading, loadingMore) => ` +const 상품목록_레이아웃_로딩 = (products, total, loading, loadingMore, categories, category1, category2) => `
@@ -45,20 +53,31 @@ const 상품목록_레이아웃_로딩 = (products, total, loading, loadingMore)
+
- + ${breadcrumb(category1, category2)}
- -
+ ${ - loading - ? `
카테고리 로딩 중...
` + category1 + ? "" + : `
+ ${ + loading + ? '
카테고리 로딩 중...
' + : renderCategory1(categories, category1) + } +
` + } + + ${ + category1 + ? `
+ ${renderCategory2(categories, category1, category2)} +
` : "" } -
-
@@ -140,3 +159,49 @@ const 상품목록_레이아웃_로딩 = (products, total, loading, loadingMore)
`; + +const renderCategory1 = (cats, selected) => + Object.keys(cats) + .map( + (c) => ``, + ) + .join(""); + +const renderCategory2 = (cats, c1, c2) => + c1 && cats[c1] + ? Object.keys(cats[c1]) + .map( + (c) => ``, + ) + .join("") + : ""; + +const breadcrumb = (c1, c2) => { + let html = ''; + if (c1) { + html += `> + `; + } + if (c2) { + html += `> + ${c2}`; + } + return html; +}; From 18a1ae13c646ac5e67a20c65bf3a0848cd7203e8 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Mon, 7 Jul 2025 01:28:07 +0900 Subject: [PATCH 07/19] =?UTF-8?q?fix:=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/cart/cart.modal.js | 52 ++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/features/cart/cart.modal.js b/src/features/cart/cart.modal.js index b05430fcb..10c003228 100644 --- a/src/features/cart/cart.modal.js +++ b/src/features/cart/cart.modal.js @@ -137,17 +137,33 @@ export function openCartModal() {
+ + + +
총 금액 ${format(totalPrice)}
+ + + +
- - +
`; @@ -198,10 +214,32 @@ export function openCartModal() { const recalcSelection = () => { const selectedCnt = [...itemChks].filter((c) => c.checked).length; + selectAllChk.checked = selectedCnt === itemChks.length; selectAllChk.indeterminate = selectedCnt > 0 && selectedCnt < itemChks.length; - removeSelBtn.disabled = selectedCnt === 0; - removeSelBtn.textContent = `선택한 상품 삭제 (${selectedCnt}개)`; + + // 선택 요약 + 버튼 노출 제어 + const selSummary = modal.querySelector("#cart-modal-selected-summary"); + const selAmount = modal.querySelector("#selected-amount"); + const selBtn = modal.querySelector("#cart-modal-remove-selected-btn"); + + if (selectedCnt === 0) { + selSummary.classList.add("hidden"); + selBtn.classList.add("hidden"); + } else { + const sum = [...itemChks] + .filter((c) => c.checked) + .reduce((s, c) => { + const pid = c.dataset.productId; + return s + cart[pid].product.lprice * cart[pid].quantity; + }, 0); + + selSummary.classList.remove("hidden"); + selBtn.classList.remove("hidden"); + selSummary.querySelector("#selected-count").textContent = selectedCnt; + selAmount.textContent = format(sum); + selBtn.textContent = `선택한 상품 삭제 (${selectedCnt}개)`; + } }; selectAllChk.onchange = () => { @@ -217,7 +255,7 @@ export function openCartModal() { selectedPids.forEach((pid) => delete cart[pid]); saveCart(); updateCartBadge(); - renderCartModal(); + renderCartModal(); // 모달 전체 재렌더 → UI 동기화 }; modal.querySelector("#cart-modal-clear-cart-btn").onclick = () => { From b053d6cb9ada49b6473c53e5274d468425b6af66 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Mon, 7 Jul 2025 01:52:45 +0900 Subject: [PATCH 08/19] =?UTF-8?q?feat:=20product=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.js | 70 +++++++++++++++++++++++++++++---- src/pages/ProductDetailPage.js | 71 ++++++++++++++++++++++++++++++++++ src/router.js | 23 +++++++++++ 3 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 src/pages/ProductDetailPage.js create mode 100644 src/router.js diff --git a/src/main.js b/src/main.js index 674b1734b..4fd8e2cf9 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,7 @@ import HomePage from "./pages/HomePage.js"; import { getProducts, getCategories } from "./api/productApi.js"; import { loadCart, updateCartBadge, addToCart, openCartModal } from "./features/cart/index.js"; +import ProductDetailPage from "./pages/ProductDetailPage.js"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -88,11 +89,19 @@ function attachEventListeners() { state.page = 1; fetchProductsAndRender(); }); + + document.querySelectorAll(".product-image, .product-info").forEach((el) => { + el.onclick = () => { + const card = el.closest(".product-card"); + const pid = card.dataset.productId; + navigate(`/product/${pid}`); + }; + }); } let scrollAttached = false; -function setupInfiniteScroll() { +export function setupInfiniteScroll() { if (scrollAttached) return; scrollAttached = true; @@ -153,14 +162,8 @@ function render() { async function main() { loadCart(); - state.loading = true; - render(); - await Promise.all([fetchProducts(), getCategories().then((c) => (state.categories = c))]); - - state.loading = false; - render(); - setupInfiniteScroll(); + renderRoute(); } if (import.meta.env.MODE !== "test") { @@ -168,3 +171,54 @@ if (import.meta.env.MODE !== "test") { } else { main(); } + +function navigate(path) { + if (window.location.pathname === path) return; + history.pushState({}, "", path); + renderRoute(); +} + +window.addEventListener("popstate", renderRoute); + +async function renderRoute() { + const { pathname } = location; + + if (pathname.startsWith("/product/")) { + const productId = pathname.split("/product/")[1]; + + document.querySelector("#root").innerHTML = ProductDetailPage({ loading: true }); + + const product = await (await import("./api/productApi.js")).getProduct(productId); + const { products: all } = await (await import("./api/productApi.js")).getProducts({ limit: 100 }); + + const related = all.filter((p) => p.productId !== productId).slice(0, 19); + + document.querySelector("#root").innerHTML = ProductDetailPage({ + loading: false, + product, + related, + }); + attachDetailEvents(product); + updateCartBadge(); + return; + } + + render(); +} + +function attachDetailEvents(product) { + const qtyInput = document.querySelector("#quantity-input"); + document.querySelector("#quantity-increase").onclick = () => (qtyInput.value = Number(qtyInput.value) + 1); + document.querySelector("#quantity-decrease").onclick = () => { + if (qtyInput.value > 1) qtyInput.value = Number(qtyInput.value) - 1; + }; + + document.querySelector("#add-to-cart-btn").onclick = () => { + const qty = Number(qtyInput.value); + addToCart(product, qty); + }; + + document.querySelectorAll(".related-product-card").forEach((card) => { + card.onclick = () => navigate(`/product/${card.dataset.productId}`); + }); +} diff --git a/src/pages/ProductDetailPage.js b/src/pages/ProductDetailPage.js new file mode 100644 index 000000000..dc28e4330 --- /dev/null +++ b/src/pages/ProductDetailPage.js @@ -0,0 +1,71 @@ +export default function ProductDetailPage({ loading = true, product = null, related = [] }) { + if (loading || !product) { + return ` +
+
+
`; + } + + return ` +
+ +
+
+ +

상품 상세

+
+
+ +
+
+
+ ${product.title} +
+

${product.title}

+

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

+ +
+ 수량 +
+ + + +
+
+ +
+ + +
+
+

관련 상품

+
+
+ ${related + .map( + (r) => ` + `, + ) + .join("")} +
+
+
+
`; +} diff --git a/src/router.js b/src/router.js new file mode 100644 index 000000000..2bd890947 --- /dev/null +++ b/src/router.js @@ -0,0 +1,23 @@ +import ProductDetailPage from "./pages/ProductDetailPage.js"; +import { fetchProductsAndRender, renderHome } from "./state.js"; + +// URL → 화면 +export async function router() { + const match = location.pathname.match(/^\/product\/(\w+)/); + if (match) { + await ProductDetailPage(match[1]); + } else { + // 홈 + await fetchProductsAndRender(); + renderHome(); + } +} + +// 링크 처리 +export function handleLinkClicks(e) { + const a = e.target.closest("[data-link]"); + if (!a) return; + e.preventDefault(); + history.pushState(null, "", a.href); + router(); +} From 3f8b291973302f16050a2e933d9ef6732fadbde3 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Mon, 7 Jul 2025 23:09:59 +0900 Subject: [PATCH 09/19] =?UTF-8?q?feat:=20Toast=20=EA=B5=AC=ED=98=84,=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=83=81=EC=84=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Toast.js | 48 +++++++++++++++++++++++++++++++++ src/features/cart/cart.modal.js | 7 +++-- src/features/cart/cart.state.js | 12 +++------ src/main.js | 4 +++ src/pages/ProductDetailPage.js | 27 +++++++++++++++++++ 5 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 src/components/Toast.js diff --git a/src/components/Toast.js b/src/components/Toast.js new file mode 100644 index 000000000..2196e9fe3 --- /dev/null +++ b/src/components/Toast.js @@ -0,0 +1,48 @@ +const COLORS = { + success: "bg-green-600", + info: "bg-blue-600", + error: "bg-red-600", +}; + +/** + * 토스트를 화면에 표시 + * @param {string} message + * @param {"success"|"info"|"error"} type + * @param {number} duration + */ +export function showToast(message, type = "success", duration = 3000) { + const wrapper = document.createElement("div"); + wrapper.className = "fixed bottom-6 left-1/2 -translate-x-1/2 z-[60]"; + + wrapper.innerHTML = ` +
+
+ ${ + type === "success" + ? ` + + ` + : type === "info" + ? ` + + ` + : ` + + ` + } +
+

${message}

+ +
`; + + document.body.appendChild(wrapper); + + const close = () => wrapper.remove(); + wrapper.querySelector(".toast-close-btn").onclick = close; + setTimeout(close, duration); +} diff --git a/src/features/cart/cart.modal.js b/src/features/cart/cart.modal.js index 10c003228..902d94cd6 100644 --- a/src/features/cart/cart.modal.js +++ b/src/features/cart/cart.modal.js @@ -1,4 +1,5 @@ import { cart, saveCart, updateCartBadge } from "./cart.state.js"; +import { showToast } from "../../components/Toast.js"; const format = (won) => `${Number(won).toLocaleString()}원`; @@ -205,6 +206,7 @@ export function openCartModal() { saveCart(); updateCartBadge(); renderCartModal(); + showToast("삭제되었습니다", "info"); }; }); @@ -218,7 +220,6 @@ export function openCartModal() { selectAllChk.checked = selectedCnt === itemChks.length; selectAllChk.indeterminate = selectedCnt > 0 && selectedCnt < itemChks.length; - // 선택 요약 + 버튼 노출 제어 const selSummary = modal.querySelector("#cart-modal-selected-summary"); const selAmount = modal.querySelector("#selected-amount"); const selBtn = modal.querySelector("#cart-modal-remove-selected-btn"); @@ -255,7 +256,8 @@ export function openCartModal() { selectedPids.forEach((pid) => delete cart[pid]); saveCart(); updateCartBadge(); - renderCartModal(); // 모달 전체 재렌더 → UI 동기화 + renderCartModal(); + showToast("선택한 상품이 삭제되었습니다", "info"); }; modal.querySelector("#cart-modal-clear-cart-btn").onclick = () => { @@ -263,6 +265,7 @@ export function openCartModal() { saveCart(); updateCartBadge(); renderCartModal(); + showToast("장바구니를 비웠습니다", "info"); }; } } diff --git a/src/features/cart/cart.state.js b/src/features/cart/cart.state.js index 90f303af3..4245c5e04 100644 --- a/src/features/cart/cart.state.js +++ b/src/features/cart/cart.state.js @@ -1,3 +1,5 @@ +import { showToast } from "../../components/Toast.js"; + export let cart = {}; export function loadCart() { @@ -12,14 +14,6 @@ export function saveCart() { localStorage.setItem("shopping_cart", JSON.stringify(cart)); } -export function showToast(msg = "장바구니에 추가되었습니다") { - const toast = document.createElement("div"); - toast.textContent = msg; - toast.className = "fixed top-4 left-1/2 -translate-x-1/2 bg-green-600 text-white px-4 py-2 rounded shadow z-50"; - document.body.appendChild(toast); - setTimeout(() => toast.remove(), 2500); -} - export function updateCartBadge() { const btn = document.querySelector("#cart-icon-btn"); if (!btn) return; @@ -42,5 +36,5 @@ export function addToCart(product, qty = 1) { saveCart(); updateCartBadge(); - showToast(); + showToast("장바구니에 추가되었습니다", "success"); } diff --git a/src/main.js b/src/main.js index 4fd8e2cf9..83fefd98f 100644 --- a/src/main.js +++ b/src/main.js @@ -221,4 +221,8 @@ function attachDetailEvents(product) { document.querySelectorAll(".related-product-card").forEach((card) => { card.onclick = () => navigate(`/product/${card.dataset.productId}`); }); + + document.querySelector(".go-to-product-list")?.addEventListener("click", () => { + navigate("/"); + }); } diff --git a/src/pages/ProductDetailPage.js b/src/pages/ProductDetailPage.js index dc28e4330..c6079ad42 100644 --- a/src/pages/ProductDetailPage.js +++ b/src/pages/ProductDetailPage.js @@ -26,8 +26,28 @@ export default function ProductDetailPage({ loading = true, product = null, rela ${product.title}

${product.title}

+ + +
+
+ ${[1, 2, 3, 4, 5] + .map( + (n) => ` + + `, + ) + .join("")} +
+ ${product.rating.toFixed(1)} (${product.reviewCount}개 리뷰) +
+

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

+ +
재고 ${product.stock}개
+
수량
@@ -46,6 +66,13 @@ export default function ProductDetailPage({ loading = true, product = null, rela
+ +
+ +
+
From 94b7e187151d6b2e8138918c7f4b41fdd02392fc Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Mon, 7 Jul 2025 23:52:56 +0900 Subject: [PATCH 10/19] =?UTF-8?q?fix:=20=ED=8F=B4=EB=8D=94=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EA=B0=9C=EC=B2=B4=EC=A7=80=ED=96=A5=EC=A0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/HomePageController.js | 181 +++++++++++++++++++++ src/controllers/ProductDetailController.js | 46 ++++++ src/features/cart/cart.state.js | 35 +--- src/main.js | 93 +++-------- src/router/Router.js | 65 ++++++++ src/services/CartService.js | 103 ++++++++++++ src/services/InfiniteScrollManager.js | 36 ++++ src/services/ProductService.js | 22 +++ 8 files changed, 486 insertions(+), 95 deletions(-) create mode 100644 src/controllers/HomePageController.js create mode 100644 src/controllers/ProductDetailController.js create mode 100644 src/router/Router.js create mode 100644 src/services/CartService.js create mode 100644 src/services/InfiniteScrollManager.js create mode 100644 src/services/ProductService.js diff --git a/src/controllers/HomePageController.js b/src/controllers/HomePageController.js new file mode 100644 index 000000000..3f196e75f --- /dev/null +++ b/src/controllers/HomePageController.js @@ -0,0 +1,181 @@ +import HomePage from "../pages/HomePage.js"; +import productService from "../services/ProductService.js"; +import router from "../router/Router.js"; +import InfiniteScrollManager from "../services/InfiniteScrollManager.js"; +import { addToCart, updateCartBadge, openCartModal, loadCart } from "../features/cart/index.js"; + +class HomePageController { + constructor(rootSelector = "#root") { + this.rootEl = document.querySelector(rootSelector); + this.state = { + products: [], + total: 0, + loading: false, + loadingMore: false, + categories: {}, + limit: 20, + page: 1, + search: "", + category1: "", + category2: "", + sort: "price_asc", + }; + this.scrollManager = null; + } + + async init() { + loadCart(); + await Promise.all([this.fetchProducts(), productService.getCategories().then((c) => (this.state.categories = c))]); + this.render(); + this.setupInfiniteScroll(); + } + + async fetchProducts() { + const { products, pagination } = await productService.getProducts({ + limit: this.state.limit, + page: this.state.page, + search: this.state.search, + category1: this.state.category1, + category2: this.state.category2, + sort: this.state.sort, + }); + this.state.products = products; + this.state.total = pagination.total; + } + + async loadMore() { + this.state.loadingMore = true; + this.render(); + const nextPage = this.state.page + 1; + const { products: newProducts, pagination } = await productService.getProducts({ + limit: this.state.limit, + page: nextPage, + search: this.state.search, + category1: this.state.category1, + category2: this.state.category2, + sort: this.state.sort, + }); + this.state.products = [...this.state.products, ...newProducts]; + this.state.total = pagination.total; + this.state.page = nextPage; + this.state.loadingMore = false; + this.render(); + } + + setupInfiniteScroll() { + if (this.scrollManager) return; + this.scrollManager = new InfiniteScrollManager(async () => { + if (!this.state.loading && !this.state.loadingMore && this.state.products.length < this.state.total) { + await this.loadMore(); + } + }); + this.scrollManager.attach(); + } + + render() { + this.rootEl.innerHTML = HomePage(this.state); + this.attachEventListeners(); + updateCartBadge(); + } + + attachEventListeners() { + const { state } = this; + const limitSelect = document.querySelector("#limit-select"); + if (limitSelect) { + limitSelect.value = String(state.limit); + limitSelect.onchange = async (e) => { + state.limit = Number(e.target.value); + state.page = 1; + await this.fetchProducts(); + this.render(); + }; + } + + const searchInput = document.querySelector("#search-input"); + if (searchInput) { + searchInput.value = state.search; + searchInput.onkeydown = async (e) => { + if (e.key === "Enter") { + const keyword = searchInput.value.trim(); + if (state.search !== keyword) { + state.search = keyword; + state.page = 1; + await this.fetchProducts(); + this.render(); + } + } + }; + } + + document.querySelectorAll(".add-to-cart-btn").forEach((btn) => { + btn.onclick = () => { + const product = state.products.find((p) => String(p.productId) === btn.dataset.productId); + if (product) addToCart(product); + }; + }); + + document.querySelector("#cart-icon-btn")?.addEventListener("click", openCartModal); + + // 카테고리 필터 + document.querySelectorAll(".category1-filter-btn").forEach((btn) => { + btn.onclick = async () => { + state.category1 = btn.dataset.category1; + state.category2 = ""; + state.page = 1; + await this.fetchProducts(); + this.render(); + }; + }); + + document.querySelectorAll(".category2-filter-btn").forEach((btn) => { + btn.onclick = async () => { + state.category1 = btn.dataset.category1; + state.category2 = btn.dataset.category2; + state.page = 1; + await this.fetchProducts(); + this.render(); + }; + }); + + document.querySelector('[data-breadcrumb="reset"]')?.addEventListener("click", async () => { + state.category1 = ""; + state.category2 = ""; + state.page = 1; + await this.fetchProducts(); + this.render(); + }); + + document.querySelector('[data-breadcrumb="category1"]')?.addEventListener("click", async (e) => { + state.category1 = e.target.dataset.category1; + state.category2 = ""; + state.page = 1; + await this.fetchProducts(); + this.render(); + }); + + // 상품 카드 클릭 → 상세 페이지 이동 + document.querySelectorAll(".product-image, .product-info").forEach((el) => { + el.onclick = () => { + const card = el.closest(".product-card"); + const pid = card.dataset.productId; + router.navigate(`/product/${pid}`); + }; + }); + + // 정렬 드롭다운 + const sortSelect = document.querySelector("#sort-select"); + if (sortSelect) { + sortSelect.value = state.sort; + sortSelect.onchange = async (e) => { + state.sort = e.target.value; + state.page = 1; + await this.fetchProducts(); + this.render(); + }; + } + } +} + +// 싱글톤 인스턴스로 export +const homePageController = new HomePageController(); +export default homePageController; diff --git a/src/controllers/ProductDetailController.js b/src/controllers/ProductDetailController.js new file mode 100644 index 000000000..fd3af724c --- /dev/null +++ b/src/controllers/ProductDetailController.js @@ -0,0 +1,46 @@ +import ProductDetailPage from "../pages/ProductDetailPage.js"; +import productService from "../services/ProductService.js"; +import router from "../router/Router.js"; +import { addToCart, updateCartBadge } from "../features/cart/index.js"; + +class ProductDetailController { + constructor(rootSelector = "#root") { + this.rootEl = document.querySelector(rootSelector); + } + + async show(productId) { + this.rootEl.innerHTML = ProductDetailPage({ loading: true }); + + const product = await productService.getProduct(productId); + const { products: all } = await productService.getProducts({ limit: 100 }); + const related = all.filter((p) => p.productId !== productId).slice(0, 19); + + this.rootEl.innerHTML = ProductDetailPage({ loading: false, product, related }); + this.attachEvents(product); + updateCartBadge(); + } + + attachEvents(product) { + const qtyInput = document.querySelector("#quantity-input"); + document.querySelector("#quantity-increase").onclick = () => (qtyInput.value = Number(qtyInput.value) + 1); + document.querySelector("#quantity-decrease").onclick = () => { + if (qtyInput.value > 1) qtyInput.value = Number(qtyInput.value) - 1; + }; + + document.querySelector("#add-to-cart-btn").onclick = () => { + const qty = Number(qtyInput.value); + addToCart(product, qty); + }; + + document.querySelectorAll(".related-product-card").forEach((card) => { + card.onclick = () => router.navigate(`/product/${card.dataset.productId}`); + }); + + document.querySelector(".go-to-product-list")?.addEventListener("click", () => { + router.navigate("/"); + }); + } +} + +const productDetailController = new ProductDetailController(); +export default productDetailController; diff --git a/src/features/cart/cart.state.js b/src/features/cart/cart.state.js index 4245c5e04..402873c98 100644 --- a/src/features/cart/cart.state.js +++ b/src/features/cart/cart.state.js @@ -1,40 +1,21 @@ -import { showToast } from "../../components/Toast.js"; +import cartService from "../../services/CartService.js"; +// showToast 는 CartService 내부에서 호출됨 -export let cart = {}; +// CartService 의 카트 객체를 그대로 노출 (레거시 호환) +export const cart = cartService.getCart(); export function loadCart() { - try { - cart = JSON.parse(localStorage.getItem("shopping_cart") || "{}"); - } catch { - cart = {}; - } + cartService.loadFromStorage(); } export function saveCart() { - localStorage.setItem("shopping_cart", JSON.stringify(cart)); + cartService.saveToStorage(); } export function updateCartBadge() { - const btn = document.querySelector("#cart-icon-btn"); - if (!btn) return; - btn.querySelector("span")?.remove(); - - const count = Object.keys(cart).length; - if (count) { - const badge = document.createElement("span"); - badge.textContent = count; - badge.className = - "absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"; - btn.appendChild(badge); - } + cartService.updateBadge(); } export function addToCart(product, qty = 1) { - const { productId } = product; - if (cart[productId]) cart[productId].quantity += qty; - else cart[productId] = { product, quantity: qty }; - - saveCart(); - updateCartBadge(); - showToast("장바구니에 추가되었습니다", "success"); + cartService.add(product, qty); } diff --git a/src/main.js b/src/main.js index 83fefd98f..1575e98d1 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,10 @@ import HomePage from "./pages/HomePage.js"; -import { getProducts, getCategories } from "./api/productApi.js"; -import { loadCart, updateCartBadge, addToCart, openCartModal } from "./features/cart/index.js"; -import ProductDetailPage from "./pages/ProductDetailPage.js"; +import productService from "./services/ProductService.js"; +import { updateCartBadge, addToCart, openCartModal } from "./features/cart/index.js"; +import router from "./router/Router.js"; +import InfiniteScrollManager from "./services/InfiniteScrollManager.js"; +import homePageController from "./controllers/HomePageController.js"; +import productDetailController from "./controllers/ProductDetailController.js"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -99,23 +102,20 @@ function attachEventListeners() { }); } -let scrollAttached = false; +let scrollManager; export function setupInfiniteScroll() { - if (scrollAttached) return; - scrollAttached = true; - - window.addEventListener("scroll", () => { - const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200; - - if (nearBottom && !state.loading && !state.loadingMore && state.products.length < state.total) { - loadMore(); + if (scrollManager) return; + scrollManager = new InfiniteScrollManager(async () => { + if (!state.loading && !state.loadingMore && state.products.length < state.total) { + await loadMore(); } }); + scrollManager.attach(); } async function fetchProducts() { - const { products, pagination } = await getProducts({ + const { products, pagination } = await productService.getProducts({ limit: state.limit, page: state.page, search: state.search, @@ -139,7 +139,7 @@ async function loadMore() { render(); const nextPage = state.page + 1; - const { products: newProducts, pagination } = await getProducts({ + const { products: newProducts, pagination } = await productService.getProducts({ limit: state.limit, page: nextPage, search: state.search, @@ -161,9 +161,8 @@ function render() { } async function main() { - loadCart(); - await Promise.all([fetchProducts(), getCategories().then((c) => (state.categories = c))]); - renderRoute(); + // 초깃값 렌더링은 컨트롤러/라우터가 담당 + router.handle(window.location.pathname); } if (import.meta.env.MODE !== "test") { @@ -173,56 +172,14 @@ if (import.meta.env.MODE !== "test") { } function navigate(path) { - if (window.location.pathname === path) return; - history.pushState({}, "", path); - renderRoute(); + router.navigate(path); } -window.addEventListener("popstate", renderRoute); - -async function renderRoute() { - const { pathname } = location; - - if (pathname.startsWith("/product/")) { - const productId = pathname.split("/product/")[1]; - - document.querySelector("#root").innerHTML = ProductDetailPage({ loading: true }); - - const product = await (await import("./api/productApi.js")).getProduct(productId); - const { products: all } = await (await import("./api/productApi.js")).getProducts({ limit: 100 }); - - const related = all.filter((p) => p.productId !== productId).slice(0, 19); - - document.querySelector("#root").innerHTML = ProductDetailPage({ - loading: false, - product, - related, - }); - attachDetailEvents(product); - updateCartBadge(); - return; - } - - render(); -} - -function attachDetailEvents(product) { - const qtyInput = document.querySelector("#quantity-input"); - document.querySelector("#quantity-increase").onclick = () => (qtyInput.value = Number(qtyInput.value) + 1); - document.querySelector("#quantity-decrease").onclick = () => { - if (qtyInput.value > 1) qtyInput.value = Number(qtyInput.value) - 1; - }; - - document.querySelector("#add-to-cart-btn").onclick = () => { - const qty = Number(qtyInput.value); - addToCart(product, qty); - }; - - document.querySelectorAll(".related-product-card").forEach((card) => { - card.onclick = () => navigate(`/product/${card.dataset.productId}`); - }); - - document.querySelector(".go-to-product-list")?.addEventListener("click", () => { - navigate("/"); - }); -} +// 라우트 등록 (컨트롤러 활용) +router.add("/", () => { + homePageController.init(); +}); +router.add("/product/:id", async ({ id }) => { + await productDetailController.show(id); +}); +router.setNotFound(() => homePageController.init()); diff --git a/src/router/Router.js b/src/router/Router.js new file mode 100644 index 000000000..6095a38e3 --- /dev/null +++ b/src/router/Router.js @@ -0,0 +1,65 @@ +class Router { + constructor() { + this.routes = []; + this.notFoundHandler = null; + + // 브라우저 뒤로가기/앞으로가기 처리 + window.addEventListener("popstate", () => { + this.handle(window.location.pathname); + }); + } + + // 라우트 등록: 패턴(예: '/product/:id')과 핸들러 함수 + add(pattern, handler) { + this.routes.push({ pattern, handler }); + } + + // 404(매칭 안됨) 핸들러 등록 + setNotFound(handler) { + this.notFoundHandler = handler; + } + + // 내비게이션 (pushState 후 핸들) + navigate(path) { + if (window.location.pathname === path) return; + history.pushState({}, "", path); + this.handle(path); + } + + // 현재 경로 처리 + handle(path) { + for (const { pattern, handler } of this.routes) { + const params = this.match(pattern, path); + if (params) { + handler(params); + return; + } + } + // 매칭 실패 시 notFound + if (this.notFoundHandler) this.notFoundHandler(); + } + + // 패턴 매칭 (간단한 ':' 파라미터 지원) + match(pattern, path) { + const patternParts = pattern.split("/").filter(Boolean); + const pathParts = path.split("/").filter(Boolean); + + if (patternParts.length !== pathParts.length) return null; + + const params = {}; + for (let i = 0; i < patternParts.length; i++) { + const pp = patternParts[i]; + const cp = pathParts[i]; + if (pp.startsWith(":")) { + params[pp.slice(1)] = decodeURIComponent(cp); + } else if (pp !== cp) { + return null; + } + } + return params; + } +} + +// 싱글톤 인스턴스 export +const router = new Router(); +export default router; diff --git a/src/services/CartService.js b/src/services/CartService.js new file mode 100644 index 000000000..54b3e71ec --- /dev/null +++ b/src/services/CartService.js @@ -0,0 +1,103 @@ +import { showToast } from "../components/Toast.js"; + +class CartService { + constructor() { + this.cart = {}; + this.loadFromStorage(); + } + + // 로컬스토리지에서 장바구니 불러오기 + loadFromStorage() { + try { + this.cart = JSON.parse(localStorage.getItem("shopping_cart") || "{}"); + } catch { + this.cart = {}; + } + } + + // 로컬스토리지에 저장 + saveToStorage() { + localStorage.setItem("shopping_cart", JSON.stringify(this.cart)); + } + + // 장바구니 배지 업데이트 (UI) + updateBadge() { + const btn = document.querySelector("#cart-icon-btn"); + if (!btn) return; + btn.querySelector("span")?.remove(); + + const count = Object.keys(this.cart).length; + if (count) { + const badge = document.createElement("span"); + badge.textContent = count; + badge.className = + "absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"; + btn.appendChild(badge); + } + } + + // 장바구니 추가 + add(product, qty = 1) { + const { productId } = product; + if (this.cart[productId]) this.cart[productId].quantity += qty; + else this.cart[productId] = { product, quantity: qty }; + + this.saveToStorage(); + this.updateBadge(); + showToast("장바구니에 추가되었습니다", "success"); + } + + // 수량 변경 (절대 값 설정) + setQuantity(productId, quantity) { + if (!this.cart[productId]) return; + if (quantity < 1) return; + this.cart[productId].quantity = quantity; + this.saveToStorage(); + this.updateBadge(); + } + + // 수량 증가 + increase(productId) { + if (!this.cart[productId]) return; + this.cart[productId].quantity += 1; + this.saveToStorage(); + this.updateBadge(); + } + + // 수량 감소 + decrease(productId) { + if (!this.cart[productId]) return; + if (this.cart[productId].quantity === 1) return; + this.cart[productId].quantity -= 1; + this.saveToStorage(); + this.updateBadge(); + } + + // 항목 제거 + remove(productId) { + if (!this.cart[productId]) return; + delete this.cart[productId]; + this.saveToStorage(); + this.updateBadge(); + } + + // 전체 비우기 + clear() { + this.cart = {}; + this.saveToStorage(); + this.updateBadge(); + } + + // 장바구니 객체 가져오기 (참조) + getCart() { + return this.cart; + } + + // 항목 수 반환 + getCount() { + return Object.keys(this.cart).length; + } +} + +const cartService = new CartService(); +export default cartService; diff --git a/src/services/InfiniteScrollManager.js b/src/services/InfiniteScrollManager.js new file mode 100644 index 000000000..a130272e9 --- /dev/null +++ b/src/services/InfiniteScrollManager.js @@ -0,0 +1,36 @@ +class InfiniteScrollManager { + constructor(callback, options = {}) { + this.callback = callback; + this.threshold = options.threshold ?? 200; // px before bottom + this.loading = false; + this.attached = false; + this.onScroll = this.onScroll.bind(this); + } + + attach() { + if (this.attached) return; + window.addEventListener("scroll", this.onScroll); + this.attached = true; + } + + detach() { + if (!this.attached) return; + window.removeEventListener("scroll", this.onScroll); + this.attached = false; + } + + async onScroll() { + if (this.loading) return; + const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - this.threshold; + if (nearBottom) { + this.loading = true; + try { + await this.callback(); + } finally { + this.loading = false; + } + } + } +} + +export default InfiniteScrollManager; diff --git a/src/services/ProductService.js b/src/services/ProductService.js new file mode 100644 index 000000000..4229b4b8e --- /dev/null +++ b/src/services/ProductService.js @@ -0,0 +1,22 @@ +import * as productApi from "../api/productApi.js"; + +class ProductService { + // 상품 목록 조회 + async getProducts(params = {}) { + return await productApi.getProducts(params); + } + + // 단일 상품 조회 + async getProduct(productId) { + return await productApi.getProduct(productId); + } + + // 카테고리 목록 조회 + async getCategories() { + return await productApi.getCategories(); + } +} + +// 애플리케이션 전역에서 공유되는 싱글톤 인스턴스 +const productService = new ProductService(); +export default productService; From ae35e2b2c58872d65f83b05a0dcb79a78d6da9b1 Mon Sep 17 00:00:00 2001 From: junilhwang Date: Thu, 10 Jul 2025 18:42:17 +0900 Subject: [PATCH 11/19] =?UTF-8?q?feat:=20easy=20/=20hard=20=EB=A5=BC=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=ED=95=98=EC=97=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=8B=A4=ED=96=89=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8F=84=EB=A1=9D=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 47 +++- e2e/e2e-easy.advanced.spec.js | 305 ++++++++++++++++++++++++++ e2e/e2e-easy.basic.spec.js | 278 +++++++++++++++++++++++ e2e/{e2e.spec.js => e2e-hard.spec.js} | 11 - package.json | 6 +- 5 files changed, 630 insertions(+), 17 deletions(-) create mode 100644 e2e/e2e-easy.advanced.spec.js create mode 100644 e2e/e2e-easy.basic.spec.js rename e2e/{e2e.spec.js => e2e-hard.spec.js} (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 601a6ce26..70a9c3e74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,45 @@ on: workflow_dispatch: jobs: - unit: + hard-basic: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - uses: pnpm/action-setup@v4 + with: + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + - run: | + pnpm install + pnpm run test:hard:basic + hard-advanced: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - uses: pnpm/action-setup@v4 + with: + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + - name: Install dependencies + run: | + pnpm install + npx playwright install --with-deps + pnpm run test:hard:advanced + easy-basic: + timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -27,8 +65,9 @@ jobs: - name: Install dependencies run: | pnpm install - pnpm run test - e2e: + npx playwright install --with-deps + pnpm run test:easy:basic + easy-advanced: timeout-minutes: 60 runs-on: ubuntu-latest steps: @@ -47,4 +86,4 @@ jobs: run: | pnpm install npx playwright install --with-deps - pnpm run test:e2e + pnpm run test:easy:advanced diff --git a/e2e/e2e-easy.advanced.spec.js b/e2e/e2e-easy.advanced.spec.js new file mode 100644 index 000000000..259379c41 --- /dev/null +++ b/e2e/e2e-easy.advanced.spec.js @@ -0,0 +1,305 @@ +import { expect, test } from "@playwright/test"; + +// 테스트 설정 +test.describe.configure({ mode: "serial" }); + +// 헬퍼 함수들 +class E2EHelpers { + constructor(page) { + this.page = page; + } + + // 페이지 로딩 대기 + async waitForPageLoad() { + await this.page.waitForSelector('[data-testid="products-grid"], #products-grid', { timeout: 10000 }); + await this.page.waitForFunction(() => { + const text = document.body.textContent; + return text.includes("총") && text.includes("개"); + }); + } + + // 상품을 장바구니에 추가 + async addProductToCart(productName) { + await this.page.click( + `text=${productName} >> xpath=ancestor::*[contains(@class, 'product-card')] >> .add-to-cart-btn`, + ); + await this.page.waitForSelector("text=장바구니에 추가되었습니다", { timeout: 5000 }); + } + + // 장바구니 모달 열기 + async openCartModal() { + await this.page.click("#cart-icon-btn"); + await this.page.waitForSelector(".cart-modal-overlay", { timeout: 5000 }); + } +} + +test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { + test.beforeEach(async ({ page }) => { + // 로컬 스토리지 초기화 + await page.goto("/"); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + }); + + test.describe("1. 검색 및 필터링 기능이 URL과 연동된다.", () => { + test("검색어 입력 후 Enter 키로 검색하고 URL이 업데이트된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 검색어 입력 + await page.fill("#search-input", "젤리"); + await page.press("#search-input", "Enter"); + + // URL 업데이트 확인 + await expect(page).toHaveURL(/search=%EC%A0%A4%EB%A6%AC/); + + // 검색 결과 확인 + await expect(page.locator("text=3개")).toBeVisible(); + + // 검색어가 검색창에 유지되는지 확인 + await expect(page.locator("#search-input")).toHaveValue("젤리"); + + // 검색어 입력 + await page.fill("#search-input", "아이패드"); + await page.press("#search-input", "Enter"); + + // URL 업데이트 확인 + await expect(page).toHaveURL(/search=%EC%95%84%EC%9D%B4%ED%8C%A8%EB%93%9C/); + + // 검색 결과 확인 + await expect(page.locator("text=21개")).toBeVisible(); + + // 새로고침을 해도 유지 되는지 확인 + await page.reload(); + await helpers.waitForPageLoad(); + await expect(page.locator("text=21개")).toBeVisible(); + }); + + test("정렬 옵션 변경 시 URL이 업데이트된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 가격 높은순으로 정렬 + await page.selectOption("#sort-select", "price_desc"); + + // 첫 번째 상품 이 가격 높은 순으로 정렬되었는지 확인 + await expect(page.locator(".product-card").first()).toMatchAriaSnapshot(` + - img "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" + - heading "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" [level=3] + - paragraph: ASUS + - paragraph: 3,749,000원 + - button "장바구니 담기" + `); + + await page.selectOption("#sort-select", "name_asc"); + await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` + - img "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" + - heading "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" [level=3] + - paragraph: 유로블루플러스 + - paragraph: 8,700원 + - button "장바구니 담기" + `); + + await page.selectOption("#sort-select", "name_desc"); + await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` + - img "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" + - heading "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" [level=3] + - paragraph: 다우니 + - paragraph: 16,610원 + - button "장바구니 담기" + `); + + await page.reload(); + await helpers.waitForPageLoad(); + await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` + - img "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" + - heading "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" [level=3] + - paragraph: 다우니 + - paragraph: 16,610원 + - button "장바구니 담기" + `); + }); + + test("페이지당 상품 수 변경 시 URL이 업데이트된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 10개로 변경 + await page.selectOption("#limit-select", "10"); + await expect(page).toHaveURL(/limit=10/); + await page.waitForFunction(() => { + return document.querySelectorAll(".product-card").length === 10; + }); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot( + `- heading "탈부착 방충망 자석쫄대 방풍비닐 창문방충망 셀프시공 DIY 백색 100cm" [level=3]`, + ); + + await page.selectOption("#limit-select", "20"); + await expect(page).toHaveURL(/limit=20/); + await page.waitForFunction(() => { + return document.querySelectorAll(".product-card").length === 20; + }); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot( + `- heading "고양이 난간 안전망 복층 베란다 방묘창 방묘문 방충망 캣도어 일반형검정1mx1m" [level=3]`, + ); + + await page.selectOption("#limit-select", "50"); + await expect(page).toHaveURL(/limit=50/); + await page.waitForFunction(() => { + return document.querySelectorAll(".product-card").length === 50; + }); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot( + `- heading "강아지 고양이 아이스팩 파우치 여름 베개 젤리곰 M사이즈" [level=3]`, + ); + + await page.selectOption("#limit-select", "100"); + await expect(page).toHaveURL(/limit=100/); + await page.waitForFunction(() => { + return document.querySelectorAll(".product-card").length === 100; + }); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot( + `- heading "고양이 스크래쳐 숨숨집 하우스 대형 원목 스크레쳐 A type" [level=3]`, + ); + + await page.reload(); + await helpers.waitForPageLoad(); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot( + `- heading "고양이 스크래쳐 숨숨집 하우스 대형 원목 스크레쳐 A type" [level=3]`, + ); + }); + + test("검색어와 필터 조건이 URL에서 복원된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + + await page.goto("/?search=젤리&sort=price_desc&limit=10"); + await helpers.waitForPageLoad(); + + await expect(page.locator("#search-input")).toHaveValue("젤리"); + await expect(page.locator("#sort-select")).toHaveValue("price_desc"); + await expect(page.locator("#limit-select")).toHaveValue("10"); + await expect(page.getByRole("main")).toMatchAriaSnapshot(`- text: /총 3개의 상품/`); + + await page.goto("/?search=고양이&sort=name_desc&limit=50"); + await helpers.waitForPageLoad(); + + await expect(page.locator("#search-input")).toHaveValue("고양이"); + await expect(page.locator("#sort-select")).toHaveValue("name_desc"); + await expect(page.locator("#limit-select")).toHaveValue("50"); + await expect(page.getByRole("main")).toMatchAriaSnapshot(`- text: /총 84개의 상품/`); + }); + }); + + test.describe("2. 상품 상세 페이지와 URL이 연동된다.", () => { + test("상품 클릭부터 관련 상품 이동까지 전체 플로우", async ({ page }) => { + await page.evaluate(() => { + window.loadFlag = true; + window.history.pushState({}, "", "/product/85067212996"); + window.dispatchEvent(new Event("popstate")); + }); + + // 상세 페이지 로딩 확인 + await expect(page.locator("text=상품 상세")).toBeVisible(); + + // h1 태그에 상품명 확인 + await expect( + page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'), + ).toBeVisible(); + + // 관련 상품 섹션 확인 + await expect(page.locator("text=관련 상품")).toBeVisible(); + const relatedProducts = page.locator(".related-product-card"); + await expect(relatedProducts.first()).toBeVisible(); + + // 첫 번째 관련 상품 클릭 + await relatedProducts.first().click(); + + // 다른 상품의 상세 페이지로 이동했는지 확인 + await expect(page).toHaveURL("/product/86940857379"); + await expect( + page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), + ).toBeVisible(); + + await expect(await page.evaluate(() => window.loadFlag)).toBe(true); + + await page.reload(); + + await expect( + page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), + ).toBeVisible(); + + await expect(await page.evaluate(() => window.loadFlag)).toBe(undefined); + }); + }); + + test.describe("3. SPA 네비게이션", () => { + test("브라우저 뒤로가기/앞으로가기가 올바르게 작동한다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await page.evaluate(() => { + window.loadFlag = true; + }); + await helpers.waitForPageLoad(); + + // 상품 상세 페이지로 이동 + const productCard = page + .locator("text=PVC 투명 젤리 쇼핑백") + .locator('xpath=ancestor::*[contains(@class, "product-card")]'); + await productCard.locator("img").click(); + + await expect(page).toHaveURL("/product/85067212996"); + await expect( + page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'), + ).toBeVisible(); + await expect(page.locator("text=관련 상품")).toBeVisible(); + const relatedProducts = page.locator(".related-product-card"); + await relatedProducts.first().click(); + + await expect(page).toHaveURL("/product/86940857379"); + await expect( + page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), + ).toBeVisible(); + + // 브라우저 뒤로가기 + await page.goBack(); + await expect(page).toHaveURL("/product/85067212996"); + await expect( + page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'), + ).toBeVisible(); + + // 브라우저 앞으로가기 + await page.goForward(); + await expect(page).toHaveURL("/product/86940857379"); + await expect( + page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), + ).toBeVisible(); + + await page.goBack(); + await page.goBack(); + await expect(page).toHaveURL("/"); + const firstProductCard = page.locator(".product-card").first(); + await expect(firstProductCard.locator("img")).toBeVisible(); + + expect(await page.evaluate(() => window.loadFlag)).toBe(true); + + await page.reload(); + expect( + await page.evaluate(() => { + return window.loadFlag; + }), + ).toBe(undefined); + }); + + // 404 페이지 테스트 + test("존재하지 않는 페이지 접근 시 404 페이지가 표시된다", async ({ page }) => { + // 존재하지 않는 경로로 이동 + await page.goto("/non-existent-page"); + + // 404 페이지 확인 + await expect(page.getByRole("main")).toMatchAriaSnapshot(` + - img: /404 페이지를 찾을 수 없습니다/ + - link "홈으로" + `); + }); + }); +}); diff --git a/e2e/e2e-easy.basic.spec.js b/e2e/e2e-easy.basic.spec.js new file mode 100644 index 000000000..9b10a55fa --- /dev/null +++ b/e2e/e2e-easy.basic.spec.js @@ -0,0 +1,278 @@ +import { expect, test } from "@playwright/test"; + +// 테스트 설정 +test.describe.configure({ mode: "serial" }); + +// 헬퍼 함수들 +class E2EHelpers { + constructor(page) { + this.page = page; + } + + // 페이지 로딩 대기 + async waitForPageLoad() { + await this.page.waitForSelector('[data-testid="products-grid"], #products-grid', { timeout: 10000 }); + await this.page.waitForFunction(() => { + const text = document.body.textContent; + return text.includes("총") && text.includes("개"); + }); + } + + // 상품을 장바구니에 추가 + async addProductToCart(productName) { + await this.page.click( + `text=${productName} >> xpath=ancestor::*[contains(@class, 'product-card')] >> .add-to-cart-btn`, + ); + } +} + +test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 > 난이도 쉬움 > 기본과제", () => { + test.beforeEach(async ({ page }) => { + // 로컬 스토리지 초기화 + await page.goto("/"); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + }); + + test.describe("1. 애플리케이션 초기화 및 기본 기능", () => { + test("페이지 접속 시 로딩 상태가 표시되고 상품 목록이 정상적으로 로드된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + + // 로딩 상태 확인 + await expect(page.locator("text=카테고리 로딩 중...")).toBeVisible(); + + // 상품 목록 로드 완료 대기 + await helpers.waitForPageLoad(); + + // 상품 개수 확인 (340개) + await expect(page.locator("text=340개")).toBeVisible(); + + // 기본 UI 요소들 존재 확인 + await expect(page.locator("#search-input")).toBeVisible(); + await expect(page.locator("#cart-icon-btn")).toBeVisible(); + await expect(page.locator("#limit-select")).toBeVisible(); + await expect(page.locator("#sort-select")).toBeVisible(); + }); + + test("상품 카드에 기본 정보가 올바르게 표시된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 첫 번째 상품 카드 확인 + const firstProductCard = page.locator(".product-card").first(); + + // 상품 이미지 존재 확인 + await expect(firstProductCard.locator("img")).toBeVisible(); + + // 상품명 확인 + await expect(firstProductCard).toContainText(/pvc 투명 젤리 쇼핑백|고양이 난간 안전망/i); + + // 가격 정보 확인 (숫자 + 원) + await expect(firstProductCard).toContainText(/\d{1,3}(,\d{3})*원/); + + // 장바구니 버튼 확인 + await expect(firstProductCard.locator(".add-to-cart-btn")).toBeVisible(); + }); + }); + + test.describe("2. 검색 및 필터링 기능", () => { + test("검색어 입력 후 Enter 키로 검색할 수 있다.", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 검색어 입력 + await page.fill("#search-input", "젤리"); + await page.press("#search-input", "Enter"); + + // 검색 결과 확인 + await expect(page.locator("text=3개")).toBeVisible(); + + // 검색어가 검색창에 유지되는지 확인 + await expect(page.locator("#search-input")).toHaveValue("젤리"); + + // 검색어 입력 + await page.fill("#search-input", "아이패드"); + await page.press("#search-input", "Enter"); + + // 검색 결과 확인 + await expect(page.locator("text=21개")).toBeVisible(); + }); + + test("상품의 정렬을 변경할 수 있다.", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 가격 높은순으로 정렬 + await page.selectOption("#sort-select", "price_desc"); + + // 첫 번째 상품 이 가격 높은 순으로 정렬되었는지 확인 + await expect(page.locator(".product-card").first()).toMatchAriaSnapshot(` + - img "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" + - heading "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" [level=3] + - paragraph: ASUS + - paragraph: 3,749,000원 + - button "장바구니 담기" + `); + + await page.selectOption("#sort-select", "name_asc"); + await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` + - img "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" + - heading "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" [level=3] + - paragraph: 유로블루플러스 + - paragraph: 8,700원 + - button "장바구니 담기" + `); + + await page.selectOption("#sort-select", "name_desc"); + await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` + - img "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" + - heading "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" [level=3] + - paragraph: 다우니 + - paragraph: 16,610원 + - button "장바구니 담기" + `); + + await page.reload(); + await helpers.waitForPageLoad(); + await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` + - img "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" + - heading "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" [level=3] + - paragraph: 다우니 + - paragraph: 16,610원 + - button "장바구니 담기" + `); + }); + + test("페이지당 상품 수 변경이 가능하다.", async ({ page }) => { + const helpers = new E2EHelpers(page); + + await page.goto("/"); + await helpers.waitForPageLoad(); + + // 10개로 변경 + await page.selectOption("#limit-select", "10"); + + await page.waitForFunction(() => document.querySelectorAll(".product-card").length === 10); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot( + `- heading "탈부착 방충망 자석쫄대 방풍비닐 창문방충망 셀프시공 DIY 백색 100cm" [level=3]`, + ); + + await page.selectOption("#limit-select", "20"); + + await page.waitForFunction(() => document.querySelectorAll(".product-card").length === 20); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot( + `- heading "고양이 난간 안전망 복층 베란다 방묘창 방묘문 방충망 캣도어 일반형검정1mx1m" [level=3]`, + ); + + await page.selectOption("#limit-select", "50"); + + await page.waitForFunction(() => document.querySelectorAll(".product-card").length === 50); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot( + `- heading "강아지 고양이 아이스팩 파우치 여름 베개 젤리곰 M사이즈" [level=3]`, + ); + + await page.selectOption("#limit-select", "100"); + + await page.waitForFunction(() => document.querySelectorAll(".product-card").length === 100); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot( + `- heading "고양이 스크래쳐 숨숨집 하우스 대형 원목 스크레쳐 A type" [level=3]`, + ); + }); + }); + + test.describe("3. 장바구니 상태 유지", () => { + test("장바구니 아이콘에 상품 개수가 정확히 표시된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 초기에는 개수 표시가 없어야 함 + await expect(page.locator("#cart-icon-btn span")).not.toBeVisible(); + + // 첫 번째 상품 추가 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + await expect(page.locator("#cart-icon-btn span")).toHaveText("1"); + + // 두 번째 상품 추가 + await helpers.addProductToCart("샷시 풍지판"); + await expect(page.locator("#cart-icon-btn span")).toHaveText("2"); + + // 첫 번째 상품 한 번 더 추가 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + await expect(page.locator("#cart-icon-btn span")).toHaveText("2"); + }); + }); + + test.describe("4. 상품 상세 페이지 워크플로우", () => { + test("상품 클릭부터 관련 상품 이동까지 전체 플로우", async ({ page }) => { + const helpers = new E2EHelpers(page); + await page.evaluate(() => { + window.loadFlag = true; + }); + await helpers.waitForPageLoad(); + + // 상품 이미지 클릭하여 상세 페이지로 이동 + const productCard = page + .locator("text=PVC 투명 젤리 쇼핑백") + .locator('xpath=ancestor::*[contains(@class, "product-card")]'); + await productCard.locator("img").click(); + + // 상세 페이지 로딩 확인 + await expect(page.locator("text=상품 상세")).toBeVisible(); + + // h1 태그에 상품명 확인 + await expect( + page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'), + ).toBeVisible(); + + // 수량 조절 후 장바구니 담기 + await page.click("#quantity-increase"); + await expect(page.locator("#quantity-input")).toHaveValue("2"); + + await page.click("#add-to-cart-btn"); + + // 관련 상품 섹션 확인 + await expect(page.locator("text=관련 상품")).toBeVisible(); + + const relatedProducts = page.locator(".related-product-card"); + await expect(relatedProducts.first()).toBeVisible(); + + // 첫 번째 관련 상품 클릭 + await relatedProducts.first().click(); + + // 다른 상품의 상세 페이지로 이동했는지 확인 + await expect( + page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), + ).toBeVisible(); + + await expect(await page.evaluate(() => window.loadFlag)).toBe(true); + }); + }); + + test.describe("5. 무한 스크롤 기능", () => { + test("페이지 하단 스크롤 시 추가 상품이 로드된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 초기 상품 카드 수 확인 + const initialCards = await page.locator(".product-card").count(); + expect(initialCards).toBe(20); + + // 페이지 하단으로 스크롤 + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight); + }); + + // 로딩 인디케이터 확인 + await expect(page.locator("text=상품을 불러오는 중...")).toBeVisible(); + + // 추가 상품 로드 대기 + await page.waitForFunction(() => document.querySelectorAll(".product-card").length === 40); + + // 상품 수가 증가했는지 확인 + const updatedCards = await page.locator(".product-card").count(); + expect(updatedCards).toBe(40); + }); + }); +}); diff --git a/e2e/e2e.spec.js b/e2e/e2e-hard.spec.js similarity index 98% rename from e2e/e2e.spec.js rename to e2e/e2e-hard.spec.js index 23f773492..ec8d552d4 100644 --- a/e2e/e2e.spec.js +++ b/e2e/e2e-hard.spec.js @@ -26,22 +26,11 @@ class E2EHelpers { await this.page.waitForSelector("text=장바구니에 추가되었습니다", { timeout: 5000 }); } - // 토스트 메시지가 사라질 때까지 대기 - async waitForToastToDisappear() { - await this.page.waitForSelector("text=장바구니에 추가되었습니다", { state: "hidden", timeout: 5000 }); - } - // 장바구니 모달 열기 async openCartModal() { await this.page.click("#cart-icon-btn"); await this.page.waitForSelector(".cart-modal-overlay", { timeout: 5000 }); } - - // 현재 상품 개수 가져오기 - async getCurrentProductCount() { - const countText = await this.page.textContent('[data-testid="product-count"]'); - return countText ? parseInt(countText.replace(/[^\d]/g, "")) : 0; - } } test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { diff --git a/package.json b/package.json index 7b2368941..5ba78da82 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "prettier:write": "prettier --write ./src", "preview": "vite preview", "test": "vitest", - "test:basic": "vitest basic.test.js", - "test:advanced": "vitest advanced", + "test:hard:basic": "vitest --run", + "test:hard:advanced": "playwright test e2e-hard", + "test:easy:basic": "playwright test e2e-easy.basic", + "test:easy:advanced": "playwright test e2e-easy.advanced", "test:ui": "vitest --ui", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", From 2d23cd72b9c764c28abf0ac99808e9f5f1399fdd Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Fri, 11 Jul 2025 02:13:17 +0900 Subject: [PATCH 12/19] =?UTF-8?q?fix:=20=EB=A1=9C=EB=94=A9=ED=95=A0=20?= =?UTF-8?q?=EB=95=8C=20=EB=A1=9C=EB=94=A9=20UI=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductCard.js | 4 ++-- src/controllers/HomePageController.js | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/ProductCard.js b/src/components/ProductCard.js index 9ebcfead9..be037a28c 100644 --- a/src/components/ProductCard.js +++ b/src/components/ProductCard.js @@ -15,9 +15,9 @@ export default function ProductCard(product) {

${product.title}

-

+

${product.brand}

- ${product.lprice}원 + ${Number(product.lprice).toLocaleString()}원

diff --git a/src/controllers/HomePageController.js b/src/controllers/HomePageController.js index 3f196e75f..863379dce 100644 --- a/src/controllers/HomePageController.js +++ b/src/controllers/HomePageController.js @@ -25,7 +25,18 @@ class HomePageController { async init() { loadCart(); - await Promise.all([this.fetchProducts(), productService.getCategories().then((c) => (this.state.categories = c))]); + // 1) 초깃값 로딩 상태 활성화 → 로딩 UI(스켈레톤, "카테고리 로딩 중...") 표시 + this.state.loading = true; + this.render(); + + // 2) 상품·카테고리 동시 요청 + const [, categories] = await Promise.all([this.fetchProducts(), productService.getCategories().then((c) => c)]); + + // 3) 응답 데이터 상태 반영 + this.state.categories = categories; + this.state.loading = false; + + // 4) 최종 렌더링 및 스크롤 설정 this.render(); this.setupInfiniteScroll(); } From 5cd623f70b9616243784daed350f91ae319f87e6 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Sat, 12 Jul 2025 00:50:35 +0900 Subject: [PATCH 13/19] =?UTF-8?q?feat:=20easy=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=91=90=20=ED=86=B5=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Toast.js | 4 +- src/controllers/HomePageController.js | 64 ++++++++++++++++++++++ src/controllers/ProductDetailController.js | 8 +++ src/features/cart/cart.modal.js | 7 ++- src/main.js | 5 +- src/pages/HomePage.js | 3 +- src/pages/NotFoundPage.js | 36 ++++++++++++ src/pages/ProductDetailPage.js | 7 ++- src/services/CartService.js | 12 +++- 9 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 src/pages/NotFoundPage.js diff --git a/src/components/Toast.js b/src/components/Toast.js index 2196e9fe3..bb103d660 100644 --- a/src/components/Toast.js +++ b/src/components/Toast.js @@ -11,8 +11,10 @@ const COLORS = { * @param {number} duration */ export function showToast(message, type = "success", duration = 3000) { + // 이미 표시중인 토스트가 있다면 제거 (동일/다른 메시지 구분 X, 한 번에 하나만 표시) + document.querySelectorAll(".toast-notification").forEach((el) => el.remove()); const wrapper = document.createElement("div"); - wrapper.className = "fixed bottom-6 left-1/2 -translate-x-1/2 z-[60]"; + wrapper.className = "toast-notification fixed bottom-6 left-1/2 -translate-x-1/2 z-[60]"; wrapper.innerHTML = `
diff --git a/src/controllers/HomePageController.js b/src/controllers/HomePageController.js index 863379dce..bea777cb3 100644 --- a/src/controllers/HomePageController.js +++ b/src/controllers/HomePageController.js @@ -21,9 +21,63 @@ class HomePageController { sort: "price_asc", }; this.scrollManager = null; + + // URL 쿼리 → 초기 상태 반영 + this.applyQueryParams(); + } + + // 현재 URL 의 쿼리스트링을 읽어 state 에 반영 + applyQueryParams() { + const params = new URLSearchParams(window.location.search); + if (params.has("sort")) this.state.sort = params.get("sort"); + if (params.has("limit")) this.state.limit = Number(params.get("limit")); + if (params.has("search")) this.state.search = params.get("search"); + if (params.has("category1")) this.state.category1 = params.get("category1"); + if (params.has("category2")) this.state.category2 = params.get("category2"); + } + + // state 값을 쿼리스트링으로 직렬화하여 pushState (페이지 새로고침 시 유지) + updateQueryParams({ replace = false } = {}) { + const params = new URLSearchParams(); + if (this.state.sort && this.state.sort !== "price_asc") params.set("sort", this.state.sort); + if (!(replace && this.state.limit === 20)) { + params.set("limit", String(this.state.limit)); + } + if (this.state.search) params.set("search", this.state.search); + if (this.state.category1) params.set("category1", this.state.category1); + if (this.state.category2) params.set("category2", this.state.category2); + + const qs = params.toString(); + const newUrl = qs ? `/?${qs}` : "/"; + let method = replace ? "replaceState" : "pushState"; + // limit 가 기본값(20)으로 돌아가는 경우 히스토리 덮어쓰기 + if (!replace && this.state.limit === 20 && params.size === 1 && params.has("limit")) { + method = "replaceState"; + } + if (newUrl !== window.location.pathname + window.location.search) { + window.history[method]({}, "", newUrl); + } } async init() { + // 테스트 등 여러 번 init 될 때 이전 상태가 남아있지 않도록 기본 상태로 초기화 + this.state = { + products: [], + total: 0, + loading: false, + loadingMore: false, + categories: {}, + limit: 20, + page: 1, + search: "", + category1: "", + category2: "", + sort: "price_asc", + }; + + // URL 쿼리 적용 (초기화 이후 다시 적용) + this.applyQueryParams(); + loadCart(); // 1) 초깃값 로딩 상태 활성화 → 로딩 UI(스켈레톤, "카테고리 로딩 중...") 표시 this.state.loading = true; @@ -39,9 +93,12 @@ class HomePageController { // 4) 최종 렌더링 및 스크롤 설정 this.render(); this.setupInfiniteScroll(); + // 렌더링 후 현재 상태를 URL에 반영 + this.updateQueryParams({ replace: true }); } async fetchProducts() { + // (테스트 디버그용 로그 제거) const { products, pagination } = await productService.getProducts({ limit: this.state.limit, page: this.state.page, @@ -97,6 +154,7 @@ class HomePageController { limitSelect.onchange = async (e) => { state.limit = Number(e.target.value); state.page = 1; + this.updateQueryParams(); await this.fetchProducts(); this.render(); }; @@ -111,6 +169,7 @@ class HomePageController { if (state.search !== keyword) { state.search = keyword; state.page = 1; + this.updateQueryParams(); await this.fetchProducts(); this.render(); } @@ -133,6 +192,7 @@ class HomePageController { state.category1 = btn.dataset.category1; state.category2 = ""; state.page = 1; + this.updateQueryParams(); await this.fetchProducts(); this.render(); }; @@ -143,6 +203,7 @@ class HomePageController { state.category1 = btn.dataset.category1; state.category2 = btn.dataset.category2; state.page = 1; + this.updateQueryParams(); await this.fetchProducts(); this.render(); }; @@ -152,6 +213,7 @@ class HomePageController { state.category1 = ""; state.category2 = ""; state.page = 1; + this.updateQueryParams(); await this.fetchProducts(); this.render(); }); @@ -160,6 +222,7 @@ class HomePageController { state.category1 = e.target.dataset.category1; state.category2 = ""; state.page = 1; + this.updateQueryParams(); await this.fetchProducts(); this.render(); }); @@ -180,6 +243,7 @@ class HomePageController { sortSelect.onchange = async (e) => { state.sort = e.target.value; state.page = 1; + this.updateQueryParams(); await this.fetchProducts(); this.render(); }; diff --git a/src/controllers/ProductDetailController.js b/src/controllers/ProductDetailController.js index fd3af724c..552407fe0 100644 --- a/src/controllers/ProductDetailController.js +++ b/src/controllers/ProductDetailController.js @@ -11,7 +11,15 @@ class ProductDetailController { async show(productId) { this.rootEl.innerHTML = ProductDetailPage({ loading: true }); + // 1) 상품 기본 정보 로드 const product = await productService.getProduct(productId); + + // 2) 우선 상품 정보만 렌더링 (관련 상품 제외) + this.rootEl.innerHTML = ProductDetailPage({ loading: false, product, related: [] }); + this.attachEvents(product); + updateCartBadge(); + + // 3) 관련 상품 비동기 로드 → 준비되면 다시 렌더링 const { products: all } = await productService.getProducts({ limit: 100 }); const related = all.filter((p) => p.productId !== productId).slice(0, 19); diff --git a/src/features/cart/cart.modal.js b/src/features/cart/cart.modal.js index 902d94cd6..f1d07cf5d 100644 --- a/src/features/cart/cart.modal.js +++ b/src/features/cart/cart.modal.js @@ -4,7 +4,10 @@ import { showToast } from "../../components/Toast.js"; const format = (won) => `${Number(won).toLocaleString()}원`; export function openCartModal() { - if (document.querySelector(".cart-modal-overlay")) return; + const existing = document.querySelector(".cart-modal-overlay"); + if (existing) { + existing.remove(); + } const overlay = document.createElement("div"); overlay.className = @@ -31,6 +34,8 @@ export function openCartModal() { overlay.innerHTML = ""; const cartItems = Object.values(cart); + // 디버그 로그 제거 + if (cartItems.length === 0) { overlay.innerHTML = `
diff --git a/src/main.js b/src/main.js index 1575e98d1..9730f05f1 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,7 @@ import router from "./router/Router.js"; import InfiniteScrollManager from "./services/InfiniteScrollManager.js"; import homePageController from "./controllers/HomePageController.js"; import productDetailController from "./controllers/ProductDetailController.js"; +import NotFoundPage from "./pages/NotFoundPage.js"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -182,4 +183,6 @@ router.add("/", () => { router.add("/product/:id", async ({ id }) => { await productDetailController.show(id); }); -router.setNotFound(() => homePageController.init()); +router.setNotFound(() => { + document.querySelector("#root").innerHTML = NotFoundPage(); +}); diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js index e97f50931..de28947a6 100644 --- a/src/pages/HomePage.js +++ b/src/pages/HomePage.js @@ -122,8 +122,7 @@ const 상품목록_레이아웃_로딩 = (products, total, loading, loadingMore, ? "" : `
- 총 의 상품 - ${total}개 + ${total}개의 상품
` } diff --git a/src/pages/NotFoundPage.js b/src/pages/NotFoundPage.js new file mode 100644 index 000000000..b0ae46d02 --- /dev/null +++ b/src/pages/NotFoundPage.js @@ -0,0 +1,36 @@ +export default function NotFoundPage() { + return ` +
+
+ + + + + + + + + + + + + 404 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+
+ `; +} diff --git a/src/pages/ProductDetailPage.js b/src/pages/ProductDetailPage.js index c6079ad42..a9ca2803b 100644 --- a/src/pages/ProductDetailPage.js +++ b/src/pages/ProductDetailPage.js @@ -73,6 +73,9 @@ export default function ProductDetailPage({ loading = true, product = null, rela
+ ${ + related && related.length + ? `
@@ -92,7 +95,9 @@ export default function ProductDetailPage({ loading = true, product = null, rela ) .join("")}
-
+
` + : "" + }
`; } diff --git a/src/services/CartService.js b/src/services/CartService.js index 54b3e71ec..d559881f6 100644 --- a/src/services/CartService.js +++ b/src/services/CartService.js @@ -9,9 +9,13 @@ class CartService { // 로컬스토리지에서 장바구니 불러오기 loadFromStorage() { try { - this.cart = JSON.parse(localStorage.getItem("shopping_cart") || "{}"); + const stored = JSON.parse(localStorage.getItem("shopping_cart") || "{}"); + // 기존 객체 내용을 모두 제거하고, 저장된 데이터를 병합하여 + // cart 객체의 참조를 유지한다. + Object.keys(this.cart).forEach((k) => delete this.cart[k]); + Object.assign(this.cart, stored); } catch { - this.cart = {}; + Object.keys(this.cart).forEach((k) => delete this.cart[k]); } } @@ -42,6 +46,8 @@ class CartService { if (this.cart[productId]) this.cart[productId].quantity += qty; else this.cart[productId] = { product, quantity: qty }; + // 디버그 로그 제거 + this.saveToStorage(); this.updateBadge(); showToast("장바구니에 추가되었습니다", "success"); @@ -83,7 +89,7 @@ class CartService { // 전체 비우기 clear() { - this.cart = {}; + Object.keys(this.cart).forEach((k) => delete this.cart[k]); this.saveToStorage(); this.updateBadge(); } From bec64f7bd8995204397180ee8154d6906f6233d2 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Sat, 12 Jul 2025 03:23:05 +0900 Subject: [PATCH 14/19] =?UTF-8?q?fix:=20=EC=95=88=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/router.js | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 src/router.js diff --git a/src/router.js b/src/router.js deleted file mode 100644 index 2bd890947..000000000 --- a/src/router.js +++ /dev/null @@ -1,23 +0,0 @@ -import ProductDetailPage from "./pages/ProductDetailPage.js"; -import { fetchProductsAndRender, renderHome } from "./state.js"; - -// URL → 화면 -export async function router() { - const match = location.pathname.match(/^\/product\/(\w+)/); - if (match) { - await ProductDetailPage(match[1]); - } else { - // 홈 - await fetchProductsAndRender(); - renderHome(); - } -} - -// 링크 처리 -export function handleLinkClicks(e) { - const a = e.target.closest("[data-link]"); - if (!a) return; - e.preventDefault(); - history.pushState(null, "", a.href); - router(); -} From 5029efca1376bd7506adb6d83600b8c4102ad4e7 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Sat, 12 Jul 2025 04:06:54 +0900 Subject: [PATCH 15/19] =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=84=B8=ED=8C=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- public/404.html | 122 +++++++++++++++++++++++++++++++++++++++++++ src/main.js | 6 +-- src/mocks/browser.js | 14 +++++ vite.config.js | 13 +++++ 5 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 public/404.html diff --git a/package.json b/package.json index 62c1eb716..3d2f932dd 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,10 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:report": "npx playwright show-report", "test:generate": "playwright codegen localhost:5173", - "prepare": "husky" + "prepare": "husky", + "build:prod": "NODE_ENV=production vite build", + "preview:prod": "NODE_ENV=production vite preview", + "deploy": "pnpm build:prod && pnpm preview:prod" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ diff --git a/public/404.html b/public/404.html new file mode 100644 index 000000000..0f143d481 --- /dev/null +++ b/public/404.html @@ -0,0 +1,122 @@ + + + + + + 상품 쇼핑몰 + + + + + + +
+ + + + + diff --git a/src/main.js b/src/main.js index 9730f05f1..2a9e6b48d 100644 --- a/src/main.js +++ b/src/main.js @@ -8,11 +8,7 @@ import productDetailController from "./controllers/ProductDetailController.js"; import NotFoundPage from "./pages/NotFoundPage.js"; const enableMocking = () => - import("./mocks/browser.js").then(({ worker }) => - worker.start({ - onUnhandledRequest: "bypass", - }), - ); + import("./mocks/browser.js").then(({ worker, workerOptions }) => worker.start(workerOptions)); let state = { products: [], diff --git a/src/mocks/browser.js b/src/mocks/browser.js index be3dedca2..b19b86c07 100644 --- a/src/mocks/browser.js +++ b/src/mocks/browser.js @@ -3,3 +3,17 @@ import { handlers } from "./handlers"; // MSW 워커 설정 export const worker = setupWorker(...handlers); + +const basePath = import.meta.env.PROD ? "/front_6th_chapter1-1" : ""; + +// Worker start 옵션을 export하여 main.js에서 사용 +export const workerOptions = import.meta.env.PROD + ? { + serviceWorker: { + url: `${basePath}/mockServiceWorker.js`, + }, + onUnhandledRequest: "bypass", + } + : { + onUnhandledRequest: "bypass", + }; diff --git a/vite.config.js b/vite.config.js index 2eef1c44e..5d987e62c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,19 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + base: process.env.NODE_ENV === "production" ? "/front_6th_chapter1-1/" : "/", + + build: { + outDir: "dist", + assetsDir: "assets", + sourcemap: false, + rollupOptions: { + output: { + manualChunks: undefined, + }, + }, + }, + test: { globals: true, environment: "jsdom", From bb81df4561f3b0d875fe40248a3a88d8fc523814 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Sat, 12 Jul 2025 04:18:14 +0900 Subject: [PATCH 16/19] =?UTF-8?q?fix:=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/router/Router.js | 43 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/router/Router.js b/src/router/Router.js index 6095a38e3..26253ce11 100644 --- a/src/router/Router.js +++ b/src/router/Router.js @@ -3,12 +3,26 @@ class Router { this.routes = []; this.notFoundHandler = null; + // BASE_PATH 설정 (프로덕션 환경에서 서브패스 지원) + this.BASE_PATH = import.meta.env.PROD ? "/front_6th_chapter1-1" : ""; + // 브라우저 뒤로가기/앞으로가기 처리 window.addEventListener("popstate", () => { - this.handle(window.location.pathname); + const appPath = this.getAppPath(window.location.pathname); + this.handle(appPath); }); } + // 전체 경로에서 앱 경로 추출 + getAppPath(fullPath = window.location.pathname) { + return fullPath.startsWith(this.BASE_PATH) ? fullPath.slice(this.BASE_PATH.length) || "/" : fullPath; + } + + // 앱 경로를 전체 경로로 변환 + getFullPath(appPath) { + return this.BASE_PATH + appPath; + } + // 라우트 등록: 패턴(예: '/product/:id')과 핸들러 함수 add(pattern, handler) { this.routes.push({ pattern, handler }); @@ -20,16 +34,19 @@ class Router { } // 내비게이션 (pushState 후 핸들) - navigate(path) { - if (window.location.pathname === path) return; - history.pushState({}, "", path); - this.handle(path); + navigate(appPath) { + const currentAppPath = this.getAppPath(); + if (currentAppPath === appPath) return; + + const fullPath = this.getFullPath(appPath); + history.pushState({}, "", fullPath); + this.handle(appPath); } - // 현재 경로 처리 - handle(path) { + // 현재 경로 처리 (앱 경로 기준) + handle(appPath) { for (const { pattern, handler } of this.routes) { - const params = this.match(pattern, path); + const params = this.match(pattern, appPath); if (params) { handler(params); return; @@ -58,6 +75,16 @@ class Router { } return params; } + + // 현재 앱 경로 가져오기 + getCurrentAppPath() { + return this.getAppPath(); + } + + // 현재 전체 경로 가져오기 + getCurrentFullPath() { + return window.location.pathname; + } } // 싱글톤 인스턴스 export From 97933610c6b65b5b1d47f1db1dbf7183f3e324f2 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Sat, 12 Jul 2025 04:22:59 +0900 Subject: [PATCH 17/19] =?UTF-8?q?fix:=20main=EC=97=90=EC=84=9C=EB=8F=84=20?= =?UTF-8?q?router=EA=B4=80=EB=A0=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.js b/src/main.js index 2a9e6b48d..78a56e256 100644 --- a/src/main.js +++ b/src/main.js @@ -173,10 +173,10 @@ function navigate(path) { } // 라우트 등록 (컨트롤러 활용) -router.add("/", () => { +router.add(router.BASE_PATH, () => { homePageController.init(); }); -router.add("/product/:id", async ({ id }) => { +router.add(router.BASE_PATH + "/product/:id", async ({ id }) => { await productDetailController.show(id); }); router.setNotFound(() => { From 5359d403b8101b4e4812161f4fa0a12d975ad9c6 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Sat, 12 Jul 2025 04:56:14 +0900 Subject: [PATCH 18/19] =?UTF-8?q?feat:=20git=20action=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..9451f409a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,38 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm build + env: + NODE_ENV: production + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist From 6c15d0d7f8661612d17b6dd9f7dd289932a3f2b8 Mon Sep 17 00:00:00 2001 From: JungWoo0203 Date: Sat, 12 Jul 2025 05:06:49 +0900 Subject: [PATCH 19/19] =?UTF-8?q?fix:=20=EB=9D=BC=EC=9A=B0=ED=8C=85?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EA=B3=B3=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cart-modal.html | 2 +- src/controllers/HomePageController.js | 2 +- src/controllers/ProductDetailController.js | 2 +- src/pages/HomePage.js | 3 ++- src/pages/NotFoundPage.js | 4 +++- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cart-modal.html b/cart-modal.html index 66455103d..6e2695c78 100644 --- a/cart-modal.html +++ b/cart-modal.html @@ -28,7 +28,7 @@

- 쇼핑몰 + 쇼핑몰

diff --git a/src/controllers/HomePageController.js b/src/controllers/HomePageController.js index bea777cb3..f7bc03543 100644 --- a/src/controllers/HomePageController.js +++ b/src/controllers/HomePageController.js @@ -48,7 +48,7 @@ class HomePageController { if (this.state.category2) params.set("category2", this.state.category2); const qs = params.toString(); - const newUrl = qs ? `/?${qs}` : "/"; + const newUrl = qs ? `${router.BASE_PATH}/?${qs}` : router.BASE_PATH; let method = replace ? "replaceState" : "pushState"; // limit 가 기본값(20)으로 돌아가는 경우 히스토리 덮어쓰기 if (!replace && this.state.limit === 20 && params.size === 1 && params.has("limit")) { diff --git a/src/controllers/ProductDetailController.js b/src/controllers/ProductDetailController.js index 552407fe0..c9611141f 100644 --- a/src/controllers/ProductDetailController.js +++ b/src/controllers/ProductDetailController.js @@ -45,7 +45,7 @@ class ProductDetailController { }); document.querySelector(".go-to-product-list")?.addEventListener("click", () => { - router.navigate("/"); + router.navigate(router.BASE_PATH); }); } } diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js index de28947a6..2a347ba7d 100644 --- a/src/pages/HomePage.js +++ b/src/pages/HomePage.js @@ -1,5 +1,6 @@ import ProductCard from "../components/ProductCard.js"; import { LoadingCardList } from "../components/LoadingCard.js"; +import router from "../router/Router.js"; export default function HomePage({ products = [], @@ -19,7 +20,7 @@ const 상품목록_레이아웃_로딩 = (products, total, loading, loadingMore,

- 쇼핑몰 + 쇼핑몰

diff --git a/src/pages/NotFoundPage.js b/src/pages/NotFoundPage.js index b0ae46d02..5ae48e2e0 100644 --- a/src/pages/NotFoundPage.js +++ b/src/pages/NotFoundPage.js @@ -1,3 +1,5 @@ +import router from "../router/Router.js"; + export default function NotFoundPage() { return `
@@ -29,7 +31,7 @@ export default function NotFoundPage() { - 홈으로 + 홈으로
`;