From d790dbbfbc2bf64fc30bbda1d19832749ca1eca3 Mon Sep 17 00:00:00 2001 From: eveneul Date: Mon, 7 Jul 2025 00:20:22 +0900 Subject: [PATCH 01/57] =?UTF-8?q?feat=20::=20View=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20mount=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/footer.js | 7 +++++++ src/core/BaseView.js | 30 ++++++++++++++++++++++++++++++ src/main.js | 11 ++++++++++- src/views/HomeView.js | 19 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/components/footer.js create mode 100644 src/core/BaseView.js create mode 100644 src/views/HomeView.js diff --git a/src/components/footer.js b/src/components/footer.js new file mode 100644 index 000000000..f6d4f0c22 --- /dev/null +++ b/src/components/footer.js @@ -0,0 +1,7 @@ +export default /* html */ ` + +`; diff --git a/src/core/BaseView.js b/src/core/BaseView.js new file mode 100644 index 000000000..06a308efe --- /dev/null +++ b/src/core/BaseView.js @@ -0,0 +1,30 @@ +class BaseView { + constructor() { + // main 태그 안에 template 렌더링 + + this.container = document.createElement("div"); + } + + // api 호출과 html 템플릿을 담음 + async template() { + return ``; + } + + // 각종 이벤트 처리 + bindEvents() {} + + // this.template을 render + async mount(parentElement = document.querySelector("main")) { + const result = await this.template(); // return한 html 문자열 받기 + this.container.innerHTML = result; + + parentElement.appendChild(this.container); + } + + // 페이지 이탈 + unMount() { + this.container.remove(); + } +} + +export default BaseView; diff --git a/src/main.js b/src/main.js index 4b055b89d..d3a84c3a3 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3 +1,6 @@ +import footer from "./components/footer.js"; +import HomeView from "./views/HomeView.js"; + const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => worker.start({ @@ -1119,7 +1122,11 @@ function main() { `; - document.body.innerHTML = ` + const home = new HomeView(); + + document.body.innerHTML = /* html */ ` +
+ ${footer} ${상품목록_레이아웃_로딩}
${상품목록_레이아웃_로딩완료} @@ -1142,6 +1149,8 @@ function main() {
${_404_} `; + + home.mount(); } // 애플리케이션 시작 diff --git a/src/views/HomeView.js b/src/views/HomeView.js new file mode 100644 index 000000000..45cb2aed6 --- /dev/null +++ b/src/views/HomeView.js @@ -0,0 +1,19 @@ +import BaseView from "../core/BaseView"; + +class HomeView extends BaseView { + constructor() { + super(); + } + + async template() { + return /* html */ ` +

메인페이지입니다

+ `; + } + + bindEvents() {} + + unmount() {} +} + +export default HomeView; From 78b89bbb703d775515cfc3eac1932d9a2e0a3058 Mon Sep 17 00:00:00 2001 From: eveneul Date: Mon, 7 Jul 2025 20:35:47 +0900 Subject: [PATCH 02/57] =?UTF-8?q?chore:=20=EC=9B=90=EB=B3=B8=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20CI=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98f93c693..727a7a2be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} - run: | - pnpm install - pnpm run test + npm install + npm run test e2e: timeout-minutes: 60 runs-on: ubuntu-latest From 01711de06b8e8d47f0237a77200357ab495bde8b Mon Sep 17 00:00:00 2001 From: eveneul Date: Mon, 7 Jul 2025 20:37:23 +0900 Subject: [PATCH 03/57] =?UTF-8?q?chore:=20=EC=9B=90=EB=B3=B8=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20CI=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 727a7a2be..7f6e45a14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} - run: | - npm install - npm run test + pnpm install + pnpm run test e2e: timeout-minutes: 60 runs-on: ubuntu-latest @@ -34,7 +34,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - cache: 'pnpm' + cache: "pnpm" - name: Install dependencies run: | pnpm install From b032117aa565549ffac358f0d40e07f2287d56d7 Mon Sep 17 00:00:00 2001 From: eveneul Date: Mon, 7 Jul 2025 20:46:40 +0900 Subject: [PATCH 04/57] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4,=20View=20=EB=B2=A0=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Header.js | 33 ++ src/core/BaseComponents.js | 29 + src/core/BaseView.js | 4 + src/core/Router.js | 61 ++ src/main.js | 1147 +----------------------------------- src/views/HomeView.js | 121 +++- 6 files changed, 256 insertions(+), 1139 deletions(-) create mode 100644 src/components/Header.js create mode 100644 src/core/BaseComponents.js create mode 100644 src/core/Router.js diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 000000000..c37b60add --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,33 @@ +import BaseComponents from "../core/BaseComponents"; + +class Header extends BaseComponents { + constructor() { + super(); + } + + async template() { + return /* html */ ` +
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+ + `; + } + + bindEvents() {} +} + +export default Header; diff --git a/src/core/BaseComponents.js b/src/core/BaseComponents.js new file mode 100644 index 000000000..7f8c95a76 --- /dev/null +++ b/src/core/BaseComponents.js @@ -0,0 +1,29 @@ +import Router from "./Router"; + +class BaseComponents { + constructor() { + this.container = document.createElement("div"); + this.router = new Router(); + } + + async template() { + return ``; + } + + bindEvents() {} + + async mount(parentElement) { + const result = await this.template(); + this.container.innerHTML = result; + if (parentElement) { + parentElement.appendChild(this.container); + } else { + document.querySelector("main").appendChild(this.container); + } + this.bindEvents(); + } + + unMount() {} +} + +export default BaseComponents; diff --git a/src/core/BaseView.js b/src/core/BaseView.js index 06a308efe..0c213a2b7 100644 --- a/src/core/BaseView.js +++ b/src/core/BaseView.js @@ -1,8 +1,11 @@ +import Router from "./Router"; + class BaseView { constructor() { // main 태그 안에 template 렌더링 this.container = document.createElement("div"); + this.router = new Router(); } // api 호출과 html 템플릿을 담음 @@ -19,6 +22,7 @@ class BaseView { this.container.innerHTML = result; parentElement.appendChild(this.container); + this.bindEvents(); } // 페이지 이탈 diff --git a/src/core/Router.js b/src/core/Router.js new file mode 100644 index 000000000..e042292f3 --- /dev/null +++ b/src/core/Router.js @@ -0,0 +1,61 @@ +class Router { + constructor() { + // url이 변경되는 이벤트를 감지하기 위한 prevUrl + this.prevUrl = location.pathname; + + window.addEventListener("popstate", () => { + this.emitUrlChange(); + }); + } + + // 라우트 변경 -> 새로고침 없이 페이지 변경을 위해 history.pushState 사용 + push(state = {}, url) { + history.pushState(state, "", url); + this.emitUrlChange(); // 주소 변경되었는지 확인하는 이벤트 발생 + } + + // 라우트 Replace -> 앞으로 가기/뒤로가기 기록에 남기지 않음 + replace(state = {}, url) { + history.replaceState(state, "", url); + this.emitUrlChange(); // 주소 변경되었는지 확인하는 이벤트 발생 + } + + // 앞으로 가기 + go(n) { + history.go(n); + } + + // 뒤로 가기 + back() { + history.back(); + } + + // 현재 URL 정보 가져오기 + getCurrentUrl() { + return window.location.pathname; + } + + // searchParams 정보 가져오기 + getParams() { + return new URL(document.location).searchParams; + } + + // this.push를 통해 url이 변경되었을 때를 감지 + emitUrlChange() { + const currentUrl = location.pathname; + if (this.prevUrl !== currentUrl) { + const event = new CustomEvent("urlChange", { + detail: { + prevUrl: this.prevUrl, + currentUrl, + isChange: true, + }, + }); + + window.dispatchEvent(event); + this.prevUrl = currentUrl; + } + } +} + +export default Router; diff --git a/src/main.js b/src/main.js index d3a84c3a3..dca6512db 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,5 @@ -import footer from "./components/footer.js"; +import footer from "./components/Footer.js"; +import Header from "./components/header.js"; import HomeView from "./views/HomeView.js"; const enableMocking = () => @@ -9,1147 +10,17 @@ const enableMocking = () => ); function main() { - 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 - - - - - - - - - 페이지를 찾을 수 없습니다 - - - - - - 홈으로 -
-
- `; - const home = new HomeView(); + const header = new Header(); document.body.innerHTML = /* html */ ` -
- ${footer} - ${상품목록_레이아웃_로딩} -
- ${상품목록_레이아웃_로딩완료} -
- ${상품목록_레이아웃_카테고리_1Depth} -
- ${상품목록_레이아웃_카테고리_2Depth} -
- ${토스트} -
- ${장바구니_비어있음} -
- ${장바구니_선택없음} -
- ${장바구니_선택있음} -
- ${상세페이지_로딩} -
- ${상세페이지_로딩완료} -
- ${_404_} +
+
+
+ ${footer} +
`; - + header.mount(document.querySelector("header")); home.mount(); } diff --git a/src/views/HomeView.js b/src/views/HomeView.js index 45cb2aed6..777fe52c4 100644 --- a/src/views/HomeView.js +++ b/src/views/HomeView.js @@ -7,7 +7,126 @@ class HomeView extends BaseView { async template() { return /* html */ ` -

메인페이지입니다

+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + 상품을 불러오는 중... +
+
+
+
`; } From e51e0a909ba14ea628c744afa34165e9065d3bea Mon Sep 17 00:00:00 2001 From: eveneul Date: Tue, 8 Jul 2025 00:21:29 +0900 Subject: [PATCH 05/57] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20fetching=20=EB=B0=8F=20Search=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC,=20render=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Search.js | 85 ++++++++++++++++++++ src/main.js | 21 ++++- src/views/HomeView.js | 166 ++++++++++++++++++++++++--------------- 3 files changed, 204 insertions(+), 68 deletions(-) create mode 100644 src/components/Search.js diff --git a/src/components/Search.js b/src/components/Search.js new file mode 100644 index 000000000..ff651d658 --- /dev/null +++ b/src/components/Search.js @@ -0,0 +1,85 @@ +import BaseComponents from "../core/BaseComponents"; + +class Search extends BaseComponents { + constructor() { + super(); + } + + async template() { + return /* html */ ` + +
+
+ +
+ + + +
+
+
+ + +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+
+
+ `; + } + + bindEvents() {} +} + +export default Search; diff --git a/src/main.js b/src/main.js index dca6512db..54a113ed2 100644 --- a/src/main.js +++ b/src/main.js @@ -13,13 +13,28 @@ function main() { const home = new HomeView(); const header = new Header(); - document.body.innerHTML = /* html */ ` -
+ // TODO :: 렌더링 로직 따로 분리하기 + const render = (html) => { + const root = document.getElementById("root"); + if (root) { + root.innerHTML = html; + } else { + const createRootElement = document.createElement("div"); + createRootElement.id = "root"; + createRootElement.innerHTML = html; + document.body.appendChild(createRootElement); + } + }; + + render(/* html */ ` +
+
${footer}
- `; + `); + header.mount(document.querySelector("header")); home.mount(); } diff --git a/src/views/HomeView.js b/src/views/HomeView.js index 777fe52c4..df6e54943 100644 --- a/src/views/HomeView.js +++ b/src/views/HomeView.js @@ -1,78 +1,31 @@ +import { getProducts } from "../api/productApi"; +import Search from "../components/Search"; import BaseView from "../core/BaseView"; class HomeView extends BaseView { constructor() { super(); + + this.isLoading = true; + this.products = []; + this.totalProductCount = 0; } async template() { return /* html */ ` -
- -
-
- -
- - - -
-
+
+
- -
- -
-
- - -
- -
-
카테고리 로딩 중...
-
- -
- -
- -
- - -
- -
- - -
-
+ +
+ ${this.isLoading ? this.renderSkeletons() : this.renderProducts()}
-
- -
+ `; + } + + renderSkeletons() { + return /* html */ `
@@ -114,7 +67,6 @@ class HomeView extends BaseView {
-
@@ -126,12 +78,96 @@ class HomeView extends BaseView {
-
+ `; + } + + renderProducts() { + return /* html */ ` +
+ +
+ 총 ${this.totalProductCount}개의 상품 +
+ +
+ ${this.products + .map( + (product) => /* html */ ` +
+ +
+ ${product.title} +
+ +
+
+

+ ${product.title} +

+

+

+ ${product.lprice}원 +

+
+ + +
+
+ `, + ) + .join("")} +
+ +
+ 모든 상품을 확인했습니다 +
+
`; } bindEvents() {} + async fetchProducts(params = {}) { + this.isLoading = true; + try { + const data = await getProducts(params); + this.totalProductCount = data.pagination.total; + this.products = [...data.products]; + } catch (error) { + throw new Error(error); + } finally { + this.isLoading = false; + } + } + + async render() { + this.container.innerHTML = await this.template(); + } + + async mount(parentElement = document.querySelector("main")) { + await this.render(); + + parentElement.appendChild(this.container); + + const search = new Search(); + const searchContainer = this.container.querySelector("#search-container"); + await search.mount(searchContainer); + + await this.fetchProducts(); + + const productContainer = this.container.querySelector("#products-container"); + productContainer.innerHTML = this.renderProducts(); + + this.bindEvents(); + } + unmount() {} } From 3683f35dea57b53e59915a91ccf9e40e95879f05 Mon Sep 17 00:00:00 2001 From: eveneul Date: Wed, 9 Jul 2025 18:35:59 +0900 Subject: [PATCH 06/57] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=84=EB=A9=B4=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Header.js | 47 ++++------ src/components/Search.js | 85 ------------------ src/components/footer.js | 15 ++-- src/core/BaseComponents.js | 29 ------- src/core/BaseView.js | 34 -------- src/core/Router.js | 61 ------------- src/core/useNavigate.js | 44 ++++++++++ src/js/utils.js | 5 ++ src/main.js | 34 +------- src/pages/Home.js | 4 + src/pages/NotFound.js | 5 ++ src/pages/Product.js | 5 ++ src/routes.js | 21 +++++ src/views/HomeView.js | 174 ------------------------------------- 14 files changed, 112 insertions(+), 451 deletions(-) delete mode 100644 src/components/Search.js delete mode 100644 src/core/BaseComponents.js delete mode 100644 src/core/BaseView.js delete mode 100644 src/core/Router.js create mode 100644 src/core/useNavigate.js create mode 100644 src/js/utils.js create mode 100644 src/pages/Home.js create mode 100644 src/pages/NotFound.js create mode 100644 src/pages/Product.js create mode 100644 src/routes.js delete mode 100644 src/views/HomeView.js diff --git a/src/components/Header.js b/src/components/Header.js index c37b60add..eaf0f3306 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -1,33 +1,20 @@ -import BaseComponents from "../core/BaseComponents"; - -class Header extends BaseComponents { - constructor() { - super(); - } - - async template() { - return /* html */ ` -
-
-

- 쇼핑몰 -

-
- - -
+export default function Header() { + return /* html */ ` +
+
+

+ 쇼핑몰 +

+
+ +
- - `; - } - - bindEvents() {} +
+ `; } - -export default Header; diff --git a/src/components/Search.js b/src/components/Search.js deleted file mode 100644 index ff651d658..000000000 --- a/src/components/Search.js +++ /dev/null @@ -1,85 +0,0 @@ -import BaseComponents from "../core/BaseComponents"; - -class Search extends BaseComponents { - constructor() { - super(); - } - - async template() { - return /* html */ ` - -
-
- -
- - - -
-
-
- - -
- -
-
- - -
- -
-
카테고리 로딩 중...
-
- -
- - -
- -
- - -
- - -
- - -
-
-
- `; - } - - bindEvents() {} -} - -export default Search; diff --git a/src/components/footer.js b/src/components/footer.js index f6d4f0c22..f4460532b 100644 --- a/src/components/footer.js +++ b/src/components/footer.js @@ -1,7 +1,8 @@ -export default /* html */ ` -
-
-

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

-
-
-`; +export default function Footer() { + return /* html */ ` +
+
+

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

+
+
`; +} diff --git a/src/core/BaseComponents.js b/src/core/BaseComponents.js deleted file mode 100644 index 7f8c95a76..000000000 --- a/src/core/BaseComponents.js +++ /dev/null @@ -1,29 +0,0 @@ -import Router from "./Router"; - -class BaseComponents { - constructor() { - this.container = document.createElement("div"); - this.router = new Router(); - } - - async template() { - return ``; - } - - bindEvents() {} - - async mount(parentElement) { - const result = await this.template(); - this.container.innerHTML = result; - if (parentElement) { - parentElement.appendChild(this.container); - } else { - document.querySelector("main").appendChild(this.container); - } - this.bindEvents(); - } - - unMount() {} -} - -export default BaseComponents; diff --git a/src/core/BaseView.js b/src/core/BaseView.js deleted file mode 100644 index 0c213a2b7..000000000 --- a/src/core/BaseView.js +++ /dev/null @@ -1,34 +0,0 @@ -import Router from "./Router"; - -class BaseView { - constructor() { - // main 태그 안에 template 렌더링 - - this.container = document.createElement("div"); - this.router = new Router(); - } - - // api 호출과 html 템플릿을 담음 - async template() { - return ``; - } - - // 각종 이벤트 처리 - bindEvents() {} - - // this.template을 render - async mount(parentElement = document.querySelector("main")) { - const result = await this.template(); // return한 html 문자열 받기 - this.container.innerHTML = result; - - parentElement.appendChild(this.container); - this.bindEvents(); - } - - // 페이지 이탈 - unMount() { - this.container.remove(); - } -} - -export default BaseView; diff --git a/src/core/Router.js b/src/core/Router.js deleted file mode 100644 index e042292f3..000000000 --- a/src/core/Router.js +++ /dev/null @@ -1,61 +0,0 @@ -class Router { - constructor() { - // url이 변경되는 이벤트를 감지하기 위한 prevUrl - this.prevUrl = location.pathname; - - window.addEventListener("popstate", () => { - this.emitUrlChange(); - }); - } - - // 라우트 변경 -> 새로고침 없이 페이지 변경을 위해 history.pushState 사용 - push(state = {}, url) { - history.pushState(state, "", url); - this.emitUrlChange(); // 주소 변경되었는지 확인하는 이벤트 발생 - } - - // 라우트 Replace -> 앞으로 가기/뒤로가기 기록에 남기지 않음 - replace(state = {}, url) { - history.replaceState(state, "", url); - this.emitUrlChange(); // 주소 변경되었는지 확인하는 이벤트 발생 - } - - // 앞으로 가기 - go(n) { - history.go(n); - } - - // 뒤로 가기 - back() { - history.back(); - } - - // 현재 URL 정보 가져오기 - getCurrentUrl() { - return window.location.pathname; - } - - // searchParams 정보 가져오기 - getParams() { - return new URL(document.location).searchParams; - } - - // this.push를 통해 url이 변경되었을 때를 감지 - emitUrlChange() { - const currentUrl = location.pathname; - if (this.prevUrl !== currentUrl) { - const event = new CustomEvent("urlChange", { - detail: { - prevUrl: this.prevUrl, - currentUrl, - isChange: true, - }, - }); - - window.dispatchEvent(event); - this.prevUrl = currentUrl; - } - } -} - -export default Router; diff --git a/src/core/useNavigate.js b/src/core/useNavigate.js new file mode 100644 index 000000000..b40a8f99a --- /dev/null +++ b/src/core/useNavigate.js @@ -0,0 +1,44 @@ +const useNavigate = () => { + // 전 URL과 현재 URL을 비교하여 커스텀 이벤트 생성 + let prevUrl = location.pathname; + + const emitUrlChangeEvent = () => { + const currentUrl = location.pathname; + if (prevUrl !== currentUrl) { + const event = new CustomEvent("urlChange", { + detail: { + prevUrl, + currentUrl, + isUrlChange: prevUrl !== currentUrl, + }, + }); + + window.dispatchEvent(event); + prevUrl = currentUrl; + } + }; + + window.addEventListener("popstate", emitUrlChangeEvent); + + return { + push: (state = {}, url) => { + history.pushState(state, "", url); + emitUrlChangeEvent(); + }, + replace: (state = {}, url) => { + history.replaceState(state, "", url); + emitUrlChangeEvent(); + }, + go: (n) => { + history.go(n); + }, + back: () => { + history.back(); + }, + getCurrentUrl: () => { + return window.location.pathname; + }, + }; +}; + +export default useNavigate; diff --git a/src/js/utils.js b/src/js/utils.js new file mode 100644 index 000000000..49baf78c2 --- /dev/null +++ b/src/js/utils.js @@ -0,0 +1,5 @@ +// routes에 적은 path를 정규식으로 변환 -> location.pathname이랑 비교가 가능하게끔 +export function pathToRegex(path) { + // "/product/:id" → /^\/product\/([^/]+)$/ + return new RegExp("^" + path.replace(/:\w+/g, "([^/]+)").replace("*", ".*") + "$"); +} diff --git a/src/main.js b/src/main.js index 54a113ed2..e4a3b401b 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,3 @@ -import footer from "./components/Footer.js"; -import Header from "./components/header.js"; -import HomeView from "./views/HomeView.js"; - const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => worker.start({ @@ -10,33 +6,9 @@ const enableMocking = () => ); function main() { - const home = new HomeView(); - const header = new Header(); - - // TODO :: 렌더링 로직 따로 분리하기 - const render = (html) => { - const root = document.getElementById("root"); - if (root) { - root.innerHTML = html; - } else { - const createRootElement = document.createElement("div"); - createRootElement.id = "root"; - createRootElement.innerHTML = html; - document.body.appendChild(createRootElement); - } - }; - - render(/* html */ ` -
-
-
-
- ${footer} -
- `); - - header.mount(document.querySelector("header")); - home.mount(); + document.body.innerHTML = /* html */ ` +
+ `; } // 애플리케이션 시작 diff --git a/src/pages/Home.js b/src/pages/Home.js new file mode 100644 index 000000000..80f04b3fa --- /dev/null +++ b/src/pages/Home.js @@ -0,0 +1,4 @@ +export default function Home() { + return /* html */ ` + `; +} diff --git a/src/pages/NotFound.js b/src/pages/NotFound.js new file mode 100644 index 000000000..6efb1d1d9 --- /dev/null +++ b/src/pages/NotFound.js @@ -0,0 +1,5 @@ +export default function NotFound() { + return /* html */ ` +

+ `; +} diff --git a/src/pages/Product.js b/src/pages/Product.js new file mode 100644 index 000000000..2026fe729 --- /dev/null +++ b/src/pages/Product.js @@ -0,0 +1,5 @@ +export default function Product() { + return /* html */ ` +

+ `; +} diff --git a/src/routes.js b/src/routes.js new file mode 100644 index 000000000..62706aea6 --- /dev/null +++ b/src/routes.js @@ -0,0 +1,21 @@ +import { pathToRegex } from "./js/utils"; +import Home from "./pages/Home"; +import NotFound from "./pages/NotFound"; +import Product from "./pages/Product"; + +const routes = [ + { + path: pathToRegex("/"), + component: Home, + }, + { + path: pathToRegex("/product/:productId"), + component: Product, + }, + { + path: pathToRegex("*"), + component: NotFound, + }, +]; + +export default routes; diff --git a/src/views/HomeView.js b/src/views/HomeView.js deleted file mode 100644 index df6e54943..000000000 --- a/src/views/HomeView.js +++ /dev/null @@ -1,174 +0,0 @@ -import { getProducts } from "../api/productApi"; -import Search from "../components/Search"; -import BaseView from "../core/BaseView"; - -class HomeView extends BaseView { - constructor() { - super(); - - this.isLoading = true; - this.products = []; - this.totalProductCount = 0; - } - - async template() { - return /* html */ ` - -
- -
- -
- ${this.isLoading ? this.renderSkeletons() : this.renderProducts()} -
- `; - } - - renderSkeletons() { - return /* html */ ` -
- -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - - - 상품을 불러오는 중... -
-
-
- `; - } - - renderProducts() { - return /* html */ ` -
- -
- 총 ${this.totalProductCount}개의 상품 -
- -
- ${this.products - .map( - (product) => /* html */ ` -
- -
- ${product.title} -
- -
-
-

- ${product.title} -

-

-

- ${product.lprice}원 -

-
- - -
-
- `, - ) - .join("")} -
- -
- 모든 상품을 확인했습니다 -
-
- `; - } - - bindEvents() {} - - async fetchProducts(params = {}) { - this.isLoading = true; - try { - const data = await getProducts(params); - this.totalProductCount = data.pagination.total; - this.products = [...data.products]; - } catch (error) { - throw new Error(error); - } finally { - this.isLoading = false; - } - } - - async render() { - this.container.innerHTML = await this.template(); - } - - async mount(parentElement = document.querySelector("main")) { - await this.render(); - - parentElement.appendChild(this.container); - - const search = new Search(); - const searchContainer = this.container.querySelector("#search-container"); - await search.mount(searchContainer); - - await this.fetchProducts(); - - const productContainer = this.container.querySelector("#products-container"); - productContainer.innerHTML = this.renderProducts(); - - this.bindEvents(); - } - - unmount() {} -} - -export default HomeView; From 53a160cde1e27c2b8e83b103d855ffd44831ca9a Mon Sep 17 00:00:00 2001 From: eveneul Date: Wed, 9 Jul 2025 19:03:06 +0900 Subject: [PATCH 07/57] =?UTF-8?q?feat:=20useRender=20Hook,=20Layout=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Layout.js | 13 +++++++++++++ src/core/useRender.js | 25 +++++++++++++++++++++++++ src/main.js | 16 +++++++++++++--- 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 src/components/Layout.js create mode 100644 src/core/useRender.js diff --git a/src/components/Layout.js b/src/components/Layout.js new file mode 100644 index 000000000..f21ce194f --- /dev/null +++ b/src/components/Layout.js @@ -0,0 +1,13 @@ +Layout.mount = () => { + console.log("Layout Mount"); +}; + +export default function Layout() { + return /* html */ ` +
+ +
+
+
+ `; +} diff --git a/src/core/useRender.js b/src/core/useRender.js new file mode 100644 index 000000000..2d7584000 --- /dev/null +++ b/src/core/useRender.js @@ -0,0 +1,25 @@ +import routes from "../routes"; +import useNavigate from "./useNavigate"; + +const useRender = () => { + const navigate = useNavigate(); + + const draw = (tag, html) => { + document.querySelector(tag).innerHTML = html; + }; + + const view = async () => { + for (const route of routes) { + const match = navigate.getCurrentUrl().match(route.path); + if (!match) continue; + const Page = route.component; + Page.init?.(match?.[1]); + draw("main", Page({})); + await Page.mount?.(); + } + }; + + return { draw, view }; +}; + +export default useRender; diff --git a/src/main.js b/src/main.js index e4a3b401b..5104045b5 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3 +1,6 @@ +import Layout from "./components/Layout.js"; +import useRender from "./core/useRender.js"; + const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => worker.start({ @@ -5,10 +8,17 @@ const enableMocking = () => }), ); +const render = useRender(); + function main() { - document.body.innerHTML = /* html */ ` -
- `; + // #root Element에 Layout HTML 삽입 + render.draw("#root", Layout()); + + // Page에 init, mount 실행 + render.view(); + + // Layout 컴포넌트 마운트 + Layout.mount?.(); } // 애플리케이션 시작 From 9dcc2d01f7bb60e858dd7179c8899c7b8986837d Mon Sep 17 00:00:00 2001 From: eveneul Date: Wed, 9 Jul 2025 21:56:58 +0900 Subject: [PATCH 08/57] =?UTF-8?q?feat:=20=EC=A0=84=EC=97=AD=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/useStore.js | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/core/useStore.js diff --git a/src/core/useStore.js b/src/core/useStore.js new file mode 100644 index 000000000..a87837541 --- /dev/null +++ b/src/core/useStore.js @@ -0,0 +1,39 @@ +const useStore = (() => { + // 장바구니, params 등 전역적으로 쓰일 상태들 + const globalState = {}; + + const listener = []; + + // 전역 상태를 가져오는 함수 + const get = (key) => { + if (!key) { + return globalState; + } else { + return globalState[key]; + } + }; + + // 전역 상태를 만드는 함수 + const set = (key, value) => { + globalState[key] = value; + + listener.forEach(({ callback, targetKey }) => { + if (!targetKey || targetKey === key) { + callback(globalState[key], globalState); + } + }); + }; + + // 전역 상태가 변경됐음을 감지해 주는 함수 + const watch = (callback, targetKey = null) => { + listener.push({ callback, targetKey }); + + return () => { + const index = listener.findIndex((l) => l.callback === callback && l.targetKey === targetKey); + if (index !== -1) listener.splice(index, 1); + }; + }; + + return () => ({ get, set, watch }); +})(); +export default useStore; From 23ef1d57e281ef4bb11447517aaed392cca64384 Mon Sep 17 00:00:00 2001 From: eveneul Date: Wed, 9 Jul 2025 22:08:03 +0900 Subject: [PATCH 09/57] =?UTF-8?q?fix:=20=ED=99=94=EB=A9=B4=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=ED=95=A8=EC=88=98=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 매치된 첫 번째 라우트만 렌더링되게 return 추가 --- src/core/useRender.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/useRender.js b/src/core/useRender.js index 2d7584000..1dcc2af85 100644 --- a/src/core/useRender.js +++ b/src/core/useRender.js @@ -16,6 +16,7 @@ const useRender = () => { Page.init?.(match?.[1]); draw("main", Page({})); await Page.mount?.(); + return; // 매치된 첫 번째 라우트만 실행하고 멈추게 } }; From 2d6d9ac48c6609416898f5aca856c544b43da762 Mon Sep 17 00:00:00 2001 From: eveneul Date: Wed, 9 Jul 2025 22:22:24 +0900 Subject: [PATCH 10/57] =?UTF-8?q?feat:=20=EB=A1=9C=EB=94=A9=20UI=20Compone?= =?UTF-8?q?nt=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - type(category, products, product)에 따른 분기 처리 --- src/components/Loading.js | 79 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/components/Loading.js diff --git a/src/components/Loading.js b/src/components/Loading.js new file mode 100644 index 000000000..57eb4536f --- /dev/null +++ b/src/components/Loading.js @@ -0,0 +1,79 @@ +const getLoadingTemplate = (type) => { + switch (type) { + case "category": + return /* html */ ` +
+
카테고리 로딩 중...
+
+ `; + + case "products": + return /* html */ ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + 상품을 불러오는 중... +
+
+ `; + + case "product": + return /* html */ ` +
+
+
+

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

+
+
+ `; + + default: + return null; + } +}; + +export default function Loading({ type }) { + return getLoadingTemplate(type); +} From 239fb1164734323beb008bc0e3953f731146b5df Mon Sep 17 00:00:00 2001 From: eveneul Date: Wed, 9 Jul 2025 22:40:46 +0900 Subject: [PATCH 11/57] =?UTF-8?q?feat:=20products=20API=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=8F=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductList, ProductCard, Search 컴포넌트 생성 - Home 페이지에서 getProducts API 호출 - useStore.js 기본 전역 상태 추가 --- src/components/Layout.js | 8 +++- src/components/Loading.js | 84 ++++++++++++++++++----------------- src/components/ProductCard.js | 31 +++++++++++++ src/components/ProductList.js | 21 +++++++++ src/components/Search.js | 68 ++++++++++++++++++++++++++++ src/core/useStore.js | 18 +++++++- src/pages/Home.js | 36 ++++++++++++++- 7 files changed, 222 insertions(+), 44 deletions(-) create mode 100644 src/components/ProductCard.js create mode 100644 src/components/ProductList.js create mode 100644 src/components/Search.js diff --git a/src/components/Layout.js b/src/components/Layout.js index f21ce194f..2c2d6ce66 100644 --- a/src/components/Layout.js +++ b/src/components/Layout.js @@ -1,5 +1,11 @@ +import useRender from "../core/useRender"; +import Footer from "./footer"; +import Header from "./Header"; + Layout.mount = () => { - console.log("Layout Mount"); + const render = useRender(); + render.draw("#header", Header()); + render.draw("#footer", Footer()); }; export default function Layout() { diff --git a/src/components/Loading.js b/src/components/Loading.js index 57eb4536f..d7e47f032 100644 --- a/src/components/Loading.js +++ b/src/components/Loading.js @@ -9,52 +9,54 @@ const getLoadingTemplate = (type) => { case "products": return /* html */ ` -
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
- -
-
- - - - - 상품을 불러오는 중... + +
+
+ + + + + 상품을 불러오는 중... +
`; diff --git a/src/components/ProductCard.js b/src/components/ProductCard.js new file mode 100644 index 000000000..1c40962f2 --- /dev/null +++ b/src/components/ProductCard.js @@ -0,0 +1,31 @@ +export default function ProductCard(product) { + return /* html */ ` +
+ +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+
+

+ ${product.title} +

+

+

+ ${product.lprice}원 +

+
+ + +
+
+ `; +} diff --git a/src/components/ProductList.js b/src/components/ProductList.js new file mode 100644 index 000000000..990c7307b --- /dev/null +++ b/src/components/ProductList.js @@ -0,0 +1,21 @@ +import ProductCard from "./ProductCard"; + +export default function ProductList(products, pagination) { + return /* html */ ` +
+
+ +
+ 총 ${pagination.total}개의 상품 +
+ +
+ ${products.map((product) => ProductCard(product)).join("")} +
+
+ 모든 상품을 확인했습니다 +
+
+
+ `; +} diff --git a/src/components/Search.js b/src/components/Search.js new file mode 100644 index 000000000..8ff93b373 --- /dev/null +++ b/src/components/Search.js @@ -0,0 +1,68 @@ +export default function Search() { + return /* html */ ` + +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ `; +} diff --git a/src/core/useStore.js b/src/core/useStore.js index a87837541..fd9061a5e 100644 --- a/src/core/useStore.js +++ b/src/core/useStore.js @@ -1,6 +1,22 @@ const useStore = (() => { // 장바구니, params 등 전역적으로 쓰일 상태들 - const globalState = {}; + const globalState = { + filters: { + category1: "", + category2: "", + search: "", + sort: "price_asc", + }, + + pagination: { + hasNext: false, + hasPrev: false, + limit: 20, + page: 1, + total: 0, + totalPages: 0, + }, + }; const listener = []; diff --git a/src/pages/Home.js b/src/pages/Home.js index 80f04b3fa..32ea19a02 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -1,4 +1,38 @@ -export default function Home() { +import { getProducts } from "../api/productApi"; +import Loading from "../components/Loading"; +import ProductList from "../components/ProductList"; +import Search from "../components/Search"; +import useRender from "../core/useRender"; + +const state = { + isLoading: true, +}; + +const fetchProducts = async () => { + const data = await getProducts(); + state.isLoading = false; + return { + products: data.products, + pagination: data.pagination, + }; +}; + +Home.mount = async () => { + const render = useRender(); + const { products, pagination } = await fetchProducts(); + + render.draw("main", Home({ products, pagination })); +}; + +export default function Home({ products, pagination }) { return /* html */ ` + ${Search()} + +
+
+ + ${state.isLoading ? Loading({ type: "products" }) : ProductList(products, pagination)} +
+
`; } From 53a2667e86b31df19a79635dd0c57b86a7e075b0 Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 00:18:02 +0900 Subject: [PATCH 12/57] =?UTF-8?q?feat:=20store=20set=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A4=91=EC=B2=A9=20=ED=82=A4=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/useStore.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/core/useStore.js b/src/core/useStore.js index fd9061a5e..547ea6073 100644 --- a/src/core/useStore.js +++ b/src/core/useStore.js @@ -20,6 +20,19 @@ const useStore = (() => { const listener = []; + // set으로 중첩 객체에 접근하기 위함 (gpt) + const setNestedValue = (obj, path, value) => { + const keys = path.split("."); + let current = obj; + + keys.slice(0, -1).forEach((key) => { + if (!current[key]) current[key] = {}; + current = current[key]; + }); + + current[keys[keys.length - 1]] = value; + }; + // 전역 상태를 가져오는 함수 const get = (key) => { if (!key) { @@ -31,11 +44,16 @@ const useStore = (() => { // 전역 상태를 만드는 함수 const set = (key, value) => { - globalState[key] = value; + setNestedValue(globalState, key, value); listener.forEach(({ callback, targetKey }) => { if (!targetKey || targetKey === key) { - callback(globalState[key], globalState); + const keys = key.split("."); + let updated = globalState; + keys.forEach((k) => { + if (updated) updated = updated[k]; + }); + callback(updated, globalState); } }); }; From bd4c84c8af8bf897d93fed7835563eb905a92382 Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 00:25:22 +0900 Subject: [PATCH 13/57] =?UTF-8?q?feat:=20store=20watch=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2 depth가 변경되어도 1 depth 전체를 반환해주는 코드로 변경 (gpt) --- src/core/useStore.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/core/useStore.js b/src/core/useStore.js index 547ea6073..ab5de8728 100644 --- a/src/core/useStore.js +++ b/src/core/useStore.js @@ -33,6 +33,10 @@ const useStore = (() => { current[keys[keys.length - 1]] = value; }; + const getNestedValue = (obj, path) => { + return path.split(".").reduce((acc, key) => acc?.[key], obj); + }; + // 전역 상태를 가져오는 함수 const get = (key) => { if (!key) { @@ -47,13 +51,9 @@ const useStore = (() => { setNestedValue(globalState, key, value); listener.forEach(({ callback, targetKey }) => { - if (!targetKey || targetKey === key) { - const keys = key.split("."); - let updated = globalState; - keys.forEach((k) => { - if (updated) updated = updated[k]; - }); - callback(updated, globalState); + if (!targetKey || key === targetKey || key.startsWith(`${targetKey}.`)) { + const valueToReturn = getNestedValue(globalState, targetKey); + callback(valueToReturn, globalState); } }); }; From 614e88a6cf479ddabaff2c6d14ce9c15cd29709a Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 01:20:42 +0900 Subject: [PATCH 14/57] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - globalStore에서 filter -> params로 변경 및 항목 추가 - ProductCard.js에서 상품 카드 이미지 경로를 데이터로 수정 - Search.js 컴포넌트 추가 및 이벤트 바인딩 - 🐛 검색 결과에 따른 상품 목록 리랜더링이 먹히지 않음 --- src/components/ProductCard.js | 2 +- src/components/Search.js | 81 ++++++++++++++++++++++++++++++++++- src/core/useStore.js | 4 +- src/pages/Home.js | 43 +++++++++++++++---- 4 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src/components/ProductCard.js b/src/components/ProductCard.js index 1c40962f2..f2b7f801a 100644 --- a/src/components/ProductCard.js +++ b/src/components/ProductCard.js @@ -4,7 +4,7 @@ export default function ProductCard(product) { data-product-id="${product.productId}">
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 diff --git a/src/components/Search.js b/src/components/Search.js index 8ff93b373..fb32b73e0 100644 --- a/src/components/Search.js +++ b/src/components/Search.js @@ -1,4 +1,63 @@ -export default function Search() { +// import useNavigate from "../core/useNavigate"; +import useStore from "../core/useStore"; +import Loading from "./Loading"; + +const store = useStore(); +// const navigate = useNavigate(); + +const events = [ + { + el: window, + action: "keyup", + fn: (event) => { + if (event.key === "Enter") { + const searchInput = document.querySelector("#search-input"); + const keyword = searchInput.value; + store.set("params.search", keyword); + } + }, + }, + { + el: "#limit-select", + action: "change", + fn: (event) => store.set("params.limit", event.target.value), + }, + { + el: "#sort-select", + action: "change", + fn: (event) => store.set("params.sort", event.target.value), + }, + {}, +]; + +const bindEvent = (el, action, fn) => { + if (el === window) { + el?.addEventListener(action, fn); + } else { + document.querySelector(el)?.addEventListener(action, fn); + } +}; + +Search.mount = () => { + events.forEach((e) => { + const { el, action, fn } = e; + bindEvent(el, action, fn); + }); + + // Home.js에서도 같은 watch를 써야 할 것 같아서.. 중복으로 쓰는 게 의미가 있을지 몰라 주석 + // store.watch((newValue) => { + // const url = new URL(window.location); + // Object.entries(newValue).forEach(([key, value]) => { + // if (value !== "" && value) { + // url.searchParams.set(key, value); + // } + // }); + // navigate.push({}, url.toString()); + // }, "params"); +}; + +export default function Search(categories = {}, isLoading = true) { + console.log(categories, "isLoading"); return /* html */ `
@@ -25,7 +84,25 @@ export default function Search() {
-
카테고리 로딩 중...
+ ${ + isLoading + ? Loading({ type: "category" }) + : Object.keys(categories) + .map( + (category1) => /* html */ ` + + `, + ) + .join("") + } + +
diff --git a/src/core/useStore.js b/src/core/useStore.js index ab5de8728..527635653 100644 --- a/src/core/useStore.js +++ b/src/core/useStore.js @@ -1,11 +1,13 @@ const useStore = (() => { // 장바구니, params 등 전역적으로 쓰일 상태들 const globalState = { - filters: { + params: { category1: "", category2: "", search: "", sort: "price_asc", + page: 1, + limit: 20, }, pagination: { diff --git a/src/pages/Home.js b/src/pages/Home.js index 32ea19a02..27b7eb3a1 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -1,32 +1,57 @@ -import { getProducts } from "../api/productApi"; +import { getCategories, getProducts } from "../api/productApi"; import Loading from "../components/Loading"; import ProductList from "../components/ProductList"; import Search from "../components/Search"; +import useNavigate from "../core/useNavigate"; import useRender from "../core/useRender"; +import useStore from "../core/useStore"; +const store = useStore(); +const navigate = useNavigate(); const state = { isLoading: true, }; -const fetchProducts = async () => { - const data = await getProducts(); +const fetchProducts = async (params = {}) => { + state.isLoading = true; + const productData = await getProducts(params); + const categoriesData = await getCategories(); state.isLoading = false; return { - products: data.products, - pagination: data.pagination, + products: productData.products, + pagination: productData.pagination, + categories: categoriesData, }; }; Home.mount = async () => { const render = useRender(); - const { products, pagination } = await fetchProducts(); + const { products, pagination, categories } = await fetchProducts(); - render.draw("main", Home({ products, pagination })); + render.draw("main", Home({ products, pagination, isLoading: state.isLoading, categories })); + Search.mount(); + + store.watch(async (newValue) => { + const url = new URL(window.location); + Object.entries(newValue).forEach(([key, value]) => { + if (value !== "" && value) { + url.searchParams.set(key, value); + } + }); + navigate.push({}, url.toString()); + + const { products } = await fetchProducts(newValue); + console.log(products, "prod"); + // 화면을 다시 그리기 위해서 아래 함수를 실행시키면 딱 한 번만 검색이 되고, 두 번째부터는 먹히지 않음 + // TODO :: 상품 검색 수정 + // render.draw("main", Home({ products, pagination, categories })); + }, "params"); }; -export default function Home({ products, pagination }) { +export default function Home({ products, pagination, isLoading, categories }) { + console.log(products); return /* html */ ` - ${Search()} + ${Search(categories, isLoading)}
From f7090213c5beabd49c32ed8a808b1f071bde7815 Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 01:25:31 +0900 Subject: [PATCH 15/57] =?UTF-8?q?fix:=20=EA=B2=80=EC=83=89=EA=B0=92?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=A6=AC=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Search.mount()로 Search에 관한 이벤트 다시 랜더링 해 주어야 함 --- src/pages/Home.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pages/Home.js b/src/pages/Home.js index 27b7eb3a1..7b6c97a2e 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -40,11 +40,9 @@ Home.mount = async () => { }); navigate.push({}, url.toString()); - const { products } = await fetchProducts(newValue); - console.log(products, "prod"); - // 화면을 다시 그리기 위해서 아래 함수를 실행시키면 딱 한 번만 검색이 되고, 두 번째부터는 먹히지 않음 - // TODO :: 상품 검색 수정 - // render.draw("main", Home({ products, pagination, categories })); + const { products, pagination, categories } = await fetchProducts(newValue); + render.draw("main", Home({ products, pagination, isLoading: state.isLoading, categories })); + Search.mount(); }, "params"); }; From 542c4ca11e60f8c35e82c9fb908b425ee61347a0 Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 02:22:28 +0900 Subject: [PATCH 16/57] =?UTF-8?q?feat:=20=EB=AC=B4=ED=95=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EA=B5=AC=ED=98=84=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스켈레톤 UI 노출 후 새로운 프로덕트 리스트 UI 노출 작업 중 --- src/components/Search.js | 1 - src/pages/Home.js | 94 +++++++++++++++++++++++++++++++++++----- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/components/Search.js b/src/components/Search.js index fb32b73e0..c09cf7b24 100644 --- a/src/components/Search.js +++ b/src/components/Search.js @@ -57,7 +57,6 @@ Search.mount = () => { }; export default function Search(categories = {}, isLoading = true) { - console.log(categories, "isLoading"); return /* html */ `
diff --git a/src/pages/Home.js b/src/pages/Home.js index 7b6c97a2e..61287c0a4 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -10,6 +10,8 @@ const store = useStore(); const navigate = useNavigate(); const state = { isLoading: true, + products: [], + pagination: {}, }; const fetchProducts = async (params = {}) => { @@ -17,21 +19,87 @@ const fetchProducts = async (params = {}) => { const productData = await getProducts(params); const categoriesData = await getCategories(); state.isLoading = false; + + // 무한 스크롤 방식 구현으로 누적된 product 값 + if (params.page && params.page > 1) { + state.products = [...state.products, ...productData.products]; + } else { + state.products = productData.products; + } + + state.pagination = productData.pagination; + return { - products: productData.products, - pagination: productData.pagination, categories: categoriesData, }; }; +const loadMoreProducts = (trigger, callback) => { + const io = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + callback(); + } + }); + }, + { + root: null, + rootMargin: "0px", + threshold: 1.0, + }, + ); + + io.observe(trigger); +}; + +const loadMoreProductsCallback = () => { + const currentPage = store.get("params")["page"]; + const hasNextPage = state.pagination.hasNext; + if (!hasNextPage) return; + const render = useRender(); + const nextPage = currentPage + 1; + + fetchProducts({ ...store.get("params"), page: nextPage }).then(({ categories }) => { + render.draw( + "main", + Home({ + products: state.products, + pagination: state.pagination, + isLoading: state.isLoading, + categories, + }), + ); + + Search.mount(); + + setTimeout(() => { + const trigger = document.getElementById("scroll-trigger"); + if (trigger) { + loadMoreProducts(trigger, loadMoreProductsCallback); + } + }, 200); + }); +}; + Home.mount = async () => { const render = useRender(); - const { products, pagination, categories } = await fetchProducts(); + const { categories } = await fetchProducts(); + + render.draw( + "main", + Home({ + products: state.products, + pagination: state.pagination, + isLoading: state.isLoading, + categories, + }), + ); - render.draw("main", Home({ products, pagination, isLoading: state.isLoading, categories })); Search.mount(); store.watch(async (newValue) => { + console.log("watch"); const url = new URL(window.location); Object.entries(newValue).forEach(([key, value]) => { if (value !== "" && value) { @@ -40,14 +108,19 @@ Home.mount = async () => { }); navigate.push({}, url.toString()); - const { products, pagination, categories } = await fetchProducts(newValue); - render.draw("main", Home({ products, pagination, isLoading: state.isLoading, categories })); + const { categories } = await fetchProducts(newValue); + render.draw( + "main", + Home({ products: state.products, pagination: state.pagination, isLoading: state.isLoading, categories }), + ); Search.mount(); }, "params"); + + const scrollTrigger = document.getElementById("scroll-trigger"); + loadMoreProducts(scrollTrigger, loadMoreProductsCallback); }; export default function Home({ products, pagination, isLoading, categories }) { - console.log(products); return /* html */ ` ${Search(categories, isLoading)} @@ -55,7 +128,8 @@ export default function Home({ products, pagination, isLoading, categories }) {
${state.isLoading ? Loading({ type: "products" }) : ProductList(products, pagination)} -
-
- `; +
+
+
+ `; } From f1f700affa9459fe7b633788154a7f6eb23021cd Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 02:26:39 +0900 Subject: [PATCH 17/57] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=ED=81=B4=EB=A6=AD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=A5=EB=B0=94=EA=B5=AC=EB=8B=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductCard.js에 상품 클릭 시 상세 페이지로 이동하는 기능 추가 - 장바구니 추가 버튼 클릭 시 콘솔 로그 출력 기능 추가 - Home.js에서 ProductCard.mount() 호출하여 이벤트 바인딩 --- src/components/ProductCard.js | 20 +++++++++++++++++++- src/pages/Home.js | 4 ++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/components/ProductCard.js b/src/components/ProductCard.js index f2b7f801a..bc845ffde 100644 --- a/src/components/ProductCard.js +++ b/src/components/ProductCard.js @@ -1,3 +1,21 @@ +import useNavigate from "../core/useNavigate"; + +const navigate = useNavigate(); + +ProductCard.mount = () => { + const items = document.querySelectorAll(".product-card"); + items.forEach((item) => { + const productId = item.getAttribute("data-product-id"); + item.querySelector("img").addEventListener("click", () => { + navigate.push({}, `/product/${productId}`); + }); + + item.querySelector(".add-to-cart-btn").addEventListener("click", () => { + console.log("장바구니 추가"); + }); + }); +}; + export default function ProductCard(product) { return /* html */ `
PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
diff --git a/src/pages/Home.js b/src/pages/Home.js index 61287c0a4..3166458cf 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -1,5 +1,6 @@ import { getCategories, getProducts } from "../api/productApi"; import Loading from "../components/Loading"; +import ProductCard from "../components/ProductCard"; import ProductList from "../components/ProductList"; import Search from "../components/Search"; import useNavigate from "../core/useNavigate"; @@ -72,6 +73,7 @@ const loadMoreProductsCallback = () => { ); Search.mount(); + ProductCard.mount(); setTimeout(() => { const trigger = document.getElementById("scroll-trigger"); @@ -97,6 +99,7 @@ Home.mount = async () => { ); Search.mount(); + ProductCard.mount(); store.watch(async (newValue) => { console.log("watch"); @@ -114,6 +117,7 @@ Home.mount = async () => { Home({ products: state.products, pagination: state.pagination, isLoading: state.isLoading, categories }), ); Search.mount(); + ProductCard.mount(); }, "params"); const scrollTrigger = document.getElementById("scroll-trigger"); From 63e6bbc90dc7d431466dbf2591d1bfaaba0930d2 Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 02:45:06 +0900 Subject: [PATCH 18/57] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EB=B0=8F=20URL=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상품 상세 페이지에서 로딩 상태 관리 기능 추가 - URL 변경 시 뷰를 리렌더링하는 이벤트 리스너 추가 - Loading 컴포넌트의 스타일 수정으로 화면 최적화 --- src/components/Loading.js | 2 +- src/core/useNavigate.js | 5 +- src/main.js | 6 ++ src/pages/Product.js | 161 +++++++++++++++++++++++++++++++++++++- 4 files changed, 170 insertions(+), 4 deletions(-) diff --git a/src/components/Loading.js b/src/components/Loading.js index d7e47f032..308d05b53 100644 --- a/src/components/Loading.js +++ b/src/components/Loading.js @@ -63,7 +63,7 @@ const getLoadingTemplate = (type) => { case "product": return /* html */ ` -
+

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

diff --git a/src/core/useNavigate.js b/src/core/useNavigate.js index b40a8f99a..aab1a1a2c 100644 --- a/src/core/useNavigate.js +++ b/src/core/useNavigate.js @@ -1,15 +1,18 @@ const useNavigate = () => { // 전 URL과 현재 URL을 비교하여 커스텀 이벤트 생성 let prevUrl = location.pathname; + let prevSearchParams = location.search; const emitUrlChangeEvent = () => { const currentUrl = location.pathname; - if (prevUrl !== currentUrl) { + const currentSearchParams = location.search; + if (prevUrl !== currentUrl || prevSearchParams !== currentSearchParams) { const event = new CustomEvent("urlChange", { detail: { prevUrl, currentUrl, isUrlChange: prevUrl !== currentUrl, + isSearchChange: prevSearchParams !== currentSearchParams, }, }); diff --git a/src/main.js b/src/main.js index 5104045b5..0eeef48a9 100644 --- a/src/main.js +++ b/src/main.js @@ -19,6 +19,12 @@ function main() { // Layout 컴포넌트 마운트 Layout.mount?.(); + + window.addEventListener("urlChange", (event) => { + if (event.detail.isUrlChange) { + render.view(); + } + }); } // 애플리케이션 시작 diff --git a/src/pages/Product.js b/src/pages/Product.js index 2026fe729..665b26a12 100644 --- a/src/pages/Product.js +++ b/src/pages/Product.js @@ -1,5 +1,162 @@ -export default function Product() { +import { getProduct } from "../api/productApi"; +import Loading from "../components/Loading"; +import useNavigate from "../core/useNavigate"; +import useRender from "../core/useRender"; + +const navigate = useNavigate(); + +const state = { + isLoading: true, +}; + +const fetchProduct = async (productId) => { + state.isLoading = true; + const data = await getProduct(productId); + state.isLoading = false; + return data; +}; + +Product.mount = async () => { + const render = useRender(); + const productId = navigate.getCurrentUrl().match(/\d+/)[0]; + const data = await fetchProduct(productId); + console.log(data, "data.."); + render.draw("main", Product({ data })); +}; + +export default function Product({ data }) { return /* html */ ` -

+ ${ + state.isLoading + ? Loading({ type: "product" }) + : /* html */ + ` + + ${data} + + +
+ +
+
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+

+

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

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

관련 상품

+

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

+
+
+
+ + +
+
+
+ ` + } `; } From ae35e2b2c58872d65f83b05a0dcb79a78d6da9b1 Mon Sep 17 00:00:00 2001 From: junilhwang Date: Thu, 10 Jul 2025 18:42:17 +0900 Subject: [PATCH 19/57] =?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 60a46821c22fe80702798d8519cb873c669e9e43 Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 18:52:06 +0900 Subject: [PATCH 20/57] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상품 목록에서 pagination.total과 products를 안전하게 접근하도록 수정 - 상품 상세 페이지에서 관련 상품을 가져오는 기능 추가 - 상품 정보 및 이미지 동적으로 렌더링하도록 수정 --- src/components/ProductList.js | 4 +- src/pages/Product.js | 120 ++++++++++++++++++++-------------- 2 files changed, 74 insertions(+), 50 deletions(-) diff --git a/src/components/ProductList.js b/src/components/ProductList.js index 990c7307b..3fb7bb651 100644 --- a/src/components/ProductList.js +++ b/src/components/ProductList.js @@ -6,11 +6,11 @@ export default function ProductList(products, pagination) {
- 총 ${pagination.total}개의 상품 + 총 ${pagination?.total}개의 상품
- ${products.map((product) => ProductCard(product)).join("")} + ${products?.map((product) => ProductCard(product)).join("")}
모든 상품을 확인했습니다 diff --git a/src/pages/Product.js b/src/pages/Product.js index 665b26a12..3a43e7347 100644 --- a/src/pages/Product.js +++ b/src/pages/Product.js @@ -1,4 +1,4 @@ -import { getProduct } from "../api/productApi"; +import { getProduct, getProducts } from "../api/productApi"; import Loading from "../components/Loading"; import useNavigate from "../core/useNavigate"; import useRender from "../core/useRender"; @@ -7,46 +7,76 @@ const navigate = useNavigate(); const state = { isLoading: true, + product: {}, + relatedProducts: [], }; -const fetchProduct = async (productId) => { - state.isLoading = true; - const data = await getProduct(productId); - state.isLoading = false; - return data; +const methods = { + fetchProduct: async (productId) => { + state.isLoading = true; + state.product = await getProduct(productId); + state.isLoading = false; + }, + fetchRelatedProducts: async (category1, category2) => { + const products = await getProducts({ category1, category2 }); + console.log(products); + state.relatedProducts = products.products.filter((product) => product.productId !== state.product.productId); + }, + + goToProductList: () => navigate.push({}, "/"), + + handleAddCard: (productId) => { + console.log("test", productId); + }, }; Product.mount = async () => { const render = useRender(); const productId = navigate.getCurrentUrl().match(/\d+/)[0]; - const data = await fetchProduct(productId); - console.log(data, "data.."); - render.draw("main", Product({ data })); + await methods.fetchProduct(productId); + // console.log(state.product.category1, "state.product.category1"); + await methods.fetchRelatedProducts(state.product.category1, state.product.category); + // const test = await methods.fetchRelatedProducts(data.category1, data.category2); + // console.log(test, "Test.."); + render.draw("main", Product()); + + const goToProductListBtn = document.querySelector(".go-to-product-list"); + goToProductListBtn.addEventListener("click", () => { + methods.goToProductList(); + }); + + const cartBtn = document.getElementById("add-to-cart-btn"); + cartBtn.addEventListener("click", () => { + methods.handleAddCard(productId); + }); + + // .go-to-product-list ->상품 목록 돌아가기 + // #add-to-cart-btn -> 장바구니 담기 + // .related-product-card 관련 상품 }; -export default function Product({ data }) { +export default function Product() { return /* html */ ` ${ - state.isLoading + state.isLoading && state.product ? Loading({ type: "product" }) : /* html */ ` - ${data} @@ -55,44 +85,38 @@ export default function Product({ data }) {
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 + ${state.product.title}

-

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

+

${state.product.title}

- - - - - - - - - - - - - - - + ${Array.from({ length: 5 }, (_, star) => { + const activeClass = star < state.product.rating ? "text-yellow-400" : "text-gray-300"; + return /* html */ ` + + + + `; + }).join("")} +
- 4.0 (749개 리뷰) + ${state.product.rating}.0 (${state.product.reviewCount}개 리뷰)
- 220원 + ${state.product.lprice}원
- 재고 107개 + 재고 ${state.product.stock}개
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다. + ${state.product.description}
@@ -139,20 +163,20 @@ export default function Product({ data }) {
-
From c0b5e44ba2956b3b68293bdbb7371ed25551c62a Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 22:23:02 +0900 Subject: [PATCH 21/57] =?UTF-8?q?feat:=20useNavigate=20=ED=9B=85=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앞으로/뒤로가기 시 데이터가 누적돼서 호출 - useNavigate 훅을 싱글톤으로 수정 --- src/core/useNavigate.js | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/core/useNavigate.js b/src/core/useNavigate.js index aab1a1a2c..0fc132697 100644 --- a/src/core/useNavigate.js +++ b/src/core/useNavigate.js @@ -1,4 +1,4 @@ -const useNavigate = () => { +const useNavigate = (() => { // 전 URL과 현재 URL을 비교하여 커스텀 이벤트 생성 let prevUrl = location.pathname; let prevSearchParams = location.search; @@ -23,25 +23,26 @@ const useNavigate = () => { window.addEventListener("popstate", emitUrlChangeEvent); - return { - push: (state = {}, url) => { - history.pushState(state, "", url); - emitUrlChangeEvent(); - }, - replace: (state = {}, url) => { - history.replaceState(state, "", url); - emitUrlChangeEvent(); - }, - go: (n) => { - history.go(n); - }, - back: () => { - history.back(); - }, - getCurrentUrl: () => { - return window.location.pathname; - }, + const push = (state = {}, url) => { + history.pushState(state, "", url); + emitUrlChangeEvent(); }; -}; + + const replace = (state = {}, url) => { + history.replaceState(state, "", url); + emitUrlChangeEvent(); + }; + const go = (n) => { + history.go(n); + }; + const back = () => { + history.back(); + }; + const getCurrentUrl = () => { + return window.location.pathname; + }; + + return () => ({ push, replace, go, back, getCurrentUrl }); +})(); export default useNavigate; From c457811df28d11d9ce67fec219ce732a381f6208 Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 22:28:38 +0900 Subject: [PATCH 22/57] =?UTF-8?q?feat:=20Home=20=EB=B0=8F=20Search=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home.js에서 fetchProducts와 fetchCategories 함수 호출로 카테고리 데이터 관리 방식 변경 - 무한 스크롤 함수 임시 주석 처리 - Search.js에서 불필요한 return 추가 --- src/components/Search.js | 2 + src/pages/Home.js | 120 +++++++++++++++++++-------------------- 2 files changed, 61 insertions(+), 61 deletions(-) diff --git a/src/components/Search.js b/src/components/Search.js index c09cf7b24..d47ded8a1 100644 --- a/src/components/Search.js +++ b/src/components/Search.js @@ -44,6 +44,8 @@ Search.mount = () => { bindEvent(el, action, fn); }); + return; + // Home.js에서도 같은 watch를 써야 할 것 같아서.. 중복으로 쓰는 게 의미가 있을지 몰라 주석 // store.watch((newValue) => { // const url = new URL(window.location); diff --git a/src/pages/Home.js b/src/pages/Home.js index 3166458cf..746520e3d 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -13,12 +13,12 @@ const state = { isLoading: true, products: [], pagination: {}, + categories: {}, }; const fetchProducts = async (params = {}) => { state.isLoading = true; const productData = await getProducts(params); - const categoriesData = await getCategories(); state.isLoading = false; // 무한 스크롤 방식 구현으로 누적된 product 값 @@ -29,64 +29,66 @@ const fetchProducts = async (params = {}) => { } state.pagination = productData.pagination; - - return { - categories: categoriesData, - }; }; -const loadMoreProducts = (trigger, callback) => { - const io = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - callback(); - } - }); - }, - { - root: null, - rootMargin: "0px", - threshold: 1.0, - }, - ); - - io.observe(trigger); +const fetchCategories = async () => { + const categoriesData = await getCategories(); + state.categories = categoriesData; }; -const loadMoreProductsCallback = () => { - const currentPage = store.get("params")["page"]; - const hasNextPage = state.pagination.hasNext; - if (!hasNextPage) return; - const render = useRender(); - const nextPage = currentPage + 1; - - fetchProducts({ ...store.get("params"), page: nextPage }).then(({ categories }) => { - render.draw( - "main", - Home({ - products: state.products, - pagination: state.pagination, - isLoading: state.isLoading, - categories, - }), - ); - - Search.mount(); - ProductCard.mount(); - - setTimeout(() => { - const trigger = document.getElementById("scroll-trigger"); - if (trigger) { - loadMoreProducts(trigger, loadMoreProductsCallback); - } - }, 200); - }); -}; +// const loadMoreProducts = (trigger, callback) => { +// const io = new IntersectionObserver( +// (entries) => { +// entries.forEach((entry) => { +// if (entry.isIntersecting) { +// callback(); +// } +// }); +// }, +// { +// root: null, +// rootMargin: "0px", +// threshold: 1.0, +// }, +// ); + +// io.observe(trigger); +// }; + +// const loadMoreProductsCallback = () => { +// const currentPage = store.get("params")["page"]; +// const hasNextPage = state.pagination.hasNext; +// if (!hasNextPage) return; +// const render = useRender(); +// const nextPage = currentPage + 1; + +// fetchProducts({ ...store.get("params"), page: nextPage }).then(({ categories }) => { +// render.draw( +// "main", +// Home({ +// products: state.products, +// pagination: state.pagination, +// isLoading: state.isLoading, +// categories, +// }), +// ); + +// Search.mount(); +// ProductCard.mount(); + +// setTimeout(() => { +// const trigger = document.getElementById("scroll-trigger"); +// if (trigger) { +// loadMoreProducts(trigger, loadMoreProductsCallback); +// } +// }, 200); +// }); +// }; Home.mount = async () => { const render = useRender(); - const { categories } = await fetchProducts(); + await fetchProducts(); + await fetchCategories(); render.draw( "main", @@ -94,7 +96,7 @@ Home.mount = async () => { products: state.products, pagination: state.pagination, isLoading: state.isLoading, - categories, + categories: state.categories, }), ); @@ -102,7 +104,6 @@ Home.mount = async () => { ProductCard.mount(); store.watch(async (newValue) => { - console.log("watch"); const url = new URL(window.location); Object.entries(newValue).forEach(([key, value]) => { if (value !== "" && value) { @@ -111,17 +112,14 @@ Home.mount = async () => { }); navigate.push({}, url.toString()); - const { categories } = await fetchProducts(newValue); - render.draw( - "main", - Home({ products: state.products, pagination: state.pagination, isLoading: state.isLoading, categories }), - ); + await fetchProducts(newValue); + render.draw("main", Home({ products: state.products, pagination: state.pagination, isLoading: state.isLoading })); Search.mount(); ProductCard.mount(); }, "params"); - const scrollTrigger = document.getElementById("scroll-trigger"); - loadMoreProducts(scrollTrigger, loadMoreProductsCallback); + // const scrollTrigger = document.getElementById("scroll-trigger"); + // loadMoreProducts(scrollTrigger, loadMoreProductsCallback); }; export default function Home({ products, pagination, isLoading, categories }) { From 1add259d87dc483b22ea3f96b069a15e033a075c Mon Sep 17 00:00:00 2001 From: eveneul Date: Thu, 10 Jul 2025 22:31:42 +0900 Subject: [PATCH 23/57] =?UTF-8?q?feat:=20Search=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=97=90=EC=84=9C=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store에서 limit 및 sort 파라미터를 가져와서 선택 요소에 설정 - 중복된 주석 코드 제거 --- src/components/Search.js | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/components/Search.js b/src/components/Search.js index d47ded8a1..d6681773e 100644 --- a/src/components/Search.js +++ b/src/components/Search.js @@ -39,23 +39,18 @@ const bindEvent = (el, action, fn) => { }; Search.mount = () => { + const limit = store.get("params")["limit"]; + const sort = store.get("params")["sort"]; + + document.querySelector("#limit-select").value = limit; + document.querySelector("#sort-select").value = sort; + events.forEach((e) => { const { el, action, fn } = e; bindEvent(el, action, fn); }); return; - - // Home.js에서도 같은 watch를 써야 할 것 같아서.. 중복으로 쓰는 게 의미가 있을지 몰라 주석 - // store.watch((newValue) => { - // const url = new URL(window.location); - // Object.entries(newValue).forEach(([key, value]) => { - // if (value !== "" && value) { - // url.searchParams.set(key, value); - // } - // }); - // navigate.push({}, url.toString()); - // }, "params"); }; export default function Search(categories = {}, isLoading = true) { @@ -117,7 +112,7 @@ export default function Search(categories = {}, isLoading = true) { - + diff --git a/src/pages/Home.js b/src/pages/Home.js index 746520e3d..75e2e9141 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -113,7 +113,16 @@ Home.mount = async () => { navigate.push({}, url.toString()); await fetchProducts(newValue); - render.draw("main", Home({ products: state.products, pagination: state.pagination, isLoading: state.isLoading })); + await fetchCategories(); + render.draw( + "main", + Home({ + products: state.products, + pagination: state.pagination, + isLoading: state.isLoading, + categories: state.categories, + }), + ); Search.mount(); ProductCard.mount(); }, "params"); From 1142de2eb1365f517363815008d635121a9d27ce Mon Sep 17 00:00:00 2001 From: eveneul Date: Fri, 11 Jul 2025 01:12:29 +0900 Subject: [PATCH 25/57] =?UTF-8?q?feat:=20main.js=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useNavigate 및 useStore 훅을 main.js에서 직접 가져와 사용하도록 변경 - render 초기화 및 Layout 마운트 방식 수정 - URL 변경 시 뷰 리렌더링 로직 개선 - ProductCard 및 Product 컴포넌트에서 navigate.push() 대신 window.history.pushState() 사용으로 변경 --- src/components/Layout.js | 4 +-- src/components/ProductCard.js | 7 +++-- src/components/Search.js | 6 +---- src/core/useNavigate.js | 48 +++++------------------------------ src/core/useRender.js | 12 +++++---- src/main.js | 16 +++++++----- src/pages/Home.js | 8 +++--- src/pages/Product.js | 39 ++++++++++++++-------------- 8 files changed, 51 insertions(+), 89 deletions(-) diff --git a/src/components/Layout.js b/src/components/Layout.js index 2c2d6ce66..d226ca59d 100644 --- a/src/components/Layout.js +++ b/src/components/Layout.js @@ -1,9 +1,9 @@ -import useRender from "../core/useRender"; +import { render } from "../main"; import Footer from "./footer"; import Header from "./Header"; Layout.mount = () => { - const render = useRender(); + console.log("test"); render.draw("#header", Header()); render.draw("#footer", Footer()); }; diff --git a/src/components/ProductCard.js b/src/components/ProductCard.js index bc845ffde..1635f0db3 100644 --- a/src/components/ProductCard.js +++ b/src/components/ProductCard.js @@ -1,13 +1,12 @@ -import useNavigate from "../core/useNavigate"; - -const navigate = useNavigate(); +import { render } from "../main"; ProductCard.mount = () => { const items = document.querySelectorAll(".product-card"); items.forEach((item) => { const productId = item.getAttribute("data-product-id"); item.querySelector("img").addEventListener("click", () => { - navigate.push({}, `/product/${productId}`); + window.history.pushState({}, "", `/product/${productId}`); + render.view(); }); item.querySelector(".add-to-cart-btn").addEventListener("click", () => { diff --git a/src/components/Search.js b/src/components/Search.js index 9cc40e242..c97238c18 100644 --- a/src/components/Search.js +++ b/src/components/Search.js @@ -1,10 +1,6 @@ -// import useNavigate from "../core/useNavigate"; -import useStore from "../core/useStore"; +import { store } from "../main"; import Loading from "./Loading"; -const store = useStore(); -// const navigate = useNavigate(); - const events = [ { el: window, diff --git a/src/core/useNavigate.js b/src/core/useNavigate.js index 0fc132697..d32c04906 100644 --- a/src/core/useNavigate.js +++ b/src/core/useNavigate.js @@ -1,48 +1,12 @@ -const useNavigate = (() => { - // 전 URL과 현재 URL을 비교하여 커스텀 이벤트 생성 - let prevUrl = location.pathname; - let prevSearchParams = location.search; - - const emitUrlChangeEvent = () => { - const currentUrl = location.pathname; - const currentSearchParams = location.search; - if (prevUrl !== currentUrl || prevSearchParams !== currentSearchParams) { - const event = new CustomEvent("urlChange", { - detail: { - prevUrl, - currentUrl, - isUrlChange: prevUrl !== currentUrl, - isSearchChange: prevSearchParams !== currentSearchParams, - }, - }); - - window.dispatchEvent(event); - prevUrl = currentUrl; - } - }; - - window.addEventListener("popstate", emitUrlChangeEvent); +import { render } from "../main"; +const useNavigate = () => { const push = (state = {}, url) => { - history.pushState(state, "", url); - emitUrlChangeEvent(); - }; - - const replace = (state = {}, url) => { - history.replaceState(state, "", url); - emitUrlChangeEvent(); - }; - const go = (n) => { - history.go(n); - }; - const back = () => { - history.back(); - }; - const getCurrentUrl = () => { - return window.location.pathname; + window.history.pushState(state, "", url); + render.view(); }; - return () => ({ push, replace, go, back, getCurrentUrl }); -})(); + return { push }; +}; export default useNavigate; diff --git a/src/core/useRender.js b/src/core/useRender.js index 1dcc2af85..b85223a6a 100644 --- a/src/core/useRender.js +++ b/src/core/useRender.js @@ -1,16 +1,18 @@ +import Layout from "../components/Layout"; import routes from "../routes"; -import useNavigate from "./useNavigate"; const useRender = () => { - const navigate = useNavigate(); - + const init = () => { + document.querySelector("#root").innerHTML = Layout(); + }; const draw = (tag, html) => { + // document.querySelector("#root").innerHTML = Layout(); document.querySelector(tag).innerHTML = html; }; const view = async () => { for (const route of routes) { - const match = navigate.getCurrentUrl().match(route.path); + const match = window.location.pathname.match(route.path); if (!match) continue; const Page = route.component; Page.init?.(match?.[1]); @@ -20,7 +22,7 @@ const useRender = () => { } }; - return { draw, view }; + return { init, draw, view }; }; export default useRender; diff --git a/src/main.js b/src/main.js index 0eeef48a9..c6cdad21e 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,7 @@ import Layout from "./components/Layout.js"; +import useNavigate from "./core/useNavigate.js"; import useRender from "./core/useRender.js"; +import useStore from "./core/useStore.js"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -8,11 +10,13 @@ const enableMocking = () => }), ); -const render = useRender(); +export const render = useRender(); +export const navigate = useNavigate(); +export const store = useStore(); function main() { // #root Element에 Layout HTML 삽입 - render.draw("#root", Layout()); + render.init(); // Page에 init, mount 실행 render.view(); @@ -20,10 +24,10 @@ function main() { // Layout 컴포넌트 마운트 Layout.mount?.(); - window.addEventListener("urlChange", (event) => { - if (event.detail.isUrlChange) { - render.view(); - } + window.addEventListener("popstate", () => { + render.init(); + Layout.mount?.(); + render.view(); }); } diff --git a/src/pages/Home.js b/src/pages/Home.js index 75e2e9141..e01748fff 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -3,12 +3,9 @@ import Loading from "../components/Loading"; import ProductCard from "../components/ProductCard"; import ProductList from "../components/ProductList"; import Search from "../components/Search"; -import useNavigate from "../core/useNavigate"; import useRender from "../core/useRender"; -import useStore from "../core/useStore"; +import { store } from "../main"; -const store = useStore(); -const navigate = useNavigate(); const state = { isLoading: true, products: [], @@ -104,13 +101,14 @@ Home.mount = async () => { ProductCard.mount(); store.watch(async (newValue) => { + console.log("watch"); const url = new URL(window.location); Object.entries(newValue).forEach(([key, value]) => { if (value !== "" && value) { url.searchParams.set(key, value); } }); - navigate.push({}, url.toString()); + window.history.pushState({}, "", url.toString()); await fetchProducts(newValue); await fetchCategories(); diff --git a/src/pages/Product.js b/src/pages/Product.js index 3a43e7347..562a5901a 100644 --- a/src/pages/Product.js +++ b/src/pages/Product.js @@ -1,9 +1,6 @@ import { getProduct, getProducts } from "../api/productApi"; import Loading from "../components/Loading"; -import useNavigate from "../core/useNavigate"; -import useRender from "../core/useRender"; - -const navigate = useNavigate(); +import { render } from "../main.js"; const state = { isLoading: true, @@ -18,12 +15,13 @@ const methods = { state.isLoading = false; }, fetchRelatedProducts: async (category1, category2) => { + state.isLoading = true; const products = await getProducts({ category1, category2 }); - console.log(products); state.relatedProducts = products.products.filter((product) => product.productId !== state.product.productId); + state.isLoading = false; }, - goToProductList: () => navigate.push({}, "/"), + goToProductList: () => window.history.pushState.push({}, "", "/"), handleAddCard: (productId) => { console.log("test", productId); @@ -31,24 +29,25 @@ const methods = { }; Product.mount = async () => { - const render = useRender(); - const productId = navigate.getCurrentUrl().match(/\d+/)[0]; + state.isLoading = true; + const productId = window.location.pathname.match(/\d+/)[0]; await methods.fetchProduct(productId); - // console.log(state.product.category1, "state.product.category1"); await methods.fetchRelatedProducts(state.product.category1, state.product.category); - // const test = await methods.fetchRelatedProducts(data.category1, data.category2); - // console.log(test, "Test.."); + render.draw("main", Product()); + // // console.log(state.product.category1, "state.product.category1"); + // // const test = await methods.fetchRelatedProducts(data.category1, data.category2); + // // console.log(test, "Test.."); - const goToProductListBtn = document.querySelector(".go-to-product-list"); - goToProductListBtn.addEventListener("click", () => { - methods.goToProductList(); - }); + // const goToProductListBtn = document.querySelector(".go-to-product-list"); + // goToProductListBtn.addEventListener("click", () => { + // methods.goToProductList(); + // }); - const cartBtn = document.getElementById("add-to-cart-btn"); - cartBtn.addEventListener("click", () => { - methods.handleAddCard(productId); - }); + // const cartBtn = document.getElementById("add-to-cart-btn"); + // cartBtn.addEventListener("click", () => { + // methods.handleAddCard(productId); + // }); // .go-to-product-list ->상품 목록 돌아가기 // #add-to-cart-btn -> 장바구니 담기 @@ -58,7 +57,7 @@ Product.mount = async () => { export default function Product() { return /* html */ ` ${ - state.isLoading && state.product + state.isLoading ? Loading({ type: "product" }) : /* html */ ` From 31a1e4871cdd1e59f6a2b656af52ff665fd6f1cc Mon Sep 17 00:00:00 2001 From: eveneul Date: Fri, 11 Jul 2025 01:40:53 +0900 Subject: [PATCH 26/57] =?UTF-8?q?feat:=20Home=20=EB=B0=8F=20Product=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home.js에서 초기 로딩 상태 설정 및 fetchCategories 함수에서 로딩 상태 업데이트 추가 - Product.js에서 fetchProduct 및 fetchRelatedProducts 함수에서 로딩 상태 관리 제거 후 mount 메서드에서 로딩 상태 업데이트 추가 - Product.init 메서드 추가로 초기 로딩 상태 설정 방식 개선 --- src/pages/Home.js | 10 ++++++---- src/pages/Product.js | 22 +++++++++------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/pages/Home.js b/src/pages/Home.js index e01748fff..2640a5176 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -3,8 +3,7 @@ import Loading from "../components/Loading"; import ProductCard from "../components/ProductCard"; import ProductList from "../components/ProductList"; import Search from "../components/Search"; -import useRender from "../core/useRender"; -import { store } from "../main"; +import { render, store } from "../main"; const state = { isLoading: true, @@ -14,7 +13,6 @@ const state = { }; const fetchProducts = async (params = {}) => { - state.isLoading = true; const productData = await getProducts(params); state.isLoading = false; @@ -31,6 +29,7 @@ const fetchProducts = async (params = {}) => { const fetchCategories = async () => { const categoriesData = await getCategories(); state.categories = categoriesData; + state.isLoading = false; }; // const loadMoreProducts = (trigger, callback) => { @@ -82,8 +81,11 @@ const fetchCategories = async () => { // }); // }; +Home.init = () => { + state.isLoading = true; +}; + Home.mount = async () => { - const render = useRender(); await fetchProducts(); await fetchCategories(); diff --git a/src/pages/Product.js b/src/pages/Product.js index 562a5901a..cea122f6c 100644 --- a/src/pages/Product.js +++ b/src/pages/Product.js @@ -10,15 +10,11 @@ const state = { const methods = { fetchProduct: async (productId) => { - state.isLoading = true; state.product = await getProduct(productId); - state.isLoading = false; }, fetchRelatedProducts: async (category1, category2) => { - state.isLoading = true; const products = await getProducts({ category1, category2 }); state.relatedProducts = products.products.filter((product) => product.productId !== state.product.productId); - state.isLoading = false; }, goToProductList: () => window.history.pushState.push({}, "", "/"), @@ -28,21 +24,21 @@ const methods = { }, }; -Product.mount = async () => { +Product.init = () => { state.isLoading = true; +}; + +Product.mount = async () => { const productId = window.location.pathname.match(/\d+/)[0]; await methods.fetchProduct(productId); await methods.fetchRelatedProducts(state.product.category1, state.product.category); - + state.isLoading = false; render.draw("main", Product()); - // // console.log(state.product.category1, "state.product.category1"); - // // const test = await methods.fetchRelatedProducts(data.category1, data.category2); - // // console.log(test, "Test.."); - // const goToProductListBtn = document.querySelector(".go-to-product-list"); - // goToProductListBtn.addEventListener("click", () => { - // methods.goToProductList(); - // }); + const goToProductListBtn = document.querySelector(".go-to-product-list"); + goToProductListBtn.addEventListener("click", () => { + methods.goToProductList(); + }); // const cartBtn = document.getElementById("add-to-cart-btn"); // cartBtn.addEventListener("click", () => { From 73355599d4085ba8f30ee544efd91753803a41ea Mon Sep 17 00:00:00 2001 From: eveneul Date: Fri, 11 Jul 2025 02:45:38 +0900 Subject: [PATCH 27/57] =?UTF-8?q?feat:=20Header=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=83=81=ED=92=88=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?Layout=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header.js에서 상품 상세 페이지 여부에 따라 다른 UI 렌더링 로직 추가 - Layout.js에서 Header 초기화 및 렌더링 방식 수정 - ProductCard.js에서 상품 이미지 클릭 시 Header 초기화 및 렌더링 추가 - useRender.js에서 불필요한 주석 제거 --- src/components/Header.js | 30 +++++++++++++++++++++++++++++- src/components/Layout.js | 12 ++++++------ src/components/ProductCard.js | 7 +++++-- src/core/useRender.js | 1 - 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/components/Header.js b/src/components/Header.js index eaf0f3306..4fe76c989 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -1,10 +1,38 @@ +const state = { + isProductDetailPage: false, +}; + +Header.init = () => { + const pathname = window.location.pathname; + state.isProductDetailPage = pathname.includes("product"); +}; + +Header.mount = async () => {}; + export default function Header() { return /* html */ `
+ ${ + !state.isProductDetailPage + ? /* html */ ` +

- 쇼핑몰 + 쇼핑몰

+ ` + : /* html */ ` +
+ +

상품 상세

+
+ ` + } +
+
+ `; + case "deleteCart": + return /* html */ ` +
+
+ + + +
+

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

+ +
+ `; + case "error": + return /* html */ ` +
+
+ + + +
+

오류가 발생했습니다.

+ +
+ `; + default: + return /* html */ ` +
+
+ + + +
+

오류가 발생했습니다.

+ +
+ `; + } +}; + +const handleDeleteToast = () => { + const toast = document.querySelector("#toast"); + if (!toast) return; + toast.remove(); +}; + +Toast.init = () => { + setTimeout(() => { + handleDeleteToast(); + }, 3000); +}; + +Toast.mount = (type) => { + Toast.init(); + document.body.insertAdjacentHTML("beforeend", Toast(type)); + const toastCloseBtn = document.getElementById("toast-close-btn"); + toastCloseBtn.addEventListener("click", handleDeleteToast); +}; + +export default function Toast(type) { + return `
${getToastTemplate(type)}
`; +} From a22d5128de737c8d1c5efe1adb96c585275f6f4e Mon Sep 17 00:00:00 2001 From: eveneul Date: Fri, 11 Jul 2025 15:16:52 +0900 Subject: [PATCH 33/57] =?UTF-8?q?feat:=20ProductList=20=EB=B0=8F=20Search?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductList 컴포넌트에서 props 구조 분해 할당을 적용하여 가독성 향상 - Search 컴포넌트에서 이벤트 핸들링 방식을 개선하고, 검색 입력값을 상태에서 가져오도록 수정 - Home 컴포넌트에서 fetchMoreProducts 함수를 비동기로 변경하고, 스크롤 이벤트 핸들러를 추가하여 무한 스크롤 기능 개선 --- src/components/ProductList.js | 4 +- src/components/Search.js | 86 ++++++++++---------------- src/pages/Home.js | 110 ++++++++++++++++++++++++---------- 3 files changed, 115 insertions(+), 85 deletions(-) diff --git a/src/components/ProductList.js b/src/components/ProductList.js index ac76f5966..fe23daa9f 100644 --- a/src/components/ProductList.js +++ b/src/components/ProductList.js @@ -1,8 +1,8 @@ import ProductCard from "./ProductCard"; -export default function ProductList(products, pagination) { +export default function ProductList({ products, pagination }) { return /* html */ ` -
+
diff --git a/src/components/Search.js b/src/components/Search.js index 826ad24ac..ba82bab64 100644 --- a/src/components/Search.js +++ b/src/components/Search.js @@ -1,66 +1,46 @@ import { store } from "../main"; import Loading from "./Loading"; -const events = [ - { - el: window, - action: "keyup", - fn: (event) => { - if (event.key !== "Enter") return; - const searchInput = document.querySelector("#search-input"); - store.set("params", { - ...store.get("params"), - search: searchInput.value, - page: 1, - }); - }, - }, - { - el: "#limit-select", - action: "change", - fn: (event) => { - store.set("params", { - ...store.get("params"), - limit: event.target.value, - page: 1, - }); - }, - }, - { - el: "#sort-select", - action: "change", - fn: (event) => { - store.set("params", { - ...store.get("params"), - sort: event.target.value, - page: 1, - }); - }, - }, - {}, -]; - -const bindEvent = (el, action, fn) => { - if (el === window) { - el?.addEventListener(action, fn); - } else { - document.querySelector(el)?.addEventListener(action, fn); - } -}; - Search.mount = () => { + const limitSelect = document.querySelector("#limit-select"); + const sortSelect = document.querySelector("#sort-select"); + const searchInput = document.querySelector("#search-input"); const limit = store.get("params")["limit"]; const sort = store.get("params")["sort"]; + const search = store.get("params")["search"]; - document.querySelector("#limit-select").value = limit; - document.querySelector("#sort-select").value = sort; + limitSelect.value = limit; + sortSelect.value = sort; + searchInput.value = search; - events.forEach((e) => { - const { el, action, fn } = e; - bindEvent(el, action, fn); + const handleKeyup = (event) => { + console.log({ key: event.key, en: event.isComposing }); + if (event.key !== "Enter") return; + store.set("params", { + ...store.get("params"), + search: event.target.value, + page: 1, + }); + }; + + window.addEventListener("keypress", handleKeyup); + + document.querySelector("#limit-select").addEventListener("change", (event) => { + store.set("params", { + ...store.get("params"), + limit: event.target.value, + page: 1, + }); }); - return; + document.querySelector("#sort-select").addEventListener("change", (event) => { + console.log("test"); + store.set("params", { + ...store.get("params"), + sort: event.target.value, + page: 1, + }); + }); }; export default function Search(categories = {}, isLoading = true) { diff --git a/src/pages/Home.js b/src/pages/Home.js index ac5d801c5..edd804020 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -41,29 +41,71 @@ const fetchCategories = async () => { state.isLoading = false; }; -const fetchMoreProducts = (io = null) => { - if (typeof IntersectionObserver === "undefined") return; - const trigger = document.querySelector("#scroll-trigger"); - if (!trigger) return; - if (io) io.disconnect(); - - io = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) { - if (state.pagination && state.pagination.hasNext) { - const currentPage = store.get("params")["page"]; - store.set("params.page", currentPage + 1); - } - } - }, - { - root: null, - rootMargin: "0px", - threshold: 1.0, - }, - ); +// const fetchMoreProducts = async (io = null) => { +// if (typeof IntersectionObserver === "undefined") return; +// const trigger = document.querySelector("#scroll-trigger"); +// if (!trigger) return; +// if (io) io.disconnect(); + +// io = new IntersectionObserver( +// (entries) => { +// if (entries[0].isIntersecting) { +// if (state.pagination && state.pagination.hasNext) { +// const currentPage = store.get("params")["page"]; +// store.set("params.page", currentPage + 1); +// } +// } +// }, +// { +// root: null, +// rootMargin: "0px", +// threshold: 1.0, +// }, +// ); + +// io.observe(trigger); +// }; + +const fetchMoreProductsScroll = () => { + const triggerHeight = 100; + let scrollHandler = null; + + const handleScroll = () => { + if (state.isLoadingMore || !state.pagination?.hasNext) return; + const currentScroll = window.scrollY; + const viewHeight = document.documentElement.clientHeight; + const bodyHeight = document.body.scrollHeight; + if (currentScroll + viewHeight > bodyHeight - triggerHeight) { + state.isLoadingMore = true; + const currentPage = store.get("params")["page"]; + store.set("params", { + ...store.get("params"), + page: currentPage + 1, + }); + } + }; + + if (scrollHandler) { + window.removeEventListener("scroll", handleScroll); + } + + scrollHandler = handleScroll; + window.addEventListener("scroll", scrollHandler); - io.observe(trigger); + return scrollHandler; + + // window.addEventListener("scroll", () => { + // if (currentScroll + viewHeight > bodyHeight - triggerHeight) { + // state.isLoadingMore = true; + + // store.set("params", { + // ...store.get("params"), + // page: store.get("params")["page"] + 1, + // }); + + // return; + // } + // }); }; const renderHome = () => { @@ -84,14 +126,15 @@ Home.init = () => { }; Home.mount = async () => { - let io; + // let io; await fetchProducts(); await fetchCategories(); renderHome(); - fetchMoreProducts(io); + // fetchMoreProducts(io); Search.mount(); ProductCard.mount(); + fetchMoreProductsScroll(); store.watch(async (newValue) => { const url = new URL(window.location); @@ -103,10 +146,17 @@ Home.mount = async () => { window.history.pushState({}, "", url.toString()); await fetchProducts(newValue); - await fetchCategories(); - renderHome(); - fetchMoreProducts(io); - Search.mount(); + state.isLoadingMore = false; + // fetchMoreProducts(io); + render.draw( + "#product-list", + ProductList({ + products: state.products, + pagination: state.pagination, + }), + ); + + // Search.mount(); ProductCard.mount(); }, "params"); }; @@ -116,9 +166,9 @@ export default function Home({ products, pagination, isLoading, categories, isLo ${Search(categories, isLoading)}
-
+
- ${state.isLoading ? Loading({ type: "products" }) : ProductList(products, pagination)} + ${state.isLoading ? Loading({ type: "products" }) : ProductList({ products, pagination })} ${ isLoadingMore ? Loading({ type: "products" }) From 8c5e259f4d2427c989480edec632f209cfa36b36 Mon Sep 17 00:00:00 2001 From: eveneul Date: Fri, 11 Jul 2025 17:57:15 +0900 Subject: [PATCH 34/57] =?UTF-8?q?feat:=20Product=20=EB=B0=8F=20Search=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Search.js에서 불필요한 console.log 제거 - useNavigate.js에서 Header 컴포넌트 추가 및 렌더링 로직 개선 - Product.js에서 장바구니 추가 및 수량 조절 기능 구현 --- src/components/Search.js | 2 - src/core/useNavigate.js | 1 + src/pages/Product.js | 89 ++++++++++++++++++++++++++++++++++------ 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/components/Search.js b/src/components/Search.js index ba82bab64..3d5fd6033 100644 --- a/src/components/Search.js +++ b/src/components/Search.js @@ -14,7 +14,6 @@ Search.mount = () => { searchInput.value = search; const handleKeyup = (event) => { - console.log({ key: event.key, en: event.isComposing }); if (event.key !== "Enter") return; store.set("params", { ...store.get("params"), @@ -34,7 +33,6 @@ Search.mount = () => { }); document.querySelector("#sort-select").addEventListener("change", (event) => { - console.log("test"); store.set("params", { ...store.get("params"), sort: event.target.value, diff --git a/src/core/useNavigate.js b/src/core/useNavigate.js index d32c04906..3146b7be8 100644 --- a/src/core/useNavigate.js +++ b/src/core/useNavigate.js @@ -3,6 +3,7 @@ import { render } from "../main"; const useNavigate = () => { const push = (state = {}, url) => { window.history.pushState(state, "", url); + render.view(); }; diff --git a/src/pages/Product.js b/src/pages/Product.js index cea122f6c..b32d89694 100644 --- a/src/pages/Product.js +++ b/src/pages/Product.js @@ -1,6 +1,8 @@ import { getProduct, getProducts } from "../api/productApi"; +import Header from "../components/Header.js"; import Loading from "../components/Loading"; -import { render } from "../main.js"; +import { handleAddCart } from "../js/cart.js"; +import { navigate, render } from "../main.js"; const state = { isLoading: true, @@ -17,22 +19,54 @@ const methods = { state.relatedProducts = products.products.filter((product) => product.productId !== state.product.productId); }, - goToProductList: () => window.history.pushState.push({}, "", "/"), + goToRelatedProducts: async (productId) => { + Product.init(); + navigate.push({}, `/product/${productId}`); + // Product.mount(); + }, + + goToProductList: async () => { + navigate.push({}, "/"); + Header.init(); + render.draw("header", Header()); + Header.mount(); + await render.view(); + }, - handleAddCard: (productId) => { - console.log("test", productId); + handleUpQuantity: () => { + const input = document.querySelector("#quantity-input"); + input.value++; + }, + + handleDownQuantity: () => { + const input = document.querySelector("#quantity-input"); + if (input.value === "1") return; + input.value--; + }, + + handleAddCard: (quantity) => { + const product = { + ...state.product, + quantity, + }; + handleAddCart(product); + // Toast.mount("addCart"); }, }; -Product.init = () => { +Product.init = async () => { state.isLoading = true; + Header.init(); + render.draw("header", Header()); }; Product.mount = async () => { const productId = window.location.pathname.match(/\d+/)[0]; + render.draw("main", Product()); await methods.fetchProduct(productId); await methods.fetchRelatedProducts(state.product.category1, state.product.category); state.isLoading = false; + render.draw("main", Product()); const goToProductListBtn = document.querySelector(".go-to-product-list"); @@ -40,14 +74,43 @@ Product.mount = async () => { methods.goToProductList(); }); - // const cartBtn = document.getElementById("add-to-cart-btn"); - // cartBtn.addEventListener("click", () => { - // methods.handleAddCard(productId); - // }); + const homeLink = document.querySelector("a"); + homeLink.addEventListener("click", (event) => { + event.preventDefault(); + methods.goToProductList(); + }); + + const quantityInput = document.querySelector("#quantity-input"); + const quantityIncrease = document.querySelector("#quantity-increase"); + const quantityDecrease = document.querySelector("#quantity-decrease"); + const cartBtn = document.getElementById("add-to-cart-btn"); + cartBtn.addEventListener("click", () => { + methods.handleAddCard(quantityInput.value); + }); + + quantityInput.addEventListener("change", (event) => { + if (event.target.value < 1) event.target.value = 1; + }); - // .go-to-product-list ->상품 목록 돌아가기 - // #add-to-cart-btn -> 장바구니 담기 - // .related-product-card 관련 상품 + quantityIncrease.addEventListener("click", methods.handleUpQuantity); + quantityDecrease.addEventListener("click", methods.handleDownQuantity); + + const relatedProductList = document.querySelectorAll(".related-product-card"); + relatedProductList.forEach((product) => { + const productId = product.getAttribute("data-product-id"); + product.addEventListener("click", () => { + methods.goToRelatedProducts(productId); + }); + }); + + // const breadcrumbBtn = document.querySelectorAll(".breadcrumb-link"); + // breadcrumbBtn.forEach((btn) => { + // btn.addEventListener("click", (event) => { + // // if (btn.getAttribute("data-category2")) { + // // history.pushState({}, "", `/?category1=${}`) + // // } + // }); + // }); }; export default function Product() { @@ -60,7 +123,7 @@ export default function Product() {