From 79c2feddf98adf5e5363ca1a0124246f7110ccbb Mon Sep 17 00:00:00 2001 From: junilhwang Date: Fri, 26 Sep 2025 10:20:46 +0900 Subject: [PATCH 01/43] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 269 +++- .github/workflows/ci.yml | 24 +- .gitignore | 2 + .prettierrc | 3 +- README.md | 115 -- cart-modal.html | 320 +++++ e2e/E2EHelpers.js | 28 + e2e/app.spec.js | 0 e2e/e2e.advanced.spec.js | 390 ++++++ e2e/e2e.basic.spec.js | 423 ++++++ index.html | 41 +- package.json | 18 +- playwright.config.js | 2 +- pnpm-lock.yaml | 563 ++++++-- requirement.md | 190 +++ src/api/productApi.js | 30 + src/main.js | 1165 +++++++++++++++- src/mocks/browser.js | 2 +- src/mocks/handlers.js | 48 +- src/{ => mocks}/items.json | 2246 +++++++++++++++++++++++++++++- src/setupTests.js | 15 + src/styles.css | 157 +++ vite.config.js | 5 + 23 files changed, 5688 insertions(+), 368 deletions(-) delete mode 100644 README.md create mode 100644 cart-modal.html create mode 100644 e2e/E2EHelpers.js delete mode 100644 e2e/app.spec.js create mode 100644 e2e/e2e.advanced.spec.js create mode 100644 e2e/e2e.basic.spec.js create mode 100644 requirement.md create mode 100644 src/api/productApi.js rename src/{ => mocks}/items.json (59%) create mode 100644 src/styles.css diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f36c3c4b..c7ec8ab6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,92 +4,226 @@ - ### 기본과제 -#### 1) 라우팅 구현: -- [ ] History API를 사용하여 SPA 라우터 구현 - - [ ] '/' (홈 페이지) - - [ ] '/login' (로그인 페이지) - - [ ] '/profile' (프로필 페이지) -- [ ] 각 라우트에 해당하는 컴포넌트 렌더링 함수 작성 -- [ ] 네비게이션 이벤트 처리 (링크 클릭 시 페이지 전환) -- [ ] 주소가 변경되어도 새로고침이 발생하지 않아야 한다. - -#### 2) 사용자 관리 기능: -- [ ] LocalStorage를 사용한 간단한 사용자 데이터 관리 - - [ ] 사용자 정보 저장 (이름, 간단한 소개) - - [ ] 로그인 상태 관리 (로그인/로그아웃 토글) -- [ ] 로그인 폼 구현 - - [ ] 사용자 이름 입력 및 검증 - - [ ] 로그인 버튼 클릭 시 LocalStorage에 사용자 정보 저장 -- [ ] 로그아웃 기능 구현 - - [ ] 로그아웃 버튼 클릭 시 LocalStorage에서 사용자 정보 제거 - -#### 3) 프로필 페이지 구현: -- [ ] 현재 로그인한 사용자의 정보 표시 - - [ ] 사용자 이름 - - [ ] 간단한 소개 -- [ ] 프로필 수정 기능 - - [ ] 사용자 소개 텍스트 수정 가능 - - [ ] 수정된 정보 LocalStorage에 저장 - -#### 4) 컴포넌트 기반 구조 설계: -- [ ] 재사용 가능한 컴포넌트 작성 - - [ ] Header 컴포넌트 - - [ ] Footer 컴포넌트 -- [ ] 페이지별 컴포넌트 작성 - - [ ] HomePage 컴포넌트 - - [ ] ProfilePage 컴포넌트 - - [ ] NotFoundPage 컴포넌트 - -#### 5) 상태 관리 초기 구현: -- [ ] 간단한 상태 관리 시스템 설계 - - [ ] 전역 상태 객체 생성 (예: 현재 로그인한 사용자 정보) -- [ ] 상태 변경 함수 구현 - - [ ] 상태 업데이트 시 관련 컴포넌트 리렌더링 - -#### 6) 이벤트 처리 및 DOM 조작: -- [ ] 사용자 입력 처리 (로그인 폼, 프로필 수정 등) -- [ ] 동적 컨텐츠 렌더링 (사용자 정보 표시, 페이지 전환 등) - -#### 7) 라우팅 예외 처리: -- [ ] 잘못된 라우트 접근 시 404 페이지 표시 +#### 상품목록 + +**상품 목록 로딩** + +- [ ] 페이지 접속 시 로딩 상태가 표시된다 +- [ ] 데이터 로드 완료 후 상품 목록이 렌더링된다 +- [ ] 로딩 실패 시 에러 상태가 표시된다 +- [ ] 에러 발생 시 재시도 버튼이 제공된다 + +**상품 목록 조회** + +- [ ] 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 + +**한 페이지에 보여질 상품 수 선택** + +- [ ] 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. +- [ ] 선택 변경 시 즉시 목록에 반영된다 + +**상품 정렬 기능** + +- [ ] 상품을 가격순/인기순으로 오름차순/내림차순 정렬을 할 수 있다. +- [ ] 드롭다운을 통해 정렬 기준을 선택할 수 있다 +- [ ] 정렬 변경 시 즉시 목록에 반영된다 + +**무한 스크롤 페이지네이션** + +- [ ] 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 +- [ ] 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 +- [ ] 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 +- [ ] 홈 페이지에서만 무한 스크롤이 활성화된다 + +**상품을 장바구니에 담기** + +- [ ] 각 상품에 장바구니 추가 버튼이 있다 +- [ ] 버튼 클릭 시 해당 상품이 장바구니에 추가된다 +- [ ] 추가 완료 시 사용자에게 알림이 표시된다 + +**상품 검색** + +- [ ] 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- [ ] 검색 버튼 클릭으로 검색이 수행된다 +- [ ] Enter 키로 검색이 수행된다 +- [ ] 검색어와 일치하는 상품들만 목록에 표시된다 + +**카테고리 선택** + +- [ ] 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- [ ] 선택된 카테고리에 해당하는 상품들만 표시된다 +- [ ] 전체 상품 보기로 돌아갈 수 있다 +- [ ] 2단계 카테고리 구조를 지원한다 (1depth, 2depth) + +**카테고리 네비게이션** + +- [ ] 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- [ ] 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- [ ] "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 + +**현재 상품 수 표시** + +- [ ] 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- [ ] 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 + +#### 장바구니 + +**장바구니 모달** + +- [ ] 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- [ ] X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- [ ] ESC 키로 모달을 닫을 수 있다 +- [ ] 모달에서 장바구니의 모든 기능을 사용할 수 있다 + +**장바구니 수량 조절** + +- [ ] 각 장바구니 상품의 수량을 증가할 수 있다 +- [ ] 각 장바구니 상품의 수량을 감소할 수 있다 +- [ ] 수량 변경 시 총 금액이 실시간으로 업데이트된다 + +**장바구니 삭제** + +- [ ] 각 상품에 삭제 버튼이 배치되어 있다 +- [ ] 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 + +**장바구니 선택 삭제** + +- [ ] 각 상품에 선택을 위한 체크박스가 제공된다 +- [ ] 선택 삭제 버튼이 있다 +- [ ] 체크된 상품들만 일괄 삭제된다 + +**장바구니 전체 선택** + +- [ ] 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- [ ] 전체 선택 시 모든 상품의 체크박스가 선택된다 +- [ ] 전체 해제 시 모든 상품의 체크박스가 해제된다 + +**장바구니 비우기** + +- [ ] 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 + +#### 상품 상세 + +**상품 클릭시 상세 페이지 이동** + +- [ ] 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- [ ] URL이 `/product/{productId}` 형태로 변경된다 +- [ ] 상품의 자세한 정보가 전용 페이지에서 표시된다 + +**상품 상세 페이지 기능** + +- [ ] 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- [ ] 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 + +**상품 상세 - 장바구니 담기** + +- [ ] 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- [ ] 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- [ ] 수량 증가/감소 버튼이 제공된다 + +**관련 상품 기능** + +- [ ] 상품 상세 페이지에서 관련 상품들이 표시된다 +- [ ] 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- [ ] 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- [ ] 현재 보고 있는 상품은 관련 상품에서 제외된다 + +**상품 상세 페이지 내 네비게이션** + +- [ ] 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- [ ] 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- [ ] SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 + +#### 사용자 피드백 시스템 + +**토스트 메시지** + +- [ ] 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- [ ] 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- [ ] 토스트는 3초 후 자동으로 사라진다 +- [ ] 토스트에 닫기 버튼이 제공된다 +- [ ] 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) ### 심화과제 -#### 1) 해시 라우터 구현 -- [ ] location.hash를 이용하여 SPA 라우터 구현 - - [ ] '/#/' (홈 페이지) - - [ ] '/#/login' (로그인 페이지) - - [ ] '/#/profile' (프로필 페이지) - -#### 2) 라우트 가드 구현 -- [ ] 로그인 상태에 따른 접근 제어 -- [ ] 비로그인 사용자의 특정 페이지 접근 시 로그인 페이지로 리다이렉션 +#### SPA 네비게이션 및 URL 관리 + +**페이지 이동** + +- [ ] 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. + +**상품 목록 - URL 쿼리 반영** + +- [ ] 검색어가 URL 쿼리 파라미터에 저장된다 +- [ ] 카테고리 선택이 URL 쿼리 파라미터에 저장된다 +- [ ] 상품 옵션이 URL 쿼리 파라미터에 저장된다 +- [ ] 정렬 조건이 URL 쿼리 파라미터에 저장된다 +- [ ] 조건 변경 시 URL이 자동으로 업데이트된다 +- [ ] URL을 통해 현재 검색/필터 상태를 공유할 수 있다 + +**상품 목록 - 새로고침 시 상태 유지** + +- [ ] 새로고침 후 URL 쿼리에서 검색어가 복원된다 +- [ ] 새로고침 후 URL 쿼리에서 카테고리가 복원된다 +- [ ] 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 +- [ ] 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 +- [ ] 복원된 조건에 맞는 상품 데이터가 다시 로드된다 + +**장바구니 - 새로고침 시 데이터 유지** + +- [ ] 장바구니 내용이 브라우저에 저장된다 +- [ ] 새로고침 후에도 이전 장바구니 내용이 유지된다 +- [ ] 장바구니의 선택 상태도 함께 유지된다 + +**상품 상세 - URL에 ID 반영** + +- [ ] 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) +- [ ] URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 -#### 3) 이벤트 위임 +**상품 상세 - 새로고침시 유지** -- [ ] 이벤트 위임 방식으로 이벤트를 관리하고 있다. +- [ ] 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 + +**404 페이지** + +- [ ] 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 +- [ ] 홈으로 돌아가기 버튼이 제공된다 + +#### AI로 한 번 더 구현하기 + +- [ ] 기존에 구현한 기능을 AI로 다시 구현한다. +- [ ] 이 과정에서 직접 가공하는 것은 최대한 지양한다. ## 과제 셀프회고 ### 기술적 성장 + -### 코드 품질 +### 자랑하고 싶은 코드 + + + +### 개선이 필요하다고 생각하는 코드 + ### 학습 효과 분석 + ### 과제 피드백 + +### AI 활용 경험 공유하기 + + + ## 리뷰 받고 싶은 내용 + + + + + +
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
+ 총 340개의 상품 +
+ +
+
+ +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+
+

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

+

+

+ 220원 +

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

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

+

이지웨이건축자재

+

+ 230원 +

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

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

+

+

+ 280원 +

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

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

+

+

+ 350원 +

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

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

+

+

+ 420원 +

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

+ + + + 장바구니 +

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

장바구니가 비어있습니다

+

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

+
+
+
+
+
+
+ + + + + + diff --git a/e2e/E2EHelpers.js b/e2e/E2EHelpers.js new file mode 100644 index 00000000..9067804f --- /dev/null +++ b/e2e/E2EHelpers.js @@ -0,0 +1,28 @@ +export 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 }); + } +} diff --git a/e2e/app.spec.js b/e2e/app.spec.js deleted file mode 100644 index e69de29b..00000000 diff --git a/e2e/e2e.advanced.spec.js b/e2e/e2e.advanced.spec.js new file mode 100644 index 00000000..657eb959 --- /dev/null +++ b/e2e/e2e.advanced.spec.js @@ -0,0 +1,390 @@ +import { expect, test } from "@playwright/test"; +import { E2EHelpers } from "./E2EHelpers.js"; + +// 테스트 설정 +test.describe.configure({ mode: "serial" }); + +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 키로 검색하고 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(); + + // 1차 카테고리 선택 + await page.click("text=생활/건강"); + + await expect(page).toHaveURL(/category1=%EC%83%9D%ED%99%9C%2F%EA%B1%B4%EA%B0%95/); + await expect(page.locator("text=300개")).toBeVisible(); + + // 브레드크럼 확인 + await expect(page.locator("text=카테고리:").locator("..")).toContainText("생활/건강"); + + // 2차 카테고리 선택 + await page.click("text=자동차용품"); + + await expect(page).toHaveURL(/category2=%EC%9E%90%EB%8F%99%EC%B0%A8%EC%9A%A9%ED%92%88/); + await expect(page.locator("text=11개")).toBeVisible(); + + // 브레드크럼에 2차 카테고리도 표시되는지 확인 + await expect(page.locator("text=카테고리:").locator("..")).toContainText("자동차용품"); + await expect(page.locator("text=11개")).toBeVisible(); + + await page.reload(); + await helpers.waitForPageLoad(); + await expect(page.locator("text=11개")).toBeVisible(); + }); + + test("브레드크럼 클릭으로 상위 카테고리로 이동할 수 있다", async ({ page }) => { + const helpers = new E2EHelpers(page); + + // 2차 카테고리 상태에서 시작 + await page.goto("/?current=1&category1=생활%2F건강&category2=자동차용품&search=차량용"); + await helpers.waitForPageLoad(); + await expect(page.locator("text=9개")).toBeVisible(); + + // 1차 카테고리 브레드크럼 클릭 + await page.click("text=생활/건강"); + + await expect(page).toHaveURL(/category1=%EC%83%9D%ED%99%9C%2F%EA%B1%B4%EA%B0%95/); + await expect(page).not.toHaveURL(/category2/); + await expect(page.locator("text=12개")).toBeVisible(); + + // 전체 브레드크럼 클릭 + await page.click("text=전체"); + await expect(page.locator("text=카테고리: 전체 생활/건강 디지털/가전")).toBeVisible(); + + await page.reload(); + await helpers.waitForPageLoad(); + await expect(page.locator("text=카테고리: 전체 생활/건강 디지털/가전")).toBeVisible(); + + await page.fill("#search-input", ""); + await page.press("#search-input", "Enter"); + + await expect(page).not.toHaveURL(/category/); + await expect(page.locator("text=340개")).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.describe("3. URL로 접근시 UI복원", () => { + test("검색어와 필터 조건이 URL에서 복원된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + + // 복잡한 쿼리 파라미터로 직접 접근 + await page.goto("/?search=젤리&category1=생활%2F건강&sort=price_desc&limit=10"); + await helpers.waitForPageLoad(); + + // URL에서 복원된 상태 확인 + 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.locator("text=카테고리:").locator("..")).toContainText("생활/건강"); + }); + }); + + test.describe("4. 상품 상세 페이지", () => { + test("상품 클릭부터 관련 상품 이동까지 전체 플로우", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + await page.evaluate(() => { + window.loadFlag = true; + }); + + // 상품 이미지 클릭하여 상세 페이지로 이동 + const productCard = page + .locator("text=PVC 투명 젤리 쇼핑백") + .locator('xpath=ancestor::*[contains(@class, "product-card")]'); + await productCard.locator("img").click(); + + // URL이 상세 페이지로 변경되었는지 확인 + await expect(page).toHaveURL(/\/product\/\d+/); + + // 상세 페이지 로딩 확인 + 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(); + + // 관련 상품 섹션 확인 + await expect(page.locator("text=관련 상품")).toBeVisible(); + + const relatedProducts = page.locator(".related-product-card"); + await expect(relatedProducts.first()).toBeVisible(); + + // 첫 번째 관련 상품 클릭 + const currentUrl = page.url(); + await relatedProducts.first().click(); + + // 다른 상품의 상세 페이지로 이동했는지 확인 + await expect(page).toHaveURL(/\/product\/\d+/); + await expect(page.url()).not.toBe(currentUrl); + 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("5. 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.basic.spec.js b/e2e/e2e.basic.spec.js new file mode 100644 index 00000000..9bbefa22 --- /dev/null +++ b/e2e/e2e.basic.spec.js @@ -0,0 +1,423 @@ +import { expect, test } from "@playwright/test"; +import { E2EHelpers } from "./E2EHelpers.js"; + +// 테스트 설정 +test.describe.configure({ mode: "serial" }); + +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(); + + // 1차 카테고리 선택 + await page.click("text=생활/건강"); + await expect(page.locator("text=300개")).toBeVisible(); + await expect(page.locator("text=카테고리:").locator("..")).toContainText("생활/건강"); + + // 2차 카테고리 선택 + await page.click("text=자동차용품"); + await expect(page.locator("text=11개")).toBeVisible(); + await expect(page.locator("text=카테고리:").locator("..")).toContainText("자동차용품"); + + // 검색어 입력 + await page.fill("#search-input", "차량용"); + await page.press("#search-input", "Enter"); + await expect(page.locator("text=9개")).toBeVisible(); + + // 1차 카테고리 브레드크럼 클릭 + await page.click("text=생활/건강"); + await expect(page.locator("text=12개")).toBeVisible(); + + // 전체 브레드크럼 클릭 + await page.click("text=전체"); + await expect(page.locator("text=카테고리: 전체 생활/건강 디지털/가전")).toBeVisible(); + + await page.fill("#search-input", ""); + await page.press("#search-input", "Enter"); + + await expect(page).not.toHaveURL(/category/); + await expect(page.locator("text=340개")).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 "장바구니 담기" + `); + }); + + test("페이지당 상품 수 변경이 가능하다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + const args = [ + [10, `- heading "탈부착 방충망 자석쫄대 방풍비닐 창문방충망 셀프시공 DIY 백색 100cm" [level=3]`], + [20, `- heading "고양이 난간 안전망 복층 베란다 방묘창 방묘문 방충망 캣도어 일반형검정1mx1m" [level=3]`], + [50, `- heading "강아지 고양이 아이스팩 파우치 여름 베개 젤리곰 M사이즈" [level=3]`], + [100, `- heading "고양이 스크래쳐 숨숨집 하우스 대형 원목 스크레쳐 A type" [level=3]`], + ]; + for (const [limit, lastExpected] of args) { + await page.selectOption("#limit-select", limit.toString()); + await page.waitForFunction((l) => document.querySelectorAll(".product-card").length === l, limit); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(lastExpected); + } + }); + }); + + test.describe("3. 상태 유지 및 복원", () => { + test("장바구니 내용이 localStorage에 저장되고 복원된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 상품을 장바구니에 추가 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + + // 장바구니 아이콘에 개수 표시 확인 + await expect(page.locator("#cart-icon-btn span")).toBeVisible(); + + // localStorage에 저장되었는지 확인 + const cartData = await page.evaluate(() => localStorage.getItem("shopping_cart")); + expect(cartData).toBeTruthy(); + + // 페이지 새로고침 + await page.reload(); + await helpers.waitForPageLoad(); + + // 장바구니 아이콘에 여전히 개수가 표시되는지 확인 + await expect(page.locator("#cart-icon-btn span")).toBeVisible(); + }); + + 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 helpers.waitForPageLoad(); + await page.evaluate(() => { + window.loadFlag = true; + }); + + // 상품 이미지 클릭하여 상세 페이지로 이동 + 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(); + + // 관련 상품 섹션 확인 + 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(); + + // 첫 번째 상품 추가 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + + // 두 번째 상품 추가 + await helpers.addProductToCart("샷시 풍지판"); + + // 장바구니 아이콘에 개수 표시 확인 (2개) + await expect(page.locator("#cart-icon-btn span")).toHaveText("2"); + + // 장바구니 모달 열기 + await helpers.openCartModal(); + + // 두 상품이 모두 있는지 확인 + await expect(page.locator(".cart-modal")).toContainText("PVC 투명 젤리 쇼핑백"); + await expect(page.locator(".cart-modal")).toContainText("샷시 풍지판"); + + // 첫 번째 상품 수량 증가 + await page.locator(".quantity-increase-btn").first().click(); + + // 총 금액 업데이트 확인 + await expect(page.locator("#root")).toMatchAriaSnapshot(` + - text: /총 금액 670원/ + - button "전체 비우기" + - button "구매하기" + `); + + // 첫 번째 상품만 선택 + await page.locator(".cart-item-checkbox").first().check(); + + // 선택 삭제 + await page.click("#cart-modal-remove-selected-btn"); + + // 첫 번째 상품만 삭제되고 두 번째 상품은 남아있는지 확인 + await expect(page.locator(".cart-modal")).not.toContainText("PVC 투명 젤리 쇼핑백"); + await expect(page.locator(".cart-modal")).toContainText("샷시 풍지판"); + + // 장바구니 아이콘 개수 업데이트 확인 (1개) + await expect(page.locator("#cart-icon-btn span")).toHaveText("1"); + }); + + test("전체 선택 후 장바구니 비우기", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 여러 상품 추가 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + await helpers.addProductToCart("고양이 난간 안전망"); + + // 장바구니 모달 열기 + await helpers.openCartModal(); + + // 전체 선택 + await page.check("#cart-modal-select-all-checkbox"); + + // 모든 상품이 선택되었는지 확인 + const checkboxes = page.locator(".cart-item-checkbox"); + const count = await checkboxes.count(); + for (let i = 0; i < count; i++) { + await expect(checkboxes.nth(i)).toBeChecked(); + } + + // 장바구니 비우기 + await page.click("#cart-modal-clear-cart-btn"); + + // 장바구니가 비어있는지 확인 + await expect(page.locator("text=장바구니가 비어있습니다")).toBeVisible(); + + // 장바구니 아이콘에서 개수 표시가 사라졌는지 확인 + await expect(page.locator("#cart-icon-btn span")).not.toBeVisible(); + }); + }); + + test.describe("6. 무한 스크롤 기능", () => { + 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( + () => { + return document.querySelectorAll(".product-card").length > 20; + }, + { timeout: 5000 }, + ); + + // 상품 수가 증가했는지 확인 + const updatedCards = await page.locator(".product-card").count(); + expect(updatedCards).toBeGreaterThan(initialCards); + }); + }); + + test.describe("7. 모달 및 UI 인터랙션", () => { + test("장바구니 모달이 다양한 방법으로 열리고 닫힌다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 모달 열기 + await page.click("#cart-icon-btn"); + await expect(page.locator(".cart-modal-overlay")).toBeVisible(); + + // ESC 키로 닫기 + await page.keyboard.press("Escape"); + await expect(page.locator(".cart-modal-overlay")).not.toBeVisible(); + + // 다시 열기 + await page.click("#cart-icon-btn"); + await expect(page.locator(".cart-modal-overlay")).toBeVisible(); + + // X 버튼으로 닫기 + await page.click("#cart-modal-close-btn"); + await expect(page.locator(".cart-modal-overlay")).not.toBeVisible(); + + // 다시 열기 + await page.click("#cart-icon-btn"); + await expect(page.locator(".cart-modal-overlay")).toBeVisible(); + + // 배경 클릭으로 닫기 (모달 내용이 아닌 오버레이 영역 클릭) + await page.locator(".cart-modal-overlay").click({ position: { x: 10, y: 10 } }); + await expect(page.locator(".cart-modal-overlay")).not.toBeVisible(); + }); + + test("토스트 메시지 시스템이 올바르게 작동한다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 상품을 장바구니에 추가하여 토스트 메시지 트리거 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + + // 토스트 메시지 표시 확인 + let toast = await page.locator("text=장바구니에 추가되었습니다"); + await expect(toast).toBeVisible(); + + // 닫기 버튼을 클릭하여 닫기 테스트 + await page.locator("#toast-close-btn").click(); + await expect(toast).not.toBeVisible(); + + // 상품을 장바구니에 추가하여 토스트 메시지 트리거 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + + // 토스트 메시지 표시 확인 + toast = await page.locator("text=장바구니에 추가되었습니다"); + await expect(toast).toBeVisible(); + + // 자동으로 닫히는지 테스트 + await expect(toast).not.toBeVisible({ timeout: 4000 }); + }); + }); +}); diff --git a/index.html b/index.html index 6b45e6f0..d43ffde2 100644 --- a/index.html +++ b/index.html @@ -1,25 +1,26 @@ - - - - 상품 쇼핑몰 - - + + - - -
- - - \ No newline at end of file + }; + + + +
+ + + diff --git a/package.json b/package.json index 2d5c7358..5ec7f3f3 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "front-6th-chapter1-1", + "name": "front-chapter1-1", "private": true, "version": "0.0.0", "type": "module", @@ -10,11 +10,9 @@ "lint:fix": "eslint --fix", "prettier:write": "prettier --write ./src", "preview": "vite preview", - "test": "vitest", - "test:basic": "vitest basic.test.js", - "test:advanced": "vitest advanced", - "test:ui": "vitest --ui", "test:e2e": "playwright test", + "test:e2e:basic": "playwright test basic", + "test:e2e:advanced": "playwright test advanced", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "npx playwright show-report", "test:generate": "playwright codegen localhost:5173", @@ -28,9 +26,11 @@ }, "devDependencies": { "@eslint/js": "^9.16.0", - "@playwright/test": "^1.49.1", + "@playwright/test": "latest", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", - "@testing-library/user-event": "^14.5.2", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "latest", "@vitest/ui": "^2.1.8", "eslint": "^9.16.0", "eslint-config-prettier": "^9.1.0", @@ -42,11 +42,11 @@ "msw": "^2.10.2", "prettier": "^3.4.2", "vite": "npm:rolldown-vite@latest", - "vitest": "^2.1.8" + "vitest": "latest" }, "msw": { "workerDirectory": [ "public" ] } -} \ No newline at end of file +} diff --git a/playwright.config.js b/playwright.config.js index 53255d73..dd40de25 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -18,7 +18,7 @@ export default defineConfig({ }, ], webServer: { - command: "npm run dev", + command: "pnpm run dev", port: 5173, reuseExistingServer: !process.env.CI, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7aa93df..8137d4c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,17 +12,23 @@ importers: specifier: ^9.16.0 version: 9.23.0 '@playwright/test': - specifier: ^1.49.1 - version: 1.51.1 + specifier: latest + version: 1.53.2 + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.0 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 '@testing-library/user-event': - specifier: ^14.5.2 + specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@vitest/coverage-v8': + specifier: latest + version: 3.2.4(vitest@3.2.4) '@vitest/ui': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9) + version: 2.1.9(vitest@3.2.4) eslint: specifier: ^9.16.0 version: 9.23.0 @@ -54,14 +60,18 @@ importers: specifier: npm:rolldown-vite@latest version: rolldown-vite@6.3.21(esbuild@0.25.1)(yaml@2.7.0) vitest: - specifier: ^2.1.8 - version: 2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) + specifier: latest + version: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.1.1': resolution: {integrity: sha512-hpRD68SV2OMcZCsrbdkccTw5FXjNDLo5OuqSHyHZfwweGsDWZwDJ2+gONyNAbazZclobMirACLw0lk8WVxIqxA==} @@ -69,14 +79,35 @@ packages: resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.25.9': resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.0': + resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.26.10': resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.0': + resolution: {integrity: sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@bundled-es-modules/cookie@2.0.1': resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} @@ -500,9 +531,27 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/trace-mapping@0.3.29': + resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@mswjs/interceptors@0.39.2': resolution: {integrity: sha512-RuzCup9Ct91Y7V79xwCb146RaBRHZ7NBbrIUySumd1rpKqHL5OonaqrGIbug5hNwP/fRyxFMA6ISgw4FTtYFYg==} engines: {node: '>=18'} @@ -526,12 +575,16 @@ packages: '@oxc-project/types@0.73.0': resolution: {integrity: sha512-ZQS7dpsga43R7bjqRKHRhOeNpuIBeLBnlS3M6H3IqWIWiapGOQIxp4lpETLBYupkSd4dh85ESFn6vAvtpPdGkA==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/core@0.1.2': resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.51.1': - resolution: {integrity: sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==} + '@playwright/test@1.53.2': + resolution: {integrity: sha512-tEB2U5z74ebBeyfGNZ3Jfg29AnW+5HlWhvHtb/Mqco9pFdZU1ZLNdVb2UtB5CvmiilNr2ZfVH/qMmAROG/XTzw==} engines: {node: '>=18'} hasBin: true @@ -716,9 +769,15 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -731,14 +790,23 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true @@ -748,14 +816,17 @@ packages: '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} '@vitest/ui@2.1.9': resolution: {integrity: sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==} @@ -765,6 +836,9 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -828,6 +902,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.3: + resolution: {integrity: sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -837,6 +914,9 @@ packages: brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -938,6 +1018,15 @@ packages: supports-color: optional: true + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} @@ -970,12 +1059,18 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -992,8 +1087,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -1093,8 +1188,8 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} - expect-type@1.2.0: - resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==} + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} fast-deep-equal@3.1.3: @@ -1147,6 +1242,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data@4.0.2: resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} engines: {node: '>= 6'} @@ -1188,6 +1287,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1227,6 +1330,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1301,9 +1407,31 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -1427,6 +1555,9 @@ packages: loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + loupe@3.1.4: + resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -1437,6 +1568,13 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1471,6 +1609,14 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -1530,6 +1676,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1549,12 +1698,19 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.0: resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} engines: {node: '>= 14.16'} @@ -1575,13 +1731,13 @@ packages: engines: {node: '>=0.10'} hasBin: true - playwright-core@1.51.1: - resolution: {integrity: sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==} + playwright-core@1.53.2: + resolution: {integrity: sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==} engines: {node: '>=18'} hasBin: true - playwright@1.51.1: - resolution: {integrity: sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==} + playwright@1.53.2: + resolution: {integrity: sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==} engines: {node: '>=18'} hasBin: true @@ -1706,6 +1862,11 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1744,8 +1905,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.8.1: - resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -1758,6 +1919,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -1782,6 +1947,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1793,6 +1961,10 @@ packages: resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} engines: {node: ^14.18.0 || >=16.0.0} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1807,16 +1979,20 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} - tinypool@1.0.2: - resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} tinyrainbow@1.2.0: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} engines: {node: '>=14.0.0'} tldts-core@6.1.84: @@ -1871,9 +2047,9 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true vite@5.4.14: @@ -1907,20 +2083,23 @@ packages: terser: optional: true - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@types/debug': + optional: true '@types/node': optional: true '@vitest/browser': @@ -1974,6 +2153,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.0: resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} engines: {node: '>=18'} @@ -2026,6 +2209,11 @@ snapshots: '@adobe/css-tools@4.4.2': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.29 + '@asamuzakjp/css-color@3.1.1': dependencies: '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) @@ -2040,12 +2228,27 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.25.9': {} + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/parser@7.28.0': + dependencies: + '@babel/types': 7.28.0 + '@babel/runtime@7.26.10': dependencies: regenerator-runtime: 0.14.1 + '@babel/types@7.28.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@bcoe/v8-coverage@1.0.2': {} + '@bundled-es-modules/cookie@2.0.1': dependencies: cookie: 0.7.2 @@ -2316,8 +2519,31 @@ snapshots: '@inquirer/type@3.0.7': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.12': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.29 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/trace-mapping@0.3.29': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + '@mswjs/interceptors@0.39.2': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -2347,11 +2573,14 @@ snapshots: '@oxc-project/types@0.73.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.1.2': {} - '@playwright/test@1.51.1': + '@playwright/test@1.53.2': dependencies: - playwright: 1.51.1 + playwright: 1.53.2 '@polka/url@1.0.0-next.28': {} @@ -2484,8 +2713,14 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.6': {} '@types/json-schema@7.0.15': {} @@ -2494,16 +2729,36 @@ snapshots: '@types/tough-cookie@4.0.5': {} - '@vitest/expect@2.1.9': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.3 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.17 + magicast: 0.3.5 + std-env: 3.9.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.2.0 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 - '@vitest/mocker@2.1.9(msw@2.10.2)(vite@5.4.14(lightningcss@1.30.1))': + '@vitest/mocker@3.2.4(msw@2.10.2)(vite@5.4.14(lightningcss@1.30.1))': dependencies: - '@vitest/spy': 2.1.9 + '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: @@ -2514,22 +2769,27 @@ snapshots: dependencies: tinyrainbow: 1.2.0 - '@vitest/runner@2.1.9': + '@vitest/pretty-format@3.2.4': dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 + tinyrainbow: 2.0.0 - '@vitest/snapshot@2.1.9': + '@vitest/runner@3.2.4': dependencies: - '@vitest/pretty-format': 2.1.9 + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 magic-string: 0.30.17 - pathe: 1.1.2 + pathe: 2.0.3 - '@vitest/spy@2.1.9': + '@vitest/spy@3.2.4': dependencies: - tinyspy: 3.0.2 + tinyspy: 4.0.3 - '@vitest/ui@2.1.9(vitest@2.1.9)': + '@vitest/ui@2.1.9(vitest@3.2.4)': dependencies: '@vitest/utils': 2.1.9 fflate: 0.8.2 @@ -2538,7 +2798,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.12 tinyrainbow: 1.2.0 - vitest: 2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) + vitest: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) '@vitest/utils@2.1.9': dependencies: @@ -2546,6 +2806,12 @@ snapshots: loupe: 3.1.3 tinyrainbow: 1.2.0 + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.1.4 + tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.14.1): dependencies: acorn: 8.14.1 @@ -2593,6 +2859,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.3: + dependencies: + '@jridgewell/trace-mapping': 0.3.29 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + asynckit@0.4.0: {} balanced-match@1.0.2: {} @@ -2602,6 +2874,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -2620,7 +2896,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.3 + loupe: 3.1.4 pathval: 2.0.0 chalk@3.0.0: @@ -2694,6 +2970,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.1: + dependencies: + ms: 2.1.3 + decimal.js@10.5.0: {} deep-eql@5.0.2: {} @@ -2716,10 +2996,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + entities@4.5.0: {} environment@1.1.0: {} @@ -2728,7 +3012,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: dependencies: @@ -2898,7 +3182,7 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 - expect-type@1.2.0: {} + expect-type@1.2.1: {} fast-deep-equal@3.1.3: {} @@ -2938,6 +3222,11 @@ snapshots: flatted@3.3.3: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data@4.0.2: dependencies: asynckit: 0.4.0 @@ -2981,6 +3270,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} globals@15.15.0: {} @@ -3007,6 +3305,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 @@ -3064,8 +3364,37 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.29 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -3202,6 +3531,8 @@ snapshots: loupe@3.1.3: {} + loupe@3.1.4: {} + lru-cache@10.4.3: {} lz-string@1.5.0: {} @@ -3210,6 +3541,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.0 + '@babel/types': 7.28.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + math-intrinsics@1.1.0: {} merge-stream@2.0.0: {} @@ -3235,6 +3576,12 @@ snapshots: dependencies: brace-expansion: 1.1.11 + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -3301,6 +3648,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3315,10 +3664,17 @@ snapshots: path-key@4.0.0: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-to-regexp@6.3.0: {} pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.0: {} picocolors@1.1.1: {} @@ -3329,11 +3685,11 @@ snapshots: pidtree@0.6.0: {} - playwright-core@1.51.1: {} + playwright-core@1.53.2: {} - playwright@1.51.1: + playwright@1.53.2: dependencies: - playwright-core: 1.51.1 + playwright-core: 1.53.2 optionalDependencies: fsevents: 2.3.2 @@ -3456,6 +3812,8 @@ snapshots: dependencies: xmlchars: 2.2.0 + semver@7.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3488,7 +3846,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.8.1: {} + std-env@3.9.0: {} strict-event-emitter@0.5.1: {} @@ -3500,6 +3858,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + string-width@7.2.0: dependencies: emoji-regex: 10.4.0 @@ -3522,6 +3886,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3533,6 +3901,12 @@ snapshots: '@pkgr/core': 0.1.2 tslib: 2.8.1 + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -3547,11 +3921,13 @@ snapshots: fdir: 6.4.6(picomatch@4.0.2) picomatch: 4.0.2 - tinypool@1.0.2: {} + tinypool@1.1.1: {} tinyrainbow@1.2.0: {} - tinyspy@3.0.2: {} + tinyrainbow@2.0.0: {} + + tinyspy@4.0.3: {} tldts-core@6.1.84: {} @@ -3601,12 +3977,12 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 - vite-node@2.1.9(lightningcss@1.30.1): + vite-node@3.2.4(lightningcss@1.30.1): dependencies: cac: 6.7.14 - debug: 4.4.0 - es-module-lexer: 1.6.0 - pathe: 1.1.2 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 2.0.3 vite: 5.4.14(lightningcss@1.30.1) transitivePeerDependencies: - '@types/node' @@ -3628,30 +4004,33 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.1 - vitest@2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2): + vitest@3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2): dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(msw@2.10.2)(vite@5.4.14(lightningcss@1.30.1)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.10.2)(vite@5.4.14(lightningcss@1.30.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.2.0 - debug: 4.4.0 - expect-type: 1.2.0 + debug: 4.4.1 + expect-type: 1.2.1 magic-string: 0.30.17 - pathe: 1.1.2 - std-env: 3.8.1 + pathe: 2.0.3 + picomatch: 4.0.2 + std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinypool: 1.0.2 - tinyrainbow: 1.2.0 + tinyglobby: 0.2.14 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 vite: 5.4.14(lightningcss@1.30.1) - vite-node: 2.1.9(lightningcss@1.30.1) + vite-node: 3.2.4(lightningcss@1.30.1) why-is-node-running: 2.3.0 optionalDependencies: - '@vitest/ui': 2.1.9(vitest@2.1.9) + '@vitest/ui': 2.1.9(vitest@3.2.4) jsdom: 25.0.1 transitivePeerDependencies: - less @@ -3704,6 +4083,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + wrap-ansi@9.0.0: dependencies: ansi-styles: 6.2.1 diff --git a/requirement.md b/requirement.md new file mode 100644 index 00000000..4e68bf8c --- /dev/null +++ b/requirement.md @@ -0,0 +1,190 @@ +# 요구사항 명세서 + +## 상품목록 + +### 상품 목록 로딩 + +- 페이지 접속 시 로딩 상태가 표시된다 +- 데이터 로드 완료 후 상품 목록이 렌더링된다 +- 로딩 실패 시 에러 상태가 표시된다 +- 에러 발생 시 재시도 버튼이 제공된다 + +### 상품 목록 조회 + +- 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 + +### 한 페이지에 보여질 상품 수 선택 + +- 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. +- 선택 변경 시 즉시 목록에 반영된다 + +### 상품 정렬 기능 + +- 상품을 가격순/인기순으로 오름차순/내림차순 정렬을 할 수 있다. +- 드롭다운을 통해 정렬 기준을 선택할 수 있다 +- 정렬 변경 시 즉시 목록에 반영된다 + +### 무한 스크롤 페이지네이션 + +- 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 +- 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 +- 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 +- 홈 페이지에서만 무한 스크롤이 활성화된다 + +### 상품을 장바구니에 담기 + +- 각 상품에 장바구니 추가 버튼이 있다 +- 버튼 클릭 시 해당 상품이 장바구니에 추가된다 +- 추가 완료 시 사용자에게 알림이 표시된다 + +### 상품 검색 + +- 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- 검색 버튼 클릭으로 검색이 수행된다 +- Enter 키로 검색이 수행된다 +- 검색어와 일치하는 상품들만 목록에 표시된다 + +### 카테고리 선택 + +- 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- 선택된 카테고리에 해당하는 상품들만 표시된다 +- 전체 상품 보기로 돌아갈 수 있다 +- 2단계 카테고리 구조를 지원한다 (1depth, 2depth) + +### 카테고리 네비게이션 + +- 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 + +### 현재 상품 수 표시 + +- 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 + +## 장바구니 + +### 장바구니 모달 + +- 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- ESC 키로 모달을 닫을 수 있다 +- 모달에서 장바구니의 모든 기능을 사용할 수 있다 + +### 장바구니 수량 조절 + +- 각 장바구니 상품의 수량을 증가할 수 있다 +- 각 장바구니 상품의 수량을 감소할 수 있다 +- 수량 변경 시 총 금액이 실시간으로 업데이트된다 + +### 장바구니 삭제 + +- 각 상품에 삭제 버튼이 배치되어 있다 +- 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 + +### 장바구니 선택 삭제 + +- 각 상품에 선택을 위한 체크박스가 제공된다 +- 선택 삭제 버튼이 있다 +- 체크된 상품들만 일괄 삭제된다 + +### 장바구니 전체 선택 + +- 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- 전체 선택 시 모든 상품의 체크박스가 선택된다 +- 전체 해제 시 모든 상품의 체크박스가 해제된다 + +### 장바구니 비우기 + +- 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 + +## 상품 상세 + +### 상품 클릭시 상세 페이지 이동 + +- 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- URL이 `/product/{productId}` 형태로 변경된다 +- 상품의 자세한 정보가 전용 페이지에서 표시된다 + +### 상품 상세 페이지 기능 + +- 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 + +### 상품 상세 - 장바구니 담기 + +- 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- 수량 증가/감소 버튼이 제공된다 + +### 관련 상품 기능 + +- 상품 상세 페이지에서 관련 상품들이 표시된다 +- 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- 현재 보고 있는 상품은 관련 상품에서 제외된다 + +### 상품 상세 페이지 내 네비게이션 + +- 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 + +## 사용자 피드백 시스템 + +### 토스트 메시지 + +- 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- 토스트는 3초 후 자동으로 사라진다 +- 토스트에 닫기 버튼이 제공된다 +- 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) + +### 에러 처리 + +- 네트워크 오류 등 에러 발생 시 사용자에게 적절한 메시지가 표시된다 +- 에러 상황에서 재시도할 수 있는 버튼이 제공된다 +- 에러 상태가 UI에 적절히 반영된다 + +## SPA 네비게이션 및 URL 관리 + +### 페이지 이동 + +- 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. + +### 상품 목록 - URL 쿼리 반영 + +- 검색어가 URL 쿼리 파라미터에 저장된다 +- 카테고리 선택이 URL 쿼리 파라미터에 저장된다 +- 상품 옵션이 URL 쿼리 파라미터에 저장된다 +- 정렬 조건이 URL 쿼리 파라미터에 저장된다 +- 조건 변경 시 URL이 자동으로 업데이트된다 +- URL을 통해 현재 검색/필터 상태를 공유할 수 있다 + +### 상품 목록 - 새로고침 시 상태 유지 + +- 새로고침 후 URL 쿼리에서 검색어가 복원된다 +- 새로고침 후 URL 쿼리에서 카테고리가 복원된다 +- 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 +- 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 +- 복원된 조건에 맞는 상품 데이터가 다시 로드된다 + +### 장바구니 - 새로고침 시 데이터 유지 + +- 장바구니 내용이 브라우저에 저장된다 +- 새로고침 후에도 이전 장바구니 내용이 유지된다 +- 장바구니의 선택 상태도 함께 유지된다 + +### 상품 상세 - URL에 ID 반영 + +- 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) +- URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 + +### 상품 상세 - 새로고침시 유지 + +- 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 + +### 404 페이지 + +- 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 +- 홈으로 돌아가기 버튼이 제공된다 diff --git a/src/api/productApi.js b/src/api/productApi.js new file mode 100644 index 00000000..bbdea046 --- /dev/null +++ b/src/api/productApi.js @@ -0,0 +1,30 @@ +// 상품 목록 조회 +export async function getProducts(params = {}) { + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.current ?? params.page ?? 1; + + const searchParams = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + ...(search && { search }), + ...(category1 && { category1 }), + ...(category2 && { category2 }), + sort, + }); + + const response = await fetch(`/api/products?${searchParams}`); + + return await response.json(); +} + +// 상품 상세 조회 +export async function getProduct(productId) { + const response = await fetch(`/api/products/${productId}`); + return await response.json(); +} + +// 카테고리 목록 조회 +export async function getCategories() { + const response = await fetch("/api/categories"); + return await response.json(); +} diff --git a/src/main.js b/src/main.js index 983c051f..4b055b89 100644 --- a/src/main.js +++ b/src/main.js @@ -1,19 +1,1152 @@ -import { worker } from "./mocks/browser.js"; - -// 개발 환경에서만 MSW 워커 시작 -async function enableMocking() { - if (import.meta.env.DEV) { - return worker.start({ - onUnhandledRequest: "bypass", // 처리되지 않은 요청은 그대로 통과 - }); - } -} +const enableMocking = () => + import("./mocks/browser.js").then(({ worker }) => + worker.start({ + onUnhandledRequest: "bypass", + }), + ); + +function main() { + const 상품목록_레이아웃_로딩 = ` +
+
+
+
+

+ 쇼핑몰 +

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

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

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

+ 쇼핑몰 +

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

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

+

+

+ 220원 +

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

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

+

이지웨이건축자재

+

+ 230원 +

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

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

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

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

+ +
+ +
+
+ + + +
+

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

+ +
+ +
+
+ + + +
+

오류가 발생했습니다.

+ +
+
+ `; -// 앱 초기화 -async function initApp() { - // MSW 워커 시작 - await enableMocking(); + 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 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+
+ `; + + document.body.innerHTML = ` + ${상품목록_레이아웃_로딩} +
+ ${상품목록_레이아웃_로딩완료} +
+ ${상품목록_레이아웃_카테고리_1Depth} +
+ ${상품목록_레이아웃_카테고리_2Depth} +
+ ${토스트} +
+ ${장바구니_비어있음} +
+ ${장바구니_선택없음} +
+ ${장바구니_선택있음} +
+ ${상세페이지_로딩} +
+ ${상세페이지_로딩완료} +
+ ${_404_} + `; } -// 앱 시작 -initApp().catch(console.error); +// 애플리케이션 시작 +if (import.meta.env.MODE !== "test") { + enableMocking().then(main); +} else { + main(); +} diff --git a/src/mocks/browser.js b/src/mocks/browser.js index e4d86a51..be3dedca 100644 --- a/src/mocks/browser.js +++ b/src/mocks/browser.js @@ -1,5 +1,5 @@ import { setupWorker } from "msw/browser"; -import { handlers } from "./handlers.js"; +import { handlers } from "./handlers"; // MSW 워커 설정 export const worker = setupWorker(...handlers); diff --git a/src/mocks/handlers.js b/src/mocks/handlers.js index 03578eec..e7dcb949 100644 --- a/src/mocks/handlers.js +++ b/src/mocks/handlers.js @@ -1,5 +1,7 @@ import { http, HttpResponse } from "msw"; -import items from "../items.json"; +import items from "./items.json"; + +const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); // 카테고리 추출 함수 function getUniqueCategories() { @@ -8,15 +10,9 @@ function getUniqueCategories() { items.forEach((item) => { const cat1 = item.category1; const cat2 = item.category2; - const cat3 = item.category3; - const cat4 = item.category4; if (!categories[cat1]) categories[cat1] = {}; if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; - if (cat3 && !categories[cat1][cat2][cat3]) - categories[cat1][cat2][cat3] = {}; - if (cat4 && !categories[cat1][cat2][cat3][cat4]) - categories[cat1][cat2][cat3][cat4] = true; }); return categories; @@ -30,9 +26,7 @@ function filterProducts(products, query) { if (query.search) { const searchTerm = query.search.toLowerCase(); filtered = filtered.filter( - (item) => - item.title.toLowerCase().includes(searchTerm) || - item.brand.toLowerCase().includes(searchTerm), + (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), ); } @@ -43,12 +37,6 @@ function filterProducts(products, query) { if (query.category2) { filtered = filtered.filter((item) => item.category2 === query.category2); } - if (query.category3) { - filtered = filtered.filter((item) => item.category3 === query.category3); - } - if (query.category4) { - filtered = filtered.filter((item) => item.category4 === query.category4); - } // 정렬 if (query.sort) { @@ -60,10 +48,10 @@ function filterProducts(products, query) { filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); break; case "name_asc": - filtered.sort((a, b) => a.title.localeCompare(b.title)); + filtered.sort((a, b) => a.title.localeCompare(b.title, "ko")); break; case "name_desc": - filtered.sort((a, b) => b.title.localeCompare(a.title)); + filtered.sort((a, b) => b.title.localeCompare(a.title, "ko")); break; default: // 기본은 가격 낮은 순 @@ -76,15 +64,13 @@ function filterProducts(products, query) { export const handlers = [ // 상품 목록 API - http.get("/api/products", ({ request }) => { + http.get("/api/products", async ({ request }) => { const url = new URL(request.url); - const page = parseInt(url.searchParams.get("page")) || 1; + const page = parseInt(url.searchParams.get("page") ?? url.searchParams.get("current")) || 1; const limit = parseInt(url.searchParams.get("limit")) || 20; const search = url.searchParams.get("search") || ""; const category1 = url.searchParams.get("category1") || ""; const category2 = url.searchParams.get("category2") || ""; - const category3 = url.searchParams.get("category3") || ""; - const category4 = url.searchParams.get("category4") || ""; const sort = url.searchParams.get("sort") || "price_asc"; // 필터링된 상품들 @@ -92,8 +78,6 @@ export const handlers = [ search, category1, category2, - category3, - category4, sort, }); @@ -117,17 +101,17 @@ export const handlers = [ search, category1, category2, - category3, - category4, sort, }, }; + await delay(); + return HttpResponse.json(response); }), // 상품 상세 API - http.get("/api/products/:id", ({ params }) => { + http.get("/api/products/:id", async ({ params }) => { const { id } = params; const product = items.find((item) => item.productId === id); @@ -142,19 +126,17 @@ export const handlers = [ rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤 reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤 stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤 - images: [ - product.image, - product.image.replace(".jpg", "_2.jpg"), - product.image.replace(".jpg", "_3.jpg"), - ], + images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], }; + await delay(); return HttpResponse.json(detailProduct); }), // 카테고리 목록 API - http.get("/api/categories", () => { + http.get("/api/categories", async () => { const categories = getUniqueCategories(); + await delay(); return HttpResponse.json(categories); }), ]; diff --git a/src/items.json b/src/mocks/items.json similarity index 59% rename from src/items.json rename to src/mocks/items.json index fb8c291c..eb998ea0 100644 --- a/src/items.json +++ b/src/mocks/items.json @@ -160,7 +160,7 @@ "category4": "제습제" }, { - "title": "생활<\/b>공작소 대용량제습제 옷장제습제 화장실제습제 24개", + "title": "생활공작소 대용량제습제 옷장제습제 화장실제습제 24개", "link": "https:\/\/smartstore.naver.com\/main\/products\/4905164407", "image": "https:\/\/shopping-phinf.pstatic.net\/main_8244968\/82449688071.14.jpg", "lprice": "20900", @@ -448,7 +448,7 @@ "category4": "리퀴드" }, { - "title": "생활<\/b>공작소 실리카겔제습제 옷장제습제 서랍제습제 20개", + "title": "생활공작소 실리카겔제습제 옷장제습제 서랍제습제 20개", "link": "https:\/\/smartstore.naver.com\/main\/products\/4573567912", "image": "https:\/\/shopping-phinf.pstatic.net\/main_8211808\/82118088066.9.jpg", "lprice": "11500", @@ -2896,7 +2896,7 @@ "category4": "차량용방향제" }, { - "title": "캠핑슬립 라이트 SUV 차박매트 트렁크 매트리스 차량용 평탄화 차박용품<\/b> 엠보그레이", + "title": "캠핑슬립 라이트 SUV 차박매트 트렁크 매트리스 차량용 평탄화 차박용품 엠보그레이", "link": "https:\/\/smartstore.naver.com\/main\/products\/5960280549", "image": "https:\/\/shopping-phinf.pstatic.net\/main_8350478\/83504780037.7.jpg", "lprice": "139000", @@ -3198,5 +3198,2245 @@ "category2": "생활용품", "category3": "섬유유연제", "category4": "고농축섬유유연제" + }, + { + "title": "바비온 슬리커 자동 털제거 빗 쓱싹 핀 브러쉬 112ZR 오렌지, M", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53663904900", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5366390\/53663904900.20250320100513.jpg", + "lprice": "15900", + "hprice": "", + "mallName": "네이버", + "productId": "53663904900", + "productType": "1", + "brand": "바비온", + "maker": "바비온", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "브러시\/빗" + }, + { + "title": "카스테라 강아지 방석 고양이 마약쿠션 커버분리 코스트코 켄넬 대형 대형견 방석 M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7223807949", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8476830\/84768308271.11.jpg", + "lprice": "24900", + "hprice": "", + "mallName": "킁킁펫", + "productId": "84768308271", + "productType": "2", + "brand": "킁킁펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "가르르 오로라 캣타워 고양이 캣폴 알루미늄+삼줄기둥 일반세트", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8406568596", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8595106\/85951068919.43.jpg", + "lprice": "230000", + "hprice": "", + "mallName": "가르르", + "productId": "85951068919", + "productType": "2", + "brand": "가르르", + "maker": "가르르", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "캣타워\/캣폴" + }, + { + "title": "스타일러그 강아지매트 고양이 애견 미끄럼방지 펫 반려견 카페트 바닥 방수 러그 거실", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53705940330", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5370594\/53705940330.20250404094459.jpg", + "lprice": "18900", + "hprice": "", + "mallName": "네이버", + "productId": "53705940330", + "productType": "1", + "brand": "스타일러그", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "LUAZ 강아지 밥그릇 물그릇 고양이 식기 물통 LUAZ-DW01", + "link": "https:\/\/search.shopping.naver.com\/catalog\/36321905955", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3632190\/36321905955.20240331031626.jpg", + "lprice": "8500", + "hprice": "", + "mallName": "네이버", + "productId": "36321905955", + "productType": "1", + "brand": "LUAZ", + "maker": "루아즈", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "토마고 강아지 고양이 바리깡 미니 미용기 발 부분 털 발털 클리퍼 발바닥 이발기 화이트", + "link": "https:\/\/smartstore.naver.com\/main\/products\/2184526789", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_1228498\/12284980671.36.jpg", + "lprice": "24800", + "hprice": "", + "mallName": "펫방앗간", + "productId": "12284980671", + "productType": "2", + "brand": "토마고", + "maker": "케이엘테크", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "이발기" + }, + { + "title": "강아지 고양이 숨숨집 하우스 텐트 실외 길냥이 길고양이 집 플라스틱 개집", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10037143546", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8758164\/87581646050.jpg", + "lprice": "35900", + "hprice": "", + "mallName": "미우프", + "productId": "87581646050", + "productType": "2", + "brand": "UNKNOWN", + "maker": "UNKNOWN", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "실리어스 푸우형 실리콘 강아지매트, 미끄럼방지 애견 롤매트 펫 러그 140x100cm", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8719169350", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8626366\/86263669673.1.jpg", + "lprice": "83000", + "hprice": "", + "mallName": "실리어스", + "productId": "86263669673", + "productType": "2", + "brand": "실리어스", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "사롬사리 강아지 쿨매트 고양이 애견 여름 냉감 패드", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53670171320", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5367017\/53670171320.20250408070603.jpg", + "lprice": "18500", + "hprice": "", + "mallName": "네이버", + "productId": "53670171320", + "productType": "1", + "brand": "사롬사리", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "[세이버 퐁고 2.0] 펫드라이룸 중형견케어 강아지 고양이 간편 털말리기 애견 애묘 건조기", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11102041334", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8864655\/88646551656.5.jpg", + "lprice": "1190000", + "hprice": "", + "mallName": "세이버 공식몰", + "productId": "88646551656", + "productType": "2", + "brand": "세이버", + "maker": "세이버", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "드라이기\/드라이룸" + }, + { + "title": "멍묘인 강아지 2.0텐트 M 집 고양이 숨숨집 예쁜 하우스 개 애견 방석 없음", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5776179111", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8332067\/83320678525.4.jpg", + "lprice": "22900", + "hprice": "", + "mallName": "멍묘인", + "productId": "83320678525", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "LUAZ 애견 강아지 방석 고양이 쿠션 담요 이불 LUAZ-DG6", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54279064807", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5427906\/54279064807.20250502103826.jpg", + "lprice": "7500", + "hprice": "", + "mallName": "네이버", + "productId": "54279064807", + "productType": "1", + "brand": "LUAZ", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "스니프 칠링칠링 듀라론 애견 강아지쿨매트 여름용 반려동물 쿨방석", + "link": "https:\/\/search.shopping.naver.com\/catalog\/33242151678", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3324215\/33242151678.20250514090745.jpg", + "lprice": "18900", + "hprice": "", + "mallName": "네이버", + "productId": "33242151678", + "productType": "1", + "brand": "스니프", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "접촉냉감 누빔 강아지 쿨매트 고양이 아이스 패드 냉감 매트 M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10615040891", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8815954\/88159546540.7.jpg", + "lprice": "26800", + "hprice": "", + "mallName": "올웨이즈올펫", + "productId": "88159546540", + "productType": "2", + "brand": "올웨이즈올펫", + "maker": "지오위즈", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "올웨이즈올펫 딩굴 강아지매트 고양이 미끄럼방지 슬개골예방 롤 매트 110x50x0.6cm", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5311346622", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8285583\/82855839069.40.jpg", + "lprice": "10800", + "hprice": "", + "mallName": "올웨이즈올펫", + "productId": "82855839069", + "productType": "2", + "brand": "올웨이즈올펫", + "maker": "지오위즈", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "비엔메이드 무드 롤 시공 강아지매트 애견 방수 미끄럼방지 고양이 매트 70cm X 0.5M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8490392547", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8603489\/86034892870.1.jpg", + "lprice": "9900", + "hprice": "", + "mallName": "비엔메이드", + "productId": "86034892870", + "productType": "2", + "brand": "비엔메이드", + "maker": "신영인더스", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "가티가티 고양이식기 강아지밥그릇 식탁 1구식기세트 빈티지로즈", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5354078062", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8289857\/82898571031.3.jpg", + "lprice": "26400", + "hprice": "", + "mallName": "가티몰", + "productId": "82898571031", + "productType": "2", + "brand": "가티가티", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "올웨이즈올펫 강아지 쿨방석 고양이 냉감 아이스 쿨쿠션 M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8501680564", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8604618\/86046180887.10.jpg", + "lprice": "49800", + "hprice": "", + "mallName": "올웨이즈올펫", + "productId": "86046180887", + "productType": "3", + "brand": "펫토", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "슈퍼벳 레날 에이드 280mg x 60캡슐, 1개", + "link": "https:\/\/search.shopping.naver.com\/catalog\/52539061038", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5253906\/52539061038.20250117155343.jpg", + "lprice": "28700", + "hprice": "", + "mallName": "네이버", + "productId": "52539061038", + "productType": "1", + "brand": "슈퍼벳", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "테일로그 탈출방지 고양이 방묘창 캣키퍼 1개 창문 높이 85", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53922016884", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5392201\/53922016884.20250403011953.jpg", + "lprice": "32000", + "hprice": "", + "mallName": "네이버", + "productId": "53922016884", + "productType": "1", + "brand": "테일로그", + "maker": "테일로그", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "안전문" + }, + { + "title": "[케어사이드] 강아지 고양이 헤파카디오 Q10 60정 심장보조영양제 CARESIDE HEPACARDIO", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7102910072", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8464741\/84647410394.5.jpg", + "lprice": "18990", + "hprice": "", + "mallName": "예쁘개냥", + "productId": "84647410394", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "접이식 강아지 고양이 해먹 침대 대형견해먹 캠핑 의자 S", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5769443200", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8331394\/83313942614.2.jpg", + "lprice": "28000", + "hprice": "", + "mallName": "멍심사냥", + "productId": "83313942614", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "침대\/해먹" + }, + { + "title": "[페스룸] 네이처 이어 클리너 강아지 고양이 귀세정제 귀청소 귓병 예방", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4792716744", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8233723\/82337239241.3.jpg", + "lprice": "15900", + "hprice": "", + "mallName": "PETHROOM", + "productId": "82337239241", + "productType": "2", + "brand": "페스룸", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "눈\/귀 관리용품" + }, + { + "title": "키즈온더블럭 펫도어 견문 강아지 고양이 안전문 베란다 펫도어 시공 미니", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7918440666", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8546294\/85462940989.10.jpg", + "lprice": "98000", + "hprice": "", + "mallName": "키즈온더블럭", + "productId": "85462940989", + "productType": "2", + "brand": "키즈온더블럭", + "maker": "아이작", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "안전문" + }, + { + "title": "퍼키퍼키 강아지밥그릇 고양이밥그릇 물그릇 애견 식기 높이조절 식탁 세트", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10268762667", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8781326\/87813266469.16.jpg", + "lprice": "27900", + "hprice": "", + "mallName": "퍼키퍼키", + "productId": "87813266469", + "productType": "2", + "brand": "퍼키퍼키", + "maker": "퍼키퍼키", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "펫테일 견분무취 200g, 1개", + "link": "https:\/\/search.shopping.naver.com\/catalog\/51929267504", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5192926\/51929267504.20241213211322.jpg", + "lprice": "18900", + "hprice": "", + "mallName": "네이버", + "productId": "51929267504", + "productType": "1", + "brand": "펫테일", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "펫코본 고양이밥그릇 물그릇 강아지 1구 투명 유리 식기 수반", + "link": "https:\/\/search.shopping.naver.com\/catalog\/51181438556", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5118143\/51181438556.20241211202407.jpg", + "lprice": "16900", + "hprice": "", + "mallName": "네이버", + "productId": "51181438556", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "가또나인 고양이스크래쳐 옐로 레오파드 3PC 스크래쳐 2개", + "link": "https:\/\/smartstore.naver.com\/main\/products\/2058243766", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_1185459\/11854591070.14.jpg", + "lprice": "17900", + "hprice": "", + "mallName": "GATO", + "productId": "11854591070", + "productType": "2", + "brand": "가또나인", + "maker": "빅트리", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "DUIT 올데이보드 고양이 스크래쳐 장난감", + "link": "https:\/\/search.shopping.naver.com\/catalog\/33691361489", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3369136\/33691361489.20241015154005.jpg", + "lprice": "28000", + "hprice": "", + "mallName": "네이버", + "productId": "33691361489", + "productType": "1", + "brand": "DUIT", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "루시몰 고양이 스크래쳐 원형 대형 특대형 기본 46cm", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6659642344", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8420414\/84204142666.13.jpg", + "lprice": "19000", + "hprice": "", + "mallName": "Lusi mall", + "productId": "84204142666", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "강아지 이불 블랭킷 고양이 담요 펫 애견 쿠션 더블유곰 소", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8671921224", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8621642\/86216421547.jpg", + "lprice": "10900", + "hprice": "", + "mallName": "해피앤퍼피", + "productId": "86216421547", + "productType": "2", + "brand": "", + "maker": "해피앤퍼피", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "씨리얼펫 젤리냥수기 고양이 세라믹 정수기 반려동물 필터 음수기 1.2L", + "link": "https:\/\/search.shopping.naver.com\/catalog\/30431203499", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3043120\/30431203499.20250222214801.jpg", + "lprice": "49900", + "hprice": "", + "mallName": "네이버", + "productId": "30431203499", + "productType": "1", + "brand": "씨리얼펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "정수기\/필터" + }, + { + "title": "수의사가 만든 라퓨클레르 강아지 고양이 샴푸 저자극 보습 목욕 300ml", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10582992973", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8812749\/88127498563.9.jpg", + "lprice": "19900", + "hprice": "", + "mallName": "라퓨클레르", + "productId": "88127498563", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "샴푸\/린스\/비누" + }, + { + "title": "22kg까지 견디는 고양이 해먹 윈도우 해먹 창문해먹", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4709037976", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8225355\/82253558998.2.jpg", + "lprice": "6900", + "hprice": "", + "mallName": "홈앤스위트", + "productId": "82253558998", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "침대\/해먹" + }, + { + "title": "바비온 9in1 올마스터 진공 흡입 미용기 강아지 고양이 이발기 바리깡 클리퍼 셀프미용", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10352906076", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8789741\/87897410549.18.jpg", + "lprice": "179000", + "hprice": "", + "mallName": "바비온코리아", + "productId": "87897410549", + "productType": "2", + "brand": "바비온", + "maker": "바비온", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "이발기" + }, + { + "title": "MOOQS 묵스 우드 스노우 펫 하우스 강아지집 숨숨집 고양이집", + "link": "https:\/\/search.shopping.naver.com\/catalog\/40031843151", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_4003184\/40031843151.20250316173117.jpg", + "lprice": "125000", + "hprice": "", + "mallName": "네이버", + "productId": "40031843151", + "productType": "1", + "brand": "MOOQS", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "강아지 샴푸 올인원 린스 100% 천연 약용 각질 비듬 아토피 피부병 고양이겸용 270ml", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4737618345", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8228213\/82282139809.9.jpg", + "lprice": "36000", + "hprice": "", + "mallName": "지켜줄개 댕댕아", + "productId": "82282139809", + "productType": "2", + "brand": "지켜줄개댕댕아", + "maker": "지켜줄개댕댕아", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "샴푸\/린스\/비누" + }, + { + "title": "강아지 고양이 넥카라 깔대기 목보호대 애견 중성화 쿠션 중형견 피너츠 엘리자베스 그레이M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/3973660933", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8151818\/81518181158.16.jpg", + "lprice": "9800", + "hprice": "", + "mallName": "르쁘띠숑", + "productId": "81518181158", + "productType": "2", + "brand": "패리스독", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "넥카라\/보호대" + }, + { + "title": "코드 헬씨에이징 항산화 영양 보조제 2g x 30포, 1개", + "link": "https:\/\/search.shopping.naver.com\/catalog\/51929018110", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5192901\/51929018110.20241213202545.jpg", + "lprice": "35900", + "hprice": "", + "mallName": "네이버", + "productId": "51929018110", + "productType": "1", + "brand": "", + "maker": "코스맥스펫", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "세이펫 접이식 안전문 1.5m 고양이 접이식 방묘문", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4937924597", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8248244\/82482448908.10.jpg", + "lprice": "142000", + "hprice": "", + "mallName": "세이펫", + "productId": "82482448908", + "productType": "2", + "brand": "세이펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "안전문" + }, + { + "title": "고양이 난간 안전망 복층 베란다 방묘창 방묘문 방충망 캣도어 일반형검정1mx1m", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6187449408", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8373194\/83731948985.5.jpg", + "lprice": "5000", + "hprice": "", + "mallName": "나이스메쉬", + "productId": "83731948985", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "안전문" + }, + { + "title": "티지오매트 우다다 강아지매트 애견 롤 미끄럼방지 거실 복도 펫 110x50cm (10T)", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5154283552", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8269880\/82698804475.15.jpg", + "lprice": "10900", + "hprice": "", + "mallName": "티지오매트", + "productId": "82698804475", + "productType": "2", + "brand": "티지오매트", + "maker": "티지오", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "[페스룸] 논슬립 폴더블 욕조 강아지 고양이 목욕 접이식 스파욕조 애견욕조", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5534035049", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8307853\/83078530731.2.jpg", + "lprice": "51900", + "hprice": "", + "mallName": "PETHROOM", + "productId": "83078530731", + "productType": "2", + "brand": "페스룸", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "샤워기\/욕조" + }, + { + "title": "제스퍼우드 원목 강아지 침대 S 애견 고양이 집 하우스 반려견 반려묘 반려동물 쿠션", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4504272686", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8204879\/82048795634.4.jpg", + "lprice": "55000", + "hprice": "", + "mallName": "제스퍼우드공방", + "productId": "82048795634", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "침대\/해먹" + }, + { + "title": "펫코본 강아지집 원목 고양이 숨숨집 애견방석 강아지하우스 아이보리, M", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54190213755", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5419021\/54190213755.20250414164048.jpg", + "lprice": "49000", + "hprice": "", + "mallName": "네이버", + "productId": "54190213755", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "[베토퀴놀][냉장배송] 강아지 고양이 아조딜 90캡슐 - 신장질환 보조제", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5572133410", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8311662\/83116629447.11.jpg", + "lprice": "75000", + "hprice": "", + "mallName": "블리펫89", + "productId": "83116629447", + "productType": "2", + "brand": "", + "maker": "베토퀴놀", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "오구구 강아지 고양이 정수기 분수대", + "link": "https:\/\/search.shopping.naver.com\/catalog\/29974021619", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_2997402\/29974021619.20211206154812.jpg", + "lprice": "29800", + "hprice": "", + "mallName": "네이버", + "productId": "29974021619", + "productType": "1", + "brand": "오구구", + "maker": "HOLYTACHI", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "정수기\/필터" + }, + { + "title": "강아지 방석 쿠션 애견 마약 반려견 꿀잠 개 본능 무중력 중형견 애완견 방석 S", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5783071611", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8332757\/83327571025.6.jpg", + "lprice": "29900", + "hprice": "", + "mallName": "알록달록댕댕샵", + "productId": "83327571025", + "productType": "2", + "brand": "쉼쉼", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "레토 고양이 숨숨집 2단 방석 쿠션 하우스 스크래쳐", + "link": "https:\/\/search.shopping.naver.com\/catalog\/45872181967", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_4587218\/45872181967.20250523124214.jpg", + "lprice": "18170", + "hprice": "", + "mallName": "네이버", + "productId": "45872181967", + "productType": "1", + "brand": "레토", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "바라바 강아지 안전문 견문 애견 고양이 방묘문 베란다 펫도어", + "link": "https:\/\/search.shopping.naver.com\/catalog\/35924635714", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3592463\/35924635714.20231129051432.jpg", + "lprice": "29800", + "hprice": "", + "mallName": "네이버", + "productId": "35924635714", + "productType": "1", + "brand": "바라바", + "maker": "바라바", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "안전문" + }, + { + "title": "라우라반 강아지밥그릇 물그릇 고양이 식탁 도자기 높이 조절 식기 그릇 수반", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10130414591", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8767491\/87674917667.1.jpg", + "lprice": "19500", + "hprice": "", + "mallName": "라우라반", + "productId": "87674917667", + "productType": "2", + "brand": "라우라반", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "강아지 고양이 빗 스팀 브러쉬 털청소기 스팀빗", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10069170353", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8761367\/87613672977.17.jpg", + "lprice": "11900", + "hprice": "", + "mallName": "캣트럴파크", + "productId": "87613672977", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "브러시\/빗" + }, + { + "title": "비니비니펫 아지트 스크래처 고양이 스크래쳐 대형 숨숨집 하우스 스크래쳐", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10280963095", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8782546\/87825466919.13.jpg", + "lprice": "37900", + "hprice": "", + "mallName": "비니비니펫", + "productId": "87825466919", + "productType": "2", + "brand": "비니비니펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "퓨어프렌즈 퓨어 밸런스 천연 강아지 샴푸 300ml, 1개", + "link": "https:\/\/search.shopping.naver.com\/catalog\/52203429639", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5220342\/52203429639.20250331163115.jpg", + "lprice": "23500", + "hprice": "", + "mallName": "네이버", + "productId": "52203429639", + "productType": "1", + "brand": "퓨어프렌즈", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "샴푸\/린스\/비누" + }, + { + "title": "고양이 밥그릇 도자기 세라믹 급체방지 슬로우 식기 그릇 높이 식탁", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6131993369", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8367649\/83676492857.2.jpg", + "lprice": "9400", + "hprice": "", + "mallName": "마브펫", + "productId": "83676492857", + "productType": "2", + "brand": "마브펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "강아지 고양이 아이스팩 파우치 여름 베개 젤리곰 M사이즈", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8554743594", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8609924\/86099243917.3.jpg", + "lprice": "9900", + "hprice": "", + "mallName": "예쁘개살고양", + "productId": "86099243917", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "강아지 고양이 애견 대형견 하우스 텐트 야외개집 숨숨집 S", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7626829741", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8517133\/85171330063.1.jpg", + "lprice": "24000", + "hprice": "", + "mallName": "미우프", + "productId": "85171330063", + "productType": "2", + "brand": "UNKNOWN", + "maker": "UNKNOWN", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "이너피스 원목 강아지집 애견하우스 고양이숨숨집 A", + "link": "https:\/\/smartstore.naver.com\/main\/products\/3307441934", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8080606\/80806066376.14.jpg", + "lprice": "79000", + "hprice": "", + "mallName": "innerpeace이너피스", + "productId": "80806066376", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "펫토 알러프리 강아지방석 고양이 애견 쿠션 쿨방석 범퍼형 라이트그레이, M", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54236867637", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5423686\/54236867637.20250416115734.jpg", + "lprice": "49800", + "hprice": "", + "mallName": "네이버", + "productId": "54236867637", + "productType": "1", + "brand": "펫토", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "원시림의곰 금빗", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54233894193", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5423389\/54233894193.20250416084020.jpg", + "lprice": "65700", + "hprice": "", + "mallName": "네이버", + "productId": "54233894193", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "브러시\/빗" + }, + { + "title": "원목 캣타워 캣워커 캣폴 고양이에버랜드 2 (고양이와나무꾼)", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4701485622", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8224600\/82246006480.11.jpg", + "lprice": "312000", + "hprice": "", + "mallName": "고양이와나무꾼", + "productId": "82246006480", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "캣타워\/캣폴" + }, + { + "title": "펫펫펫 고양이 스크래쳐 수직 대형", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5491461598", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8303595\/83035956658.4.jpg", + "lprice": "26700", + "hprice": "", + "mallName": "펫펫펫 PPPET", + "productId": "83035956658", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "슈퍼펫 강아지밥그릇 고양이 식기 물그릇 3단 높이조절 커브 도자기 식탁세트", + "link": "https:\/\/search.shopping.naver.com\/catalog\/55401583212", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5540158\/55401583212.20250621045841.jpg", + "lprice": "22900", + "hprice": "", + "mallName": "네이버", + "productId": "55401583212", + "productType": "1", + "brand": "슈퍼펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "펫테일 올독방석 강아지 방석 대형견 쿠션 극세사 면 M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4827270040", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8237179\/82371792892.3.jpg", + "lprice": "24900", + "hprice": "", + "mallName": "펫테일코리아", + "productId": "82371792892", + "productType": "2", + "brand": "펫테일", + "maker": "주떼인터내셔날", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "펫조은자리 듀라론 100% 강아지 쿨매트 3D에어매쉬 냉감패드 애견 고양이 여름방석", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11697645474", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8924215\/89242155941.1.jpg", + "lprice": "39800", + "hprice": "", + "mallName": "영메디칼바이오", + "productId": "89242155941", + "productType": "2", + "brand": "", + "maker": "영메디칼바이오", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "까치토 더보틀 휴대용 강아지 고양이 물통 애견 산책물병 원터치 급수기", + "link": "https:\/\/smartstore.naver.com\/main\/products\/9561639195", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8710614\/87106141465.7.jpg", + "lprice": "9800", + "hprice": "", + "mallName": "까치토", + "productId": "87106141465", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "급수기\/물병" + }, + { + "title": "펫모어 오메가침대 여름 방수 쿨매트 슬개골 강아지침대 펫 베드 애견 방석 고양이쇼파 소파 [국내생산]", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6096500544", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8364100\/83641000032.2.jpg", + "lprice": "59000", + "hprice": "", + "mallName": "미르공간", + "productId": "83641000032", + "productType": "2", + "brand": "펫모어", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "침대\/해먹" + }, + { + "title": "이츠독 강아지 고양이 쿨매트 인견 방석 여름 애견 쿨링 패드", + "link": "https:\/\/smartstore.naver.com\/main\/products\/2964096923", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8046184\/80461840901.1.jpg", + "lprice": "32000", + "hprice": "", + "mallName": "이츠독", + "productId": "80461840901", + "productType": "2", + "brand": "이츠독", + "maker": "이츠독", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "펫쭈 고양이 AI 자동급식기 강아지 360도 회전 카메라 반려동물 펫", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10420577952", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8796508\/87965082938.17.jpg", + "lprice": "273900", + "hprice": "", + "mallName": "펫쭈", + "productId": "87965082938", + "productType": "2", + "brand": "펫쭈", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "자동급식기" + }, + { + "title": "올웨이즈올펫 코닉 숨숨집 고양이 강아지 하우스 그레이, M", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53665784947", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5366578\/53665784947.20250320141714.jpg", + "lprice": "25400", + "hprice": "", + "mallName": "네이버", + "productId": "53665784947", + "productType": "1", + "brand": "올웨이즈올펫", + "maker": "지오위즈", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "펫초이스 댕피스텔 강아지 텐트 고양이 쿠션 숨숨 집 하우스 크림 크림, S", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54190191811", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5419019\/54190191811.20250429171332.jpg", + "lprice": "38900", + "hprice": "", + "mallName": "네이버", + "productId": "54190191811", + "productType": "1", + "brand": "펫초이스", + "maker": "프랑코모다", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "고양이 스크래쳐 숨숨집 하우스 대형 원목 스크레쳐 A type", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8137026692", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8568152\/85681527015.2.jpg", + "lprice": "14900", + "hprice": "", + "mallName": "미우프", + "productId": "85681527015", + "productType": "2", + "brand": "UNKNOWN", + "maker": "UNKNOWN", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "제로넥카라 강아지 고양이 초경량 가벼운 편안한 중성화 미용 깔대기 실내용 넥카라", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7499603619", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8504410\/85044103941.jpg", + "lprice": "24000", + "hprice": "", + "mallName": "루니펫", + "productId": "85044103941", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "넥카라\/보호대" + }, + { + "title": "펫토 클린펫 강아지 계단 고양이 논슬립 스텝 라이트그레이, 2단", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54892869310", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5489286\/54892869310.20250521143121.jpg", + "lprice": "49800", + "hprice": "", + "mallName": "네이버", + "productId": "54892869310", + "productType": "1", + "brand": "펫토", + "maker": "펫토", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "계단\/스텝" + }, + { + "title": "[폴딩70x60cm] 디팡 4mm 미끄럼방지 강아지 고양이매트 애견매트 슬개골탈구예방", + "link": "https:\/\/smartstore.naver.com\/main\/products\/2122490803", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_1206556\/12065560134.58.jpg", + "lprice": "14800", + "hprice": "", + "mallName": "디팡", + "productId": "12065560134", + "productType": "2", + "brand": "디팡", + "maker": "디팡", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "슈퍼벳 안티콜록 강아지 기관지 영양제 협착증 호흡기 기침 약x 60캡슐", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8470675034", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8601517\/86015175357.5.jpg", + "lprice": "25020", + "hprice": "", + "mallName": "슈퍼벳", + "productId": "86015175357", + "productType": "2", + "brand": "슈퍼벳", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "HAKKI 강아지 해먹 대형견쿨매트 애견침대 블랙색상 S", + "link": "https:\/\/smartstore.naver.com\/main\/products\/3477192248", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8102170\/81021709385.jpg", + "lprice": "18800", + "hprice": "", + "mallName": "돈키호테쇼핑몰", + "productId": "81021709385", + "productType": "2", + "brand": "", + "maker": "돈키호테", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "침대\/해먹" + }, + { + "title": "링펫 강아지 고양이 물그릇 밥그릇 식기 아크릴 유리수반 중형", + "link": "https:\/\/search.shopping.naver.com\/catalog\/33629233457", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3362923\/33629233457.20250512014917.jpg", + "lprice": "18900", + "hprice": "", + "mallName": "네이버", + "productId": "33629233457", + "productType": "1", + "brand": "링펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "페노비스 고양이 강아지 치약 바르는 입냄새 플라그 구취 치석 제거 임상균주 오랄벳", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10800961164", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8834546\/88345467154.4.jpg", + "lprice": "15900", + "hprice": "", + "mallName": "페노비스", + "productId": "88345467154", + "productType": "2", + "brand": "페노비스", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "치약" + }, + { + "title": "네네린도 수직 월 고양이 스크래쳐 웜 화이트, L(대형)", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54114571823", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5411457\/54114571823.20250411160223.jpg", + "lprice": "21400", + "hprice": "", + "mallName": "네이버", + "productId": "54114571823", + "productType": "1", + "brand": "네네린도", + "maker": "네네린도", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "리포소펫 강아지매트 미끄럼방지 애견 반려견 거실 복도 셀프시공 롤매트 6T 110X50cm", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5151541190", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8269606\/82696062046.45.jpg", + "lprice": "11400", + "hprice": "", + "mallName": "리포소펫", + "productId": "82696062046", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "페노비스 고양이 강아지 관절영양제 슬개골 연골 관절염 노견영양제 캡슐 벳 글루코사민", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11149454290", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8869396\/88693964612.5.jpg", + "lprice": "22900", + "hprice": "", + "mallName": "페노비스", + "productId": "88693964612", + "productType": "2", + "brand": "페노비스", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "펫코본 강아지계단 고양이 논슬립 애견 펫스텝 침대 슬라이드 A형", + "link": "https:\/\/search.shopping.naver.com\/catalog\/55343999616", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5534399\/55343999616.20250618102528.jpg", + "lprice": "59000", + "hprice": "", + "mallName": "네이버", + "productId": "55343999616", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "계단\/스텝" + }, + { + "title": "보울보울 고양이 밥그릇 강아지 식기 헬로볼 세트", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5108893506", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8265341\/82653415552.10.jpg", + "lprice": "31900", + "hprice": "", + "mallName": "보울보울", + "productId": "82653415552", + "productType": "2", + "brand": "보울보울", + "maker": "보울보울", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "강아지방석 고양이 쿠션 매트 유모차 개모차 개 꿀잠 이불 원터치 떠블유곰 소", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8571815502", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8611631\/86116315825.jpg", + "lprice": "32000", + "hprice": "", + "mallName": "해피앤퍼피", + "productId": "86116315825", + "productType": "2", + "brand": "", + "maker": "해피앤퍼피", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "큐브플래닛 윈도우 고양이 선반 해먹 캣워커 캣선반 소형 (창문, 창틀에 설치하세요)", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5660301120", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8320479\/83204798455.9.jpg", + "lprice": "19800", + "hprice": "", + "mallName": "큐브 플래닛", + "productId": "83204798455", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "캣타워\/캣폴" + }, + { + "title": "아껴주다 저자극 천연 고양이 샴푸 500ml (고양이 비듬, 턱드름 관리)", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5054264001", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8259878\/82598785222.12.jpg", + "lprice": "18500", + "hprice": "", + "mallName": "아껴주다", + "productId": "82598785222", + "productType": "2", + "brand": "아껴주다", + "maker": "아껴주다", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "샴푸\/린스\/비누" + }, + { + "title": "하개랩 상쾌하개 강아지 고양이 기관지 영양제 협착증 기침 켁켁거림", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10078212989", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8762271\/87622715642.2.jpg", + "lprice": "25000", + "hprice": "", + "mallName": "하개 LAB", + "productId": "87622715642", + "productType": "2", + "brand": "하개LAB", + "maker": "칠명바이오", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "강아지 방석 대형견 애견 쿠션 포근한 반려견 침대 그레이 L", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5652281382", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8319677\/83196778686.41.jpg", + "lprice": "19800", + "hprice": "", + "mallName": "펫브랜디", + "productId": "83196778686", + "productType": "2", + "brand": "펫브랜디", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "네코이찌 고양이 발톱깍이", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53669243993", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5366924\/53669243993.20250320194701.jpg", + "lprice": "15900", + "hprice": "", + "mallName": "네이버", + "productId": "53669243993", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "발톱\/발 관리" + }, + { + "title": "펠리웨이 클래식 스타터키트 고양이 페로몬 디퓨저 이사 동물병원외출 스트레스완화 진정", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11486023143", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8903053\/89030533508.jpg", + "lprice": "34000", + "hprice": "", + "mallName": "MOKOA", + "productId": "89030533508", + "productType": "2", + "brand": "펠리웨이", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "에센스\/향수\/밤" + }, + { + "title": "위티 강아지 빗 콤빗 고양이 슬리커 브러쉬", + "link": "https:\/\/smartstore.naver.com\/main\/products\/9970804750", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8751530\/87515307023.2.jpg", + "lprice": "8900", + "hprice": "", + "mallName": "위티witty", + "productId": "87515307023", + "productType": "2", + "brand": "ouitt", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "브러시\/빗" + }, + { + "title": "보니렌 퓨어냥 고양이 정수기 강아지정수기 고양이 음수대 자동급수기", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11364128365", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8890863\/88908638730.5.jpg", + "lprice": "59900", + "hprice": "", + "mallName": "보니렌", + "productId": "88908638730", + "productType": "2", + "brand": "보니렌", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "정수기\/필터" + }, + { + "title": "탑컷 애견이발기 YD9000 프로 클리퍼 강아지 고양이 미용 바리깡", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5238078134", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8278260\/82782600545.6.jpg", + "lprice": "90000", + "hprice": "", + "mallName": "탑컷", + "productId": "82782600545", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "이발기" + }, + { + "title": "세임스텝 [무선] 애견 미용기 클리퍼 강아지 고양이 바리깡 셀프 펫 진공 흡입 털 청소기", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11205843632", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8875035\/88750353963.2.jpg", + "lprice": "109900", + "hprice": "", + "mallName": "뉴트로 스토어", + "productId": "88750353963", + "productType": "2", + "brand": "세임스텝", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "이발기" + }, + { + "title": "독톡 강아지 커스텀 울타리 1P", + "link": "https:\/\/smartstore.naver.com\/main\/products\/2426030847", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_1325105\/13251055464.14.jpg", + "lprice": "22500", + "hprice": "", + "mallName": "독톡", + "productId": "13251055464", + "productType": "2", + "brand": "독톡", + "maker": "독톡", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "울타리" + }, + { + "title": "캣튜디오 고양이 유리 물그릇 강아지 밥그릇 식기 수반 유리화이트식기S", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6512908155", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8405740\/84057408488.7.jpg", + "lprice": "7400", + "hprice": "", + "mallName": "캣튜디오", + "productId": "84057408488", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "공간녹백 고양이 캣휠 무소음 켓휠 쳇바퀴 M 1개", + "link": "https:\/\/search.shopping.naver.com\/catalog\/49559295153", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_4955929\/49559295153.20240802032032.jpg", + "lprice": "82000", + "hprice": "", + "mallName": "네이버", + "productId": "49559295153", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "캣휠" + }, + { + "title": "바라바 강아지 밥그릇 고양이 물그릇 애견 도자기 그릇 높이조절 식기 식탁 수반 세트", + "link": "https:\/\/search.shopping.naver.com\/catalog\/50033034869", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5003303\/50033034869.20240829050921.jpg", + "lprice": "28800", + "hprice": "", + "mallName": "네이버", + "productId": "50033034869", + "productType": "1", + "brand": "바라바", + "maker": "바라바", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "이드몽 강아지 넥카라 고양이 애견 깔대기 쿠션 시즌2프라가S", + "link": "https:\/\/search.shopping.naver.com\/catalog\/36713411331", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3671341\/36713411331.20230618043123.jpg", + "lprice": "13900", + "hprice": "", + "mallName": "네이버", + "productId": "36713411331", + "productType": "1", + "brand": "이드몽", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "넥카라\/보호대" + }, + { + "title": "Apple 아이패드 11세대 실버, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370909201", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337090\/53370909201.20250403155536.jpg", + "lprice": "520500", + "hprice": "", + "mallName": "네이버", + "productId": "53370909201", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 11세대 블루, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370758552", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337075\/53370758552.20250403155332.jpg", + "lprice": "525800", + "hprice": "", + "mallName": "네이버", + "productId": "53370758552", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 11세대 핑크, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370808130", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337080\/53370808130.20250403155104.jpg", + "lprice": "527700", + "hprice": "", + "mallName": "네이버", + "productId": "53370808130", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 11세대 옐로, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370875209", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337087\/53370875209.20250403155436.jpg", + "lprice": "525900", + "hprice": "", + "mallName": "네이버", + "productId": "53370875209", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 에어 11 7세대 M3 스페이스그레이, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53371237199", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337123\/53371237199.20250403153417.jpg", + "lprice": "884810", + "hprice": "", + "mallName": "네이버", + "productId": "53371237199", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 11세대 실버, 256GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370909202", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337090\/53370909202.20250403155553.jpg", + "lprice": "679000", + "hprice": "", + "mallName": "네이버", + "productId": "53370909202", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 미니 7세대 스페이스그레이, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53351852199", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5335185\/53351852199.20250304153610.jpg", + "lprice": "696570", + "hprice": "", + "mallName": "네이버", + "productId": "53351852199", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 에어 13 7세대 M3 스페이스그레이, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53371410788", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337141\/53371410788.20250403154146.jpg", + "lprice": "1199040", + "hprice": "", + "mallName": "네이버", + "productId": "53371410788", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 프로 11 5세대 M4 스탠다드 글래스 스페이스 블랙, 256GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53394317288", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5339431\/53394317288.20250306171208.jpg", + "lprice": "1393580", + "hprice": "", + "mallName": "네이버", + "productId": "53394317288", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 프로 13 7세대 M4 스탠다드 글래스 스페이스 블랙, 256GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53491820442", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5349182\/53491820442.20250311162829.jpg", + "lprice": "1897700", + "hprice": "", + "mallName": "네이버", + "productId": "53491820442", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 11세대 블루, 256GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370758553", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337075\/53370758553.20250403155346.jpg", + "lprice": "679000", + "hprice": "", + "mallName": "네이버", + "productId": "53370758553", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "애플 아이패드 11세대 A16 WIFI 128GB 2025출시 관부포함 미국애플정품", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11553506634", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8909801\/89098017040.3.jpg", + "lprice": "459900", + "hprice": "", + "mallName": "제니퍼스토리", + "productId": "89098017040", + "productType": "2", + "brand": "아이패드", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 10세대 실버, 64GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53212173186", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5321217\/53212173186.20250225172035.jpg", + "lprice": "557000", + "hprice": "", + "mallName": "네이버", + "productId": "53212173186", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 2025 아이패드 에어 11 M3 스페이스그레이 128GB Wi-Fi MC9W4KH\/A", + "link": "https:\/\/link.coupang.com\/re\/PCSNAVERPCSDP?pageKey=8820001925&ctag=8820001925&lptag=I25079475724&itemId=25079475724&vendorItemId=92083407421&spec=10305197", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5393557\/53935570413.1.jpg", + "lprice": "884820", + "hprice": "", + "mallName": "쿠팡", + "productId": "53935570413", + "productType": "3", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "미사용 애플 아이패드 미니 5세대 WIFI 64GB 스페이스그레이", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6555981468", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8410048\/84100481801.jpg", + "lprice": "398000", + "hprice": "", + "mallName": "도란:", + "productId": "84100481801", + "productType": "2", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 에어 11 7세대 M3 퍼플, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53371237381", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337123\/53371237381.20250403153732.jpg", + "lprice": "897000", + "hprice": "", + "mallName": "네이버", + "productId": "53371237381", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 9세대 스페이스그레이, 64GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53352561711", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5335256\/53352561711.20250304165819.jpg", + "lprice": "434490", + "hprice": "", + "mallName": "네이버", + "productId": "53352561711", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "[미국당일출고]애플 아이패드 11세대 A16 WIFI 128GB 2025 신제품 미국 정품", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11553327971", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8909783\/89097838377.4.jpg", + "lprice": "459900", + "hprice": "", + "mallName": "뉴욕 스토리", + "productId": "89097838377", + "productType": "2", + "brand": "아이패드", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 에어 13 6세대 M2 퍼플, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53318261103", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5331826\/53318261103.20250303172440.jpg", + "lprice": "1019140", + "hprice": "", + "mallName": "네이버", + "productId": "53318261103", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 프로 11 5세대 M4 스탠다드 글래스 실버, 256GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53394328115", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5339432\/53394328115.20250306172608.jpg", + "lprice": "1392840", + "hprice": "", + "mallName": "네이버", + "productId": "53394328115", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "삼성 노트북 i7 윈도우11프로 사무용 인강용 업무용 교육용 학생 노트북 NT551XDA [소상공인\/기업체 우대]", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10532359076", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8807686\/88076864436.4.jpg", + "lprice": "2598990", + "hprice": "", + "mallName": "삼성온라인몰", + "productId": "88076864436", + "productType": "2", + "brand": "삼성", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53902497170", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5390249\/53902497170.20250401141458.jpg", + "lprice": "3749000", + "hprice": "", + "mallName": "네이버", + "productId": "53902497170", + "productType": "1", + "brand": "ASUS", + "maker": "ASUS", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "ASUS 노트북 비보북15 라이젠7 8GB 512GB 대학생 인강용 사무용 저렴한 포토샵", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11577222869", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8912173\/89121733275.4.jpg", + "lprice": "519000", + "hprice": "", + "mallName": "창이로운", + "productId": "89121733275", + "productType": "2", + "brand": "ASUS", + "maker": "ASUS", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북5 프로 NT960XHA-KP72G 32GB, 512GB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54024331464", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5402433\/54024331464.20250407101024.jpg", + "lprice": "2309980", + "hprice": "", + "mallName": "네이버", + "productId": "54024331464", + "productType": "1", + "brand": "갤럭시북5 프로", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "ASUS 젠북 A14 퀄컴 스냅드래곤X 초경량 사무용 대학생 업무용 노트북 Win11", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11359933656", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8890444\/88904444007.jpg", + "lprice": "1166000", + "hprice": "", + "mallName": "ASUS공식총판 에스라이즈", + "productId": "88904444007", + "productType": "2", + "brand": "ASUS", + "maker": "ASUS", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북5 프로360 NT960QHA-KC71G", + "link": "https:\/\/search.shopping.naver.com\/catalog\/51340833624", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5134083\/51340833624.20241111121622.jpg", + "lprice": "2224980", + "hprice": "", + "mallName": "네이버", + "productId": "51340833624", + "productType": "1", + "brand": "갤럭시북5 프로360", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "주연테크 캐리북e J3GW", + "link": "https:\/\/search.shopping.naver.com\/catalog\/24875454523", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_2487545\/24875454523.20201117114806.jpg", + "lprice": "219000", + "hprice": "", + "mallName": "네이버", + "productId": "24875454523", + "productType": "1", + "brand": "주연테크", + "maker": "주연테크", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "엘지 그램 14세대 울트라 7 AI 인공지능 32GB 1TB 17Z90S 터치 병행", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7049938391", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8459443\/84594438713.11.jpg", + "lprice": "1749000", + "hprice": "", + "mallName": "G-스토어", + "productId": "84594438713", + "productType": "2", + "brand": "LG그램", + "maker": "LG전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북4 NT750XGR-A71A 사무용 업무용 i7 노트북", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10093514318", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8763801\/87638016995.14.jpg", + "lprice": "1098000", + "hprice": "", + "mallName": "삼성공식파트너 코인비엠에스", + "productId": "87638016995", + "productType": "3", + "brand": "갤럭시북4", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "레노버 아이디어패드 Slim3 15ABR8 82XM00ELKR RAM 16GB, 512GB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54909327778", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5490932\/54909327778.20250522125003.jpg", + "lprice": "559000", + "hprice": "", + "mallName": "네이버", + "productId": "54909327778", + "productType": "1", + "brand": "아이디어패드", + "maker": "레노버", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "MSI 게이밍노트북 17 영상편집 캐드 고사양 i9 13980HX RTX 4070 노트북", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11205471249", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8874998\/88749981580.1.jpg", + "lprice": "1999000", + "hprice": "", + "mallName": "에이치텍 스토어", + "productId": "88749981580", + "productType": "2", + "brand": "MSI", + "maker": "MSI", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북3 NT750XFT-A51A", + "link": "https:\/\/search.shopping.naver.com\/catalog\/39746112618", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3974611\/39746112618.20230502165309.jpg", + "lprice": "798990", + "hprice": "", + "mallName": "네이버", + "productId": "39746112618", + "productType": "1", + "brand": "갤럭시북3", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북4 NT750XGQ-A51A", + "link": "https:\/\/search.shopping.naver.com\/catalog\/46633068618", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_4663306\/46633068618.20240325185204.jpg", + "lprice": "1098990", + "hprice": "", + "mallName": "네이버", + "productId": "46633068618", + "productType": "1", + "brand": "갤럭시북4", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "LG전자 울트라PC 15UD50R-GX56K 8GB, 256GB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54398511102", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5439851\/54398511102.20250424175153.jpg", + "lprice": "558890", + "hprice": "", + "mallName": "네이버", + "productId": "54398511102", + "productType": "1", + "brand": "울트라PC", + "maker": "LG전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "LG전자 그램 프로 16ZD90SP-GX56K 16GB, 256GB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/52647379133", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5264737\/52647379133.20250124115648.jpg", + "lprice": "1466380", + "hprice": "", + "mallName": "네이버", + "productId": "52647379133", + "productType": "1", + "brand": "그램 프로", + "maker": "LG전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "LG전자 LG그램 15ZD90T-GX59K 32GB, 256GB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54672053704", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5467205\/54672053704.20250509164753.jpg", + "lprice": "1668940", + "hprice": "", + "mallName": "네이버", + "productId": "54672053704", + "productType": "1", + "brand": "LG그램", + "maker": "LG전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "HP OMEN 16-xf0052ax 16GB, 1TB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53663904780", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5366390\/53663904780.20250320095528.jpg", + "lprice": "1888950", + "hprice": "", + "mallName": "네이버", + "productId": "53663904780", + "productType": "1", + "brand": "HP", + "maker": "HP", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성노트북 2024 갤럭시북4 NT750XGR-A51A SSD 총 512GB 윈도우11홈", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10164369375", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8770887\/87708872717.jpg", + "lprice": "949000", + "hprice": "", + "mallName": "더하기Shop", + "productId": "87708872717", + "productType": "2", + "brand": "갤럭시북4", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북5 프로360 NT960QHA-KD72 32GB, 1TB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53666908447", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5366690\/53666908447.20250320160726.jpg", + "lprice": "2698990", + "hprice": "", + "mallName": "네이버", + "productId": "53666908447", + "productType": "1", + "brand": "갤럭시북5 프로360", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "LG그램 노트북 가벼운 가성비 그램 AI AMD 크라켄5 16GB", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11859744023", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8940425\/89404254616.jpg", + "lprice": "1199000", + "hprice": "", + "mallName": "카인드스토어몰", + "productId": "89404254616", + "productType": "2", + "brand": "LG전자", + "maker": "LG전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" } ] diff --git a/src/setupTests.js b/src/setupTests.js index d0de870d..d72b8905 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1 +1,16 @@ import "@testing-library/jest-dom"; +import { configure } from "@testing-library/dom"; +import { afterAll, beforeAll } from "vitest"; +import { server } from "./__tests__/mockServerHandler.js"; + +configure({ + asyncUtilTimeout: 5000, +}); + +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); +}); + +afterAll(() => { + server.close(); +}); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 00000000..3824a8de --- /dev/null +++ b/src/styles.css @@ -0,0 +1,157 @@ +/* 추가 CSS 스타일 */ + +/* 상품 상세 페이지용 스타일 */ +.product-detail-container { + min-height: calc(100vh - 80px); +} + +.breadcrumb-link { + transition: color 0.2s ease; +} + +.breadcrumb-link:hover { + color: #3b82f6; +} + +.related-product-card { + transition: all 0.2s ease; +} + +.related-product-card:hover { + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.product-detail-image { + max-height: 400px; + object-fit: contain; +} + +/* 상품 카드 호버 효과 개선 */ +.product-card { + transition: all 0.2s ease; +} + +.product-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +/* 토스트 애니메이션 */ +@keyframes slide-up { + from { + transform: translateY(100px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.animate-slide-up { + animation: slide-up 0.3s ease-out; +} + +/* 모달 애니메이션 */ +.modal-overlay { + animation: fade-in 0.2s ease-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* 스켈레톤 로딩 */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* 버튼 비활성화 스타일 */ +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 로딩 스피너 */ +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +/* 반응형 그리드 개선 */ +@media (min-width: 640px) { + .responsive-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 768px) { + .responsive-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 1024px) { + .responsive-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* 스크롤바 스타일링 */ +.overflow-y-auto::-webkit-scrollbar { + width: 6px; +} + +.overflow-y-auto::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* 모바일 터치 최적화 */ +@media (max-width: 640px) { + .product-card { + transition: transform 0.1s ease-out; + } + + .product-card:active { + transform: scale(0.98); + } + + button:active { + transform: scale(0.95); + } +} diff --git a/vite.config.js b/vite.config.js index ced41c4c..2eef1c44 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,5 +6,10 @@ export default defineConfig({ environment: "jsdom", setupFiles: "./src/setupTests.js", exclude: ["**/e2e/**", "**/*.e2e.spec.js", "**/node_modules/**"], + poolOptions: { + threads: { + singleThread: true, + }, + }, }, }); From d43fe48dc8312fc72eb366d6e9b42adb5e879751 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Sat, 8 Nov 2025 14:56:19 +0900 Subject: [PATCH 02/43] =?UTF-8?q?=EA=B3=BC=EC=A0=9C=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=B9=88=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 5e62bbda0f4a681fe2d8d01d4245f3d161054f62 Mon Sep 17 00:00:00 2001 From: junilhwang Date: Fri, 26 Sep 2025 10:20:46 +0900 Subject: [PATCH 03/43] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 269 +++- .github/workflows/ci.yml | 27 +- .gitignore | 2 + .prettierrc | 3 +- README.md | 115 -- cart-modal.html | 320 +++++ e2e/E2EHelpers.js | 28 + e2e/app.spec.js | 0 e2e/e2e.advanced.spec.js | 390 ++++++ e2e/e2e.basic.spec.js | 423 ++++++ index.html | 41 +- package.json | 18 +- playwright.config.js | 2 +- pnpm-lock.yaml | 563 ++++++-- requirement.md | 190 +++ src/api/productApi.js | 30 + src/main.js | 1165 +++++++++++++++- src/mocks/browser.js | 2 +- src/mocks/handlers.js | 48 +- src/{ => mocks}/items.json | 2246 +++++++++++++++++++++++++++++- src/setupTests.js | 15 + src/styles.css | 157 +++ vite.config.js | 5 + 23 files changed, 5690 insertions(+), 369 deletions(-) delete mode 100644 README.md create mode 100644 cart-modal.html create mode 100644 e2e/E2EHelpers.js delete mode 100644 e2e/app.spec.js create mode 100644 e2e/e2e.advanced.spec.js create mode 100644 e2e/e2e.basic.spec.js create mode 100644 requirement.md create mode 100644 src/api/productApi.js rename src/{ => mocks}/items.json (59%) create mode 100644 src/styles.css diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f36c3c4b..1fad4f43 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,92 +4,226 @@ - ### 기본과제 -#### 1) 라우팅 구현: -- [ ] History API를 사용하여 SPA 라우터 구현 - - [ ] '/' (홈 페이지) - - [ ] '/login' (로그인 페이지) - - [ ] '/profile' (프로필 페이지) -- [ ] 각 라우트에 해당하는 컴포넌트 렌더링 함수 작성 -- [ ] 네비게이션 이벤트 처리 (링크 클릭 시 페이지 전환) -- [ ] 주소가 변경되어도 새로고침이 발생하지 않아야 한다. - -#### 2) 사용자 관리 기능: -- [ ] LocalStorage를 사용한 간단한 사용자 데이터 관리 - - [ ] 사용자 정보 저장 (이름, 간단한 소개) - - [ ] 로그인 상태 관리 (로그인/로그아웃 토글) -- [ ] 로그인 폼 구현 - - [ ] 사용자 이름 입력 및 검증 - - [ ] 로그인 버튼 클릭 시 LocalStorage에 사용자 정보 저장 -- [ ] 로그아웃 기능 구현 - - [ ] 로그아웃 버튼 클릭 시 LocalStorage에서 사용자 정보 제거 - -#### 3) 프로필 페이지 구현: -- [ ] 현재 로그인한 사용자의 정보 표시 - - [ ] 사용자 이름 - - [ ] 간단한 소개 -- [ ] 프로필 수정 기능 - - [ ] 사용자 소개 텍스트 수정 가능 - - [ ] 수정된 정보 LocalStorage에 저장 - -#### 4) 컴포넌트 기반 구조 설계: -- [ ] 재사용 가능한 컴포넌트 작성 - - [ ] Header 컴포넌트 - - [ ] Footer 컴포넌트 -- [ ] 페이지별 컴포넌트 작성 - - [ ] HomePage 컴포넌트 - - [ ] ProfilePage 컴포넌트 - - [ ] NotFoundPage 컴포넌트 - -#### 5) 상태 관리 초기 구현: -- [ ] 간단한 상태 관리 시스템 설계 - - [ ] 전역 상태 객체 생성 (예: 현재 로그인한 사용자 정보) -- [ ] 상태 변경 함수 구현 - - [ ] 상태 업데이트 시 관련 컴포넌트 리렌더링 - -#### 6) 이벤트 처리 및 DOM 조작: -- [ ] 사용자 입력 처리 (로그인 폼, 프로필 수정 등) -- [ ] 동적 컨텐츠 렌더링 (사용자 정보 표시, 페이지 전환 등) - -#### 7) 라우팅 예외 처리: -- [ ] 잘못된 라우트 접근 시 404 페이지 표시 +#### 상품목록 + +**상품 목록 로딩** + +- [ ] 페이지 접속 시 로딩 상태가 표시된다 +- [ ] 데이터 로드 완료 후 상품 목록이 렌더링된다 +- [ ] 로딩 실패 시 에러 상태가 표시된다 +- [ ] 에러 발생 시 재시도 버튼이 제공된다 + +**상품 목록 조회** + +- [ ] 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 + +**한 페이지에 보여질 상품 수 선택** + +- [ ] 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. +- [ ] 선택 변경 시 즉시 목록에 반영된다 + +**상품 정렬 기능** + +- [ ] 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다. +- [ ] 드롭다운을 통해 정렬 기준을 선택할 수 있다 +- [ ] 정렬 변경 시 즉시 목록에 반영된다 + +**무한 스크롤 페이지네이션** + +- [ ] 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 +- [ ] 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 +- [ ] 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 +- [ ] 홈 페이지에서만 무한 스크롤이 활성화된다 + +**상품을 장바구니에 담기** + +- [ ] 각 상품에 장바구니 추가 버튼이 있다 +- [ ] 버튼 클릭 시 해당 상품이 장바구니에 추가된다 +- [ ] 추가 완료 시 사용자에게 알림이 표시된다 + +**상품 검색** + +- [ ] 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- [ ] 검색 버튼 클릭으로 검색이 수행된다 +- [ ] Enter 키로 검색이 수행된다 +- [ ] 검색어와 일치하는 상품들만 목록에 표시된다 + +**카테고리 선택** + +- [ ] 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- [ ] 선택된 카테고리에 해당하는 상품들만 표시된다 +- [ ] 전체 상품 보기로 돌아갈 수 있다 +- [ ] 2단계 카테고리 구조를 지원한다 (1depth, 2depth) + +**카테고리 네비게이션** + +- [ ] 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- [ ] 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- [ ] "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 + +**현재 상품 수 표시** + +- [ ] 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- [ ] 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 + +#### 장바구니 + +**장바구니 모달** + +- [ ] 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- [ ] X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- [ ] ESC 키로 모달을 닫을 수 있다 +- [ ] 모달에서 장바구니의 모든 기능을 사용할 수 있다 + +**장바구니 수량 조절** + +- [ ] 각 장바구니 상품의 수량을 증가할 수 있다 +- [ ] 각 장바구니 상품의 수량을 감소할 수 있다 +- [ ] 수량 변경 시 총 금액이 실시간으로 업데이트된다 + +**장바구니 삭제** + +- [ ] 각 상품에 삭제 버튼이 배치되어 있다 +- [ ] 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 + +**장바구니 선택 삭제** + +- [ ] 각 상품에 선택을 위한 체크박스가 제공된다 +- [ ] 선택 삭제 버튼이 있다 +- [ ] 체크된 상품들만 일괄 삭제된다 + +**장바구니 전체 선택** + +- [ ] 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- [ ] 전체 선택 시 모든 상품의 체크박스가 선택된다 +- [ ] 전체 해제 시 모든 상품의 체크박스가 해제된다 + +**장바구니 비우기** + +- [ ] 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 + +#### 상품 상세 + +**상품 클릭시 상세 페이지 이동** + +- [ ] 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- [ ] URL이 `/product/{productId}` 형태로 변경된다 +- [ ] 상품의 자세한 정보가 전용 페이지에서 표시된다 + +**상품 상세 페이지 기능** + +- [ ] 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- [ ] 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 + +**상품 상세 - 장바구니 담기** + +- [ ] 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- [ ] 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- [ ] 수량 증가/감소 버튼이 제공된다 + +**관련 상품 기능** + +- [ ] 상품 상세 페이지에서 관련 상품들이 표시된다 +- [ ] 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- [ ] 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- [ ] 현재 보고 있는 상품은 관련 상품에서 제외된다 + +**상품 상세 페이지 내 네비게이션** + +- [ ] 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- [ ] 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- [ ] SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 + +#### 사용자 피드백 시스템 + +**토스트 메시지** + +- [ ] 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- [ ] 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- [ ] 토스트는 3초 후 자동으로 사라진다 +- [ ] 토스트에 닫기 버튼이 제공된다 +- [ ] 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) ### 심화과제 -#### 1) 해시 라우터 구현 -- [ ] location.hash를 이용하여 SPA 라우터 구현 - - [ ] '/#/' (홈 페이지) - - [ ] '/#/login' (로그인 페이지) - - [ ] '/#/profile' (프로필 페이지) - -#### 2) 라우트 가드 구현 -- [ ] 로그인 상태에 따른 접근 제어 -- [ ] 비로그인 사용자의 특정 페이지 접근 시 로그인 페이지로 리다이렉션 +#### SPA 네비게이션 및 URL 관리 + +**페이지 이동** + +- [ ] 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. + +**상품 목록 - URL 쿼리 반영** + +- [ ] 검색어가 URL 쿼리 파라미터에 저장된다 +- [ ] 카테고리 선택이 URL 쿼리 파라미터에 저장된다 +- [ ] 상품 옵션이 URL 쿼리 파라미터에 저장된다 +- [ ] 정렬 조건이 URL 쿼리 파라미터에 저장된다 +- [ ] 조건 변경 시 URL이 자동으로 업데이트된다 +- [ ] URL을 통해 현재 검색/필터 상태를 공유할 수 있다 + +**상품 목록 - 새로고침 시 상태 유지** + +- [ ] 새로고침 후 URL 쿼리에서 검색어가 복원된다 +- [ ] 새로고침 후 URL 쿼리에서 카테고리가 복원된다 +- [ ] 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 +- [ ] 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 +- [ ] 복원된 조건에 맞는 상품 데이터가 다시 로드된다 + +**장바구니 - 새로고침 시 데이터 유지** + +- [ ] 장바구니 내용이 브라우저에 저장된다 +- [ ] 새로고침 후에도 이전 장바구니 내용이 유지된다 +- [ ] 장바구니의 선택 상태도 함께 유지된다 + +**상품 상세 - URL에 ID 반영** + +- [ ] 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) +- [ ] URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 -#### 3) 이벤트 위임 +**상품 상세 - 새로고침시 유지** -- [ ] 이벤트 위임 방식으로 이벤트를 관리하고 있다. +- [ ] 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 + +**404 페이지** + +- [ ] 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 +- [ ] 홈으로 돌아가기 버튼이 제공된다 + +#### AI로 한 번 더 구현하기 + +- [ ] 기존에 구현한 기능을 AI로 다시 구현한다. +- [ ] 이 과정에서 직접 가공하는 것은 최대한 지양한다. ## 과제 셀프회고 ### 기술적 성장 + -### 코드 품질 +### 자랑하고 싶은 코드 + + + +### 개선이 필요하다고 생각하는 코드 + ### 학습 효과 분석 + ### 과제 피드백 + +### AI 활용 경험 공유하기 + + + ## 리뷰 받고 싶은 내용 + + + + + +
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
+ 총 340개의 상품 +
+ +
+
+ +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+
+

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

+

+

+ 220원 +

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

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

+

이지웨이건축자재

+

+ 230원 +

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

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

+

+

+ 280원 +

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

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

+

+

+ 350원 +

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

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

+

+

+ 420원 +

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

+ + + + 장바구니 +

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

장바구니가 비어있습니다

+

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

+
+
+
+
+
+
+
+
+

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

+
+
+ + + + + diff --git a/e2e/E2EHelpers.js b/e2e/E2EHelpers.js new file mode 100644 index 00000000..9067804f --- /dev/null +++ b/e2e/E2EHelpers.js @@ -0,0 +1,28 @@ +export 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 }); + } +} diff --git a/e2e/app.spec.js b/e2e/app.spec.js deleted file mode 100644 index e69de29b..00000000 diff --git a/e2e/e2e.advanced.spec.js b/e2e/e2e.advanced.spec.js new file mode 100644 index 00000000..657eb959 --- /dev/null +++ b/e2e/e2e.advanced.spec.js @@ -0,0 +1,390 @@ +import { expect, test } from "@playwright/test"; +import { E2EHelpers } from "./E2EHelpers.js"; + +// 테스트 설정 +test.describe.configure({ mode: "serial" }); + +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 키로 검색하고 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(); + + // 1차 카테고리 선택 + await page.click("text=생활/건강"); + + await expect(page).toHaveURL(/category1=%EC%83%9D%ED%99%9C%2F%EA%B1%B4%EA%B0%95/); + await expect(page.locator("text=300개")).toBeVisible(); + + // 브레드크럼 확인 + await expect(page.locator("text=카테고리:").locator("..")).toContainText("생활/건강"); + + // 2차 카테고리 선택 + await page.click("text=자동차용품"); + + await expect(page).toHaveURL(/category2=%EC%9E%90%EB%8F%99%EC%B0%A8%EC%9A%A9%ED%92%88/); + await expect(page.locator("text=11개")).toBeVisible(); + + // 브레드크럼에 2차 카테고리도 표시되는지 확인 + await expect(page.locator("text=카테고리:").locator("..")).toContainText("자동차용품"); + await expect(page.locator("text=11개")).toBeVisible(); + + await page.reload(); + await helpers.waitForPageLoad(); + await expect(page.locator("text=11개")).toBeVisible(); + }); + + test("브레드크럼 클릭으로 상위 카테고리로 이동할 수 있다", async ({ page }) => { + const helpers = new E2EHelpers(page); + + // 2차 카테고리 상태에서 시작 + await page.goto("/?current=1&category1=생활%2F건강&category2=자동차용품&search=차량용"); + await helpers.waitForPageLoad(); + await expect(page.locator("text=9개")).toBeVisible(); + + // 1차 카테고리 브레드크럼 클릭 + await page.click("text=생활/건강"); + + await expect(page).toHaveURL(/category1=%EC%83%9D%ED%99%9C%2F%EA%B1%B4%EA%B0%95/); + await expect(page).not.toHaveURL(/category2/); + await expect(page.locator("text=12개")).toBeVisible(); + + // 전체 브레드크럼 클릭 + await page.click("text=전체"); + await expect(page.locator("text=카테고리: 전체 생활/건강 디지털/가전")).toBeVisible(); + + await page.reload(); + await helpers.waitForPageLoad(); + await expect(page.locator("text=카테고리: 전체 생활/건강 디지털/가전")).toBeVisible(); + + await page.fill("#search-input", ""); + await page.press("#search-input", "Enter"); + + await expect(page).not.toHaveURL(/category/); + await expect(page.locator("text=340개")).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.describe("3. URL로 접근시 UI복원", () => { + test("검색어와 필터 조건이 URL에서 복원된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + + // 복잡한 쿼리 파라미터로 직접 접근 + await page.goto("/?search=젤리&category1=생활%2F건강&sort=price_desc&limit=10"); + await helpers.waitForPageLoad(); + + // URL에서 복원된 상태 확인 + 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.locator("text=카테고리:").locator("..")).toContainText("생활/건강"); + }); + }); + + test.describe("4. 상품 상세 페이지", () => { + test("상품 클릭부터 관련 상품 이동까지 전체 플로우", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + await page.evaluate(() => { + window.loadFlag = true; + }); + + // 상품 이미지 클릭하여 상세 페이지로 이동 + const productCard = page + .locator("text=PVC 투명 젤리 쇼핑백") + .locator('xpath=ancestor::*[contains(@class, "product-card")]'); + await productCard.locator("img").click(); + + // URL이 상세 페이지로 변경되었는지 확인 + await expect(page).toHaveURL(/\/product\/\d+/); + + // 상세 페이지 로딩 확인 + 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(); + + // 관련 상품 섹션 확인 + await expect(page.locator("text=관련 상품")).toBeVisible(); + + const relatedProducts = page.locator(".related-product-card"); + await expect(relatedProducts.first()).toBeVisible(); + + // 첫 번째 관련 상품 클릭 + const currentUrl = page.url(); + await relatedProducts.first().click(); + + // 다른 상품의 상세 페이지로 이동했는지 확인 + await expect(page).toHaveURL(/\/product\/\d+/); + await expect(page.url()).not.toBe(currentUrl); + 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("5. 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.basic.spec.js b/e2e/e2e.basic.spec.js new file mode 100644 index 00000000..9bbefa22 --- /dev/null +++ b/e2e/e2e.basic.spec.js @@ -0,0 +1,423 @@ +import { expect, test } from "@playwright/test"; +import { E2EHelpers } from "./E2EHelpers.js"; + +// 테스트 설정 +test.describe.configure({ mode: "serial" }); + +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(); + + // 1차 카테고리 선택 + await page.click("text=생활/건강"); + await expect(page.locator("text=300개")).toBeVisible(); + await expect(page.locator("text=카테고리:").locator("..")).toContainText("생활/건강"); + + // 2차 카테고리 선택 + await page.click("text=자동차용품"); + await expect(page.locator("text=11개")).toBeVisible(); + await expect(page.locator("text=카테고리:").locator("..")).toContainText("자동차용품"); + + // 검색어 입력 + await page.fill("#search-input", "차량용"); + await page.press("#search-input", "Enter"); + await expect(page.locator("text=9개")).toBeVisible(); + + // 1차 카테고리 브레드크럼 클릭 + await page.click("text=생활/건강"); + await expect(page.locator("text=12개")).toBeVisible(); + + // 전체 브레드크럼 클릭 + await page.click("text=전체"); + await expect(page.locator("text=카테고리: 전체 생활/건강 디지털/가전")).toBeVisible(); + + await page.fill("#search-input", ""); + await page.press("#search-input", "Enter"); + + await expect(page).not.toHaveURL(/category/); + await expect(page.locator("text=340개")).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 "장바구니 담기" + `); + }); + + test("페이지당 상품 수 변경이 가능하다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + const args = [ + [10, `- heading "탈부착 방충망 자석쫄대 방풍비닐 창문방충망 셀프시공 DIY 백색 100cm" [level=3]`], + [20, `- heading "고양이 난간 안전망 복층 베란다 방묘창 방묘문 방충망 캣도어 일반형검정1mx1m" [level=3]`], + [50, `- heading "강아지 고양이 아이스팩 파우치 여름 베개 젤리곰 M사이즈" [level=3]`], + [100, `- heading "고양이 스크래쳐 숨숨집 하우스 대형 원목 스크레쳐 A type" [level=3]`], + ]; + for (const [limit, lastExpected] of args) { + await page.selectOption("#limit-select", limit.toString()); + await page.waitForFunction((l) => document.querySelectorAll(".product-card").length === l, limit); + await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(lastExpected); + } + }); + }); + + test.describe("3. 상태 유지 및 복원", () => { + test("장바구니 내용이 localStorage에 저장되고 복원된다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 상품을 장바구니에 추가 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + + // 장바구니 아이콘에 개수 표시 확인 + await expect(page.locator("#cart-icon-btn span")).toBeVisible(); + + // localStorage에 저장되었는지 확인 + const cartData = await page.evaluate(() => localStorage.getItem("shopping_cart")); + expect(cartData).toBeTruthy(); + + // 페이지 새로고침 + await page.reload(); + await helpers.waitForPageLoad(); + + // 장바구니 아이콘에 여전히 개수가 표시되는지 확인 + await expect(page.locator("#cart-icon-btn span")).toBeVisible(); + }); + + 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 helpers.waitForPageLoad(); + await page.evaluate(() => { + window.loadFlag = true; + }); + + // 상품 이미지 클릭하여 상세 페이지로 이동 + 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(); + + // 관련 상품 섹션 확인 + 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(); + + // 첫 번째 상품 추가 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + + // 두 번째 상품 추가 + await helpers.addProductToCart("샷시 풍지판"); + + // 장바구니 아이콘에 개수 표시 확인 (2개) + await expect(page.locator("#cart-icon-btn span")).toHaveText("2"); + + // 장바구니 모달 열기 + await helpers.openCartModal(); + + // 두 상품이 모두 있는지 확인 + await expect(page.locator(".cart-modal")).toContainText("PVC 투명 젤리 쇼핑백"); + await expect(page.locator(".cart-modal")).toContainText("샷시 풍지판"); + + // 첫 번째 상품 수량 증가 + await page.locator(".quantity-increase-btn").first().click(); + + // 총 금액 업데이트 확인 + await expect(page.locator("#root")).toMatchAriaSnapshot(` + - text: /총 금액 670원/ + - button "전체 비우기" + - button "구매하기" + `); + + // 첫 번째 상품만 선택 + await page.locator(".cart-item-checkbox").first().check(); + + // 선택 삭제 + await page.click("#cart-modal-remove-selected-btn"); + + // 첫 번째 상품만 삭제되고 두 번째 상품은 남아있는지 확인 + await expect(page.locator(".cart-modal")).not.toContainText("PVC 투명 젤리 쇼핑백"); + await expect(page.locator(".cart-modal")).toContainText("샷시 풍지판"); + + // 장바구니 아이콘 개수 업데이트 확인 (1개) + await expect(page.locator("#cart-icon-btn span")).toHaveText("1"); + }); + + test("전체 선택 후 장바구니 비우기", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 여러 상품 추가 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + await helpers.addProductToCart("고양이 난간 안전망"); + + // 장바구니 모달 열기 + await helpers.openCartModal(); + + // 전체 선택 + await page.check("#cart-modal-select-all-checkbox"); + + // 모든 상품이 선택되었는지 확인 + const checkboxes = page.locator(".cart-item-checkbox"); + const count = await checkboxes.count(); + for (let i = 0; i < count; i++) { + await expect(checkboxes.nth(i)).toBeChecked(); + } + + // 장바구니 비우기 + await page.click("#cart-modal-clear-cart-btn"); + + // 장바구니가 비어있는지 확인 + await expect(page.locator("text=장바구니가 비어있습니다")).toBeVisible(); + + // 장바구니 아이콘에서 개수 표시가 사라졌는지 확인 + await expect(page.locator("#cart-icon-btn span")).not.toBeVisible(); + }); + }); + + test.describe("6. 무한 스크롤 기능", () => { + 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( + () => { + return document.querySelectorAll(".product-card").length > 20; + }, + { timeout: 5000 }, + ); + + // 상품 수가 증가했는지 확인 + const updatedCards = await page.locator(".product-card").count(); + expect(updatedCards).toBeGreaterThan(initialCards); + }); + }); + + test.describe("7. 모달 및 UI 인터랙션", () => { + test("장바구니 모달이 다양한 방법으로 열리고 닫힌다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 모달 열기 + await page.click("#cart-icon-btn"); + await expect(page.locator(".cart-modal-overlay")).toBeVisible(); + + // ESC 키로 닫기 + await page.keyboard.press("Escape"); + await expect(page.locator(".cart-modal-overlay")).not.toBeVisible(); + + // 다시 열기 + await page.click("#cart-icon-btn"); + await expect(page.locator(".cart-modal-overlay")).toBeVisible(); + + // X 버튼으로 닫기 + await page.click("#cart-modal-close-btn"); + await expect(page.locator(".cart-modal-overlay")).not.toBeVisible(); + + // 다시 열기 + await page.click("#cart-icon-btn"); + await expect(page.locator(".cart-modal-overlay")).toBeVisible(); + + // 배경 클릭으로 닫기 (모달 내용이 아닌 오버레이 영역 클릭) + await page.locator(".cart-modal-overlay").click({ position: { x: 10, y: 10 } }); + await expect(page.locator(".cart-modal-overlay")).not.toBeVisible(); + }); + + test("토스트 메시지 시스템이 올바르게 작동한다", async ({ page }) => { + const helpers = new E2EHelpers(page); + await helpers.waitForPageLoad(); + + // 상품을 장바구니에 추가하여 토스트 메시지 트리거 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + + // 토스트 메시지 표시 확인 + let toast = await page.locator("text=장바구니에 추가되었습니다"); + await expect(toast).toBeVisible(); + + // 닫기 버튼을 클릭하여 닫기 테스트 + await page.locator("#toast-close-btn").click(); + await expect(toast).not.toBeVisible(); + + // 상품을 장바구니에 추가하여 토스트 메시지 트리거 + await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + + // 토스트 메시지 표시 확인 + toast = await page.locator("text=장바구니에 추가되었습니다"); + await expect(toast).toBeVisible(); + + // 자동으로 닫히는지 테스트 + await expect(toast).not.toBeVisible({ timeout: 4000 }); + }); + }); +}); diff --git a/index.html b/index.html index 6b45e6f0..d43ffde2 100644 --- a/index.html +++ b/index.html @@ -1,25 +1,26 @@ - - - - 상품 쇼핑몰 - - + + - - -
- - - \ No newline at end of file + }; + + + +
+ + + diff --git a/package.json b/package.json index 2d5c7358..5ec7f3f3 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "front-6th-chapter1-1", + "name": "front-chapter1-1", "private": true, "version": "0.0.0", "type": "module", @@ -10,11 +10,9 @@ "lint:fix": "eslint --fix", "prettier:write": "prettier --write ./src", "preview": "vite preview", - "test": "vitest", - "test:basic": "vitest basic.test.js", - "test:advanced": "vitest advanced", - "test:ui": "vitest --ui", "test:e2e": "playwright test", + "test:e2e:basic": "playwright test basic", + "test:e2e:advanced": "playwright test advanced", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "npx playwright show-report", "test:generate": "playwright codegen localhost:5173", @@ -28,9 +26,11 @@ }, "devDependencies": { "@eslint/js": "^9.16.0", - "@playwright/test": "^1.49.1", + "@playwright/test": "latest", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", - "@testing-library/user-event": "^14.5.2", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "latest", "@vitest/ui": "^2.1.8", "eslint": "^9.16.0", "eslint-config-prettier": "^9.1.0", @@ -42,11 +42,11 @@ "msw": "^2.10.2", "prettier": "^3.4.2", "vite": "npm:rolldown-vite@latest", - "vitest": "^2.1.8" + "vitest": "latest" }, "msw": { "workerDirectory": [ "public" ] } -} \ No newline at end of file +} diff --git a/playwright.config.js b/playwright.config.js index 53255d73..dd40de25 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -18,7 +18,7 @@ export default defineConfig({ }, ], webServer: { - command: "npm run dev", + command: "pnpm run dev", port: 5173, reuseExistingServer: !process.env.CI, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7aa93df..8137d4c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,17 +12,23 @@ importers: specifier: ^9.16.0 version: 9.23.0 '@playwright/test': - specifier: ^1.49.1 - version: 1.51.1 + specifier: latest + version: 1.53.2 + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.0 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 '@testing-library/user-event': - specifier: ^14.5.2 + specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@vitest/coverage-v8': + specifier: latest + version: 3.2.4(vitest@3.2.4) '@vitest/ui': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9) + version: 2.1.9(vitest@3.2.4) eslint: specifier: ^9.16.0 version: 9.23.0 @@ -54,14 +60,18 @@ importers: specifier: npm:rolldown-vite@latest version: rolldown-vite@6.3.21(esbuild@0.25.1)(yaml@2.7.0) vitest: - specifier: ^2.1.8 - version: 2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) + specifier: latest + version: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.1.1': resolution: {integrity: sha512-hpRD68SV2OMcZCsrbdkccTw5FXjNDLo5OuqSHyHZfwweGsDWZwDJ2+gONyNAbazZclobMirACLw0lk8WVxIqxA==} @@ -69,14 +79,35 @@ packages: resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.25.9': resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.0': + resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.26.10': resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.0': + resolution: {integrity: sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@bundled-es-modules/cookie@2.0.1': resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} @@ -500,9 +531,27 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/trace-mapping@0.3.29': + resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@mswjs/interceptors@0.39.2': resolution: {integrity: sha512-RuzCup9Ct91Y7V79xwCb146RaBRHZ7NBbrIUySumd1rpKqHL5OonaqrGIbug5hNwP/fRyxFMA6ISgw4FTtYFYg==} engines: {node: '>=18'} @@ -526,12 +575,16 @@ packages: '@oxc-project/types@0.73.0': resolution: {integrity: sha512-ZQS7dpsga43R7bjqRKHRhOeNpuIBeLBnlS3M6H3IqWIWiapGOQIxp4lpETLBYupkSd4dh85ESFn6vAvtpPdGkA==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/core@0.1.2': resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.51.1': - resolution: {integrity: sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==} + '@playwright/test@1.53.2': + resolution: {integrity: sha512-tEB2U5z74ebBeyfGNZ3Jfg29AnW+5HlWhvHtb/Mqco9pFdZU1ZLNdVb2UtB5CvmiilNr2ZfVH/qMmAROG/XTzw==} engines: {node: '>=18'} hasBin: true @@ -716,9 +769,15 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -731,14 +790,23 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true @@ -748,14 +816,17 @@ packages: '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} '@vitest/ui@2.1.9': resolution: {integrity: sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==} @@ -765,6 +836,9 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -828,6 +902,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.3: + resolution: {integrity: sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -837,6 +914,9 @@ packages: brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -938,6 +1018,15 @@ packages: supports-color: optional: true + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} @@ -970,12 +1059,18 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -992,8 +1087,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -1093,8 +1188,8 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} - expect-type@1.2.0: - resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==} + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} fast-deep-equal@3.1.3: @@ -1147,6 +1242,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data@4.0.2: resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} engines: {node: '>= 6'} @@ -1188,6 +1287,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1227,6 +1330,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1301,9 +1407,31 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -1427,6 +1555,9 @@ packages: loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + loupe@3.1.4: + resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -1437,6 +1568,13 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1471,6 +1609,14 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -1530,6 +1676,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1549,12 +1698,19 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.0: resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} engines: {node: '>= 14.16'} @@ -1575,13 +1731,13 @@ packages: engines: {node: '>=0.10'} hasBin: true - playwright-core@1.51.1: - resolution: {integrity: sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==} + playwright-core@1.53.2: + resolution: {integrity: sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==} engines: {node: '>=18'} hasBin: true - playwright@1.51.1: - resolution: {integrity: sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==} + playwright@1.53.2: + resolution: {integrity: sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==} engines: {node: '>=18'} hasBin: true @@ -1706,6 +1862,11 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1744,8 +1905,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.8.1: - resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -1758,6 +1919,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -1782,6 +1947,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1793,6 +1961,10 @@ packages: resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} engines: {node: ^14.18.0 || >=16.0.0} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1807,16 +1979,20 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} - tinypool@1.0.2: - resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} tinyrainbow@1.2.0: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} engines: {node: '>=14.0.0'} tldts-core@6.1.84: @@ -1871,9 +2047,9 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true vite@5.4.14: @@ -1907,20 +2083,23 @@ packages: terser: optional: true - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@types/debug': + optional: true '@types/node': optional: true '@vitest/browser': @@ -1974,6 +2153,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.0: resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} engines: {node: '>=18'} @@ -2026,6 +2209,11 @@ snapshots: '@adobe/css-tools@4.4.2': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.29 + '@asamuzakjp/css-color@3.1.1': dependencies: '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) @@ -2040,12 +2228,27 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.25.9': {} + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/parser@7.28.0': + dependencies: + '@babel/types': 7.28.0 + '@babel/runtime@7.26.10': dependencies: regenerator-runtime: 0.14.1 + '@babel/types@7.28.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@bcoe/v8-coverage@1.0.2': {} + '@bundled-es-modules/cookie@2.0.1': dependencies: cookie: 0.7.2 @@ -2316,8 +2519,31 @@ snapshots: '@inquirer/type@3.0.7': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.12': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.29 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/trace-mapping@0.3.29': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + '@mswjs/interceptors@0.39.2': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -2347,11 +2573,14 @@ snapshots: '@oxc-project/types@0.73.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.1.2': {} - '@playwright/test@1.51.1': + '@playwright/test@1.53.2': dependencies: - playwright: 1.51.1 + playwright: 1.53.2 '@polka/url@1.0.0-next.28': {} @@ -2484,8 +2713,14 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.6': {} '@types/json-schema@7.0.15': {} @@ -2494,16 +2729,36 @@ snapshots: '@types/tough-cookie@4.0.5': {} - '@vitest/expect@2.1.9': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.3 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.17 + magicast: 0.3.5 + std-env: 3.9.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.2.0 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 - '@vitest/mocker@2.1.9(msw@2.10.2)(vite@5.4.14(lightningcss@1.30.1))': + '@vitest/mocker@3.2.4(msw@2.10.2)(vite@5.4.14(lightningcss@1.30.1))': dependencies: - '@vitest/spy': 2.1.9 + '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: @@ -2514,22 +2769,27 @@ snapshots: dependencies: tinyrainbow: 1.2.0 - '@vitest/runner@2.1.9': + '@vitest/pretty-format@3.2.4': dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 + tinyrainbow: 2.0.0 - '@vitest/snapshot@2.1.9': + '@vitest/runner@3.2.4': dependencies: - '@vitest/pretty-format': 2.1.9 + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 magic-string: 0.30.17 - pathe: 1.1.2 + pathe: 2.0.3 - '@vitest/spy@2.1.9': + '@vitest/spy@3.2.4': dependencies: - tinyspy: 3.0.2 + tinyspy: 4.0.3 - '@vitest/ui@2.1.9(vitest@2.1.9)': + '@vitest/ui@2.1.9(vitest@3.2.4)': dependencies: '@vitest/utils': 2.1.9 fflate: 0.8.2 @@ -2538,7 +2798,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.12 tinyrainbow: 1.2.0 - vitest: 2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) + vitest: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) '@vitest/utils@2.1.9': dependencies: @@ -2546,6 +2806,12 @@ snapshots: loupe: 3.1.3 tinyrainbow: 1.2.0 + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.1.4 + tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.14.1): dependencies: acorn: 8.14.1 @@ -2593,6 +2859,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.3: + dependencies: + '@jridgewell/trace-mapping': 0.3.29 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + asynckit@0.4.0: {} balanced-match@1.0.2: {} @@ -2602,6 +2874,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -2620,7 +2896,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.3 + loupe: 3.1.4 pathval: 2.0.0 chalk@3.0.0: @@ -2694,6 +2970,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.1: + dependencies: + ms: 2.1.3 + decimal.js@10.5.0: {} deep-eql@5.0.2: {} @@ -2716,10 +2996,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + entities@4.5.0: {} environment@1.1.0: {} @@ -2728,7 +3012,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: dependencies: @@ -2898,7 +3182,7 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 - expect-type@1.2.0: {} + expect-type@1.2.1: {} fast-deep-equal@3.1.3: {} @@ -2938,6 +3222,11 @@ snapshots: flatted@3.3.3: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data@4.0.2: dependencies: asynckit: 0.4.0 @@ -2981,6 +3270,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} globals@15.15.0: {} @@ -3007,6 +3305,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 @@ -3064,8 +3364,37 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.29 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -3202,6 +3531,8 @@ snapshots: loupe@3.1.3: {} + loupe@3.1.4: {} + lru-cache@10.4.3: {} lz-string@1.5.0: {} @@ -3210,6 +3541,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.0 + '@babel/types': 7.28.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + math-intrinsics@1.1.0: {} merge-stream@2.0.0: {} @@ -3235,6 +3576,12 @@ snapshots: dependencies: brace-expansion: 1.1.11 + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -3301,6 +3648,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3315,10 +3664,17 @@ snapshots: path-key@4.0.0: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-to-regexp@6.3.0: {} pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.0: {} picocolors@1.1.1: {} @@ -3329,11 +3685,11 @@ snapshots: pidtree@0.6.0: {} - playwright-core@1.51.1: {} + playwright-core@1.53.2: {} - playwright@1.51.1: + playwright@1.53.2: dependencies: - playwright-core: 1.51.1 + playwright-core: 1.53.2 optionalDependencies: fsevents: 2.3.2 @@ -3456,6 +3812,8 @@ snapshots: dependencies: xmlchars: 2.2.0 + semver@7.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3488,7 +3846,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.8.1: {} + std-env@3.9.0: {} strict-event-emitter@0.5.1: {} @@ -3500,6 +3858,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + string-width@7.2.0: dependencies: emoji-regex: 10.4.0 @@ -3522,6 +3886,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3533,6 +3901,12 @@ snapshots: '@pkgr/core': 0.1.2 tslib: 2.8.1 + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -3547,11 +3921,13 @@ snapshots: fdir: 6.4.6(picomatch@4.0.2) picomatch: 4.0.2 - tinypool@1.0.2: {} + tinypool@1.1.1: {} tinyrainbow@1.2.0: {} - tinyspy@3.0.2: {} + tinyrainbow@2.0.0: {} + + tinyspy@4.0.3: {} tldts-core@6.1.84: {} @@ -3601,12 +3977,12 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 - vite-node@2.1.9(lightningcss@1.30.1): + vite-node@3.2.4(lightningcss@1.30.1): dependencies: cac: 6.7.14 - debug: 4.4.0 - es-module-lexer: 1.6.0 - pathe: 1.1.2 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 2.0.3 vite: 5.4.14(lightningcss@1.30.1) transitivePeerDependencies: - '@types/node' @@ -3628,30 +4004,33 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.1 - vitest@2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2): + vitest@3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2): dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(msw@2.10.2)(vite@5.4.14(lightningcss@1.30.1)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.10.2)(vite@5.4.14(lightningcss@1.30.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.2.0 - debug: 4.4.0 - expect-type: 1.2.0 + debug: 4.4.1 + expect-type: 1.2.1 magic-string: 0.30.17 - pathe: 1.1.2 - std-env: 3.8.1 + pathe: 2.0.3 + picomatch: 4.0.2 + std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinypool: 1.0.2 - tinyrainbow: 1.2.0 + tinyglobby: 0.2.14 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 vite: 5.4.14(lightningcss@1.30.1) - vite-node: 2.1.9(lightningcss@1.30.1) + vite-node: 3.2.4(lightningcss@1.30.1) why-is-node-running: 2.3.0 optionalDependencies: - '@vitest/ui': 2.1.9(vitest@2.1.9) + '@vitest/ui': 2.1.9(vitest@3.2.4) jsdom: 25.0.1 transitivePeerDependencies: - less @@ -3704,6 +4083,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + wrap-ansi@9.0.0: dependencies: ansi-styles: 6.2.1 diff --git a/requirement.md b/requirement.md new file mode 100644 index 00000000..d450ef17 --- /dev/null +++ b/requirement.md @@ -0,0 +1,190 @@ +# 요구사항 명세서 + +## 상품목록 + +### 상품 목록 로딩 + +- 페이지 접속 시 로딩 상태가 표시된다 +- 데이터 로드 완료 후 상품 목록이 렌더링된다 +- 로딩 실패 시 에러 상태가 표시된다 +- 에러 발생 시 재시도 버튼이 제공된다 + +### 상품 목록 조회 + +- 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 + +### 한 페이지에 보여질 상품 수 선택 + +- 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. +- 선택 변경 시 즉시 목록에 반영된다 + +### 상품 정렬 기능 + +- 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다. +- 드롭다운을 통해 정렬 기준을 선택할 수 있다 +- 정렬 변경 시 즉시 목록에 반영된다 + +### 무한 스크롤 페이지네이션 + +- 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 +- 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 +- 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 +- 홈 페이지에서만 무한 스크롤이 활성화된다 + +### 상품을 장바구니에 담기 + +- 각 상품에 장바구니 추가 버튼이 있다 +- 버튼 클릭 시 해당 상품이 장바구니에 추가된다 +- 추가 완료 시 사용자에게 알림이 표시된다 + +### 상품 검색 + +- 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- 검색 버튼 클릭으로 검색이 수행된다 +- Enter 키로 검색이 수행된다 +- 검색어와 일치하는 상품들만 목록에 표시된다 + +### 카테고리 선택 + +- 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- 선택된 카테고리에 해당하는 상품들만 표시된다 +- 전체 상품 보기로 돌아갈 수 있다 +- 2단계 카테고리 구조를 지원한다 (1depth, 2depth) + +### 카테고리 네비게이션 + +- 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 + +### 현재 상품 수 표시 + +- 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 + +## 장바구니 + +### 장바구니 모달 + +- 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- ESC 키로 모달을 닫을 수 있다 +- 모달에서 장바구니의 모든 기능을 사용할 수 있다 + +### 장바구니 수량 조절 + +- 각 장바구니 상품의 수량을 증가할 수 있다 +- 각 장바구니 상품의 수량을 감소할 수 있다 +- 수량 변경 시 총 금액이 실시간으로 업데이트된다 + +### 장바구니 삭제 + +- 각 상품에 삭제 버튼이 배치되어 있다 +- 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 + +### 장바구니 선택 삭제 + +- 각 상품에 선택을 위한 체크박스가 제공된다 +- 선택 삭제 버튼이 있다 +- 체크된 상품들만 일괄 삭제된다 + +### 장바구니 전체 선택 + +- 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- 전체 선택 시 모든 상품의 체크박스가 선택된다 +- 전체 해제 시 모든 상품의 체크박스가 해제된다 + +### 장바구니 비우기 + +- 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 + +## 상품 상세 + +### 상품 클릭시 상세 페이지 이동 + +- 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- URL이 `/product/{productId}` 형태로 변경된다 +- 상품의 자세한 정보가 전용 페이지에서 표시된다 + +### 상품 상세 페이지 기능 + +- 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 + +### 상품 상세 - 장바구니 담기 + +- 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- 수량 증가/감소 버튼이 제공된다 + +### 관련 상품 기능 + +- 상품 상세 페이지에서 관련 상품들이 표시된다 +- 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- 현재 보고 있는 상품은 관련 상품에서 제외된다 + +### 상품 상세 페이지 내 네비게이션 + +- 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 + +## 사용자 피드백 시스템 + +### 토스트 메시지 + +- 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- 토스트는 3초 후 자동으로 사라진다 +- 토스트에 닫기 버튼이 제공된다 +- 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) + +### 에러 처리 + +- 네트워크 오류 등 에러 발생 시 사용자에게 적절한 메시지가 표시된다 +- 에러 상황에서 재시도할 수 있는 버튼이 제공된다 +- 에러 상태가 UI에 적절히 반영된다 + +## SPA 네비게이션 및 URL 관리 + +### 페이지 이동 + +- 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. + +### 상품 목록 - URL 쿼리 반영 + +- 검색어가 URL 쿼리 파라미터에 저장된다 +- 카테고리 선택이 URL 쿼리 파라미터에 저장된다 +- 상품 옵션이 URL 쿼리 파라미터에 저장된다 +- 정렬 조건이 URL 쿼리 파라미터에 저장된다 +- 조건 변경 시 URL이 자동으로 업데이트된다 +- URL을 통해 현재 검색/필터 상태를 공유할 수 있다 + +### 상품 목록 - 새로고침 시 상태 유지 + +- 새로고침 후 URL 쿼리에서 검색어가 복원된다 +- 새로고침 후 URL 쿼리에서 카테고리가 복원된다 +- 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 +- 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 +- 복원된 조건에 맞는 상품 데이터가 다시 로드된다 + +### 장바구니 - 새로고침 시 데이터 유지 + +- 장바구니 내용이 브라우저에 저장된다 +- 새로고침 후에도 이전 장바구니 내용이 유지된다 +- 장바구니의 선택 상태도 함께 유지된다 + +### 상품 상세 - URL에 ID 반영 + +- 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) +- URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 + +### 상품 상세 - 새로고침시 유지 + +- 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 + +### 404 페이지 + +- 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 +- 홈으로 돌아가기 버튼이 제공된다 diff --git a/src/api/productApi.js b/src/api/productApi.js new file mode 100644 index 00000000..bbdea046 --- /dev/null +++ b/src/api/productApi.js @@ -0,0 +1,30 @@ +// 상품 목록 조회 +export async function getProducts(params = {}) { + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.current ?? params.page ?? 1; + + const searchParams = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + ...(search && { search }), + ...(category1 && { category1 }), + ...(category2 && { category2 }), + sort, + }); + + const response = await fetch(`/api/products?${searchParams}`); + + return await response.json(); +} + +// 상품 상세 조회 +export async function getProduct(productId) { + const response = await fetch(`/api/products/${productId}`); + return await response.json(); +} + +// 카테고리 목록 조회 +export async function getCategories() { + const response = await fetch("/api/categories"); + return await response.json(); +} diff --git a/src/main.js b/src/main.js index 983c051f..4b055b89 100644 --- a/src/main.js +++ b/src/main.js @@ -1,19 +1,1152 @@ -import { worker } from "./mocks/browser.js"; - -// 개발 환경에서만 MSW 워커 시작 -async function enableMocking() { - if (import.meta.env.DEV) { - return worker.start({ - onUnhandledRequest: "bypass", // 처리되지 않은 요청은 그대로 통과 - }); - } -} +const enableMocking = () => + import("./mocks/browser.js").then(({ worker }) => + worker.start({ + onUnhandledRequest: "bypass", + }), + ); + +function main() { + const 상품목록_레이아웃_로딩 = ` +
+
+
+
+

+ 쇼핑몰 +

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

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

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

+ 쇼핑몰 +

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

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

+

+

+ 220원 +

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

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

+

이지웨이건축자재

+

+ 230원 +

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

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

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

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

+ +
+ +
+
+ + + +
+

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

+ +
+ +
+
+ + + +
+

오류가 발생했습니다.

+ +
+
+ `; -// 앱 초기화 -async function initApp() { - // MSW 워커 시작 - await enableMocking(); + 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 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+
+ `; + + document.body.innerHTML = ` + ${상품목록_레이아웃_로딩} +
+ ${상품목록_레이아웃_로딩완료} +
+ ${상품목록_레이아웃_카테고리_1Depth} +
+ ${상품목록_레이아웃_카테고리_2Depth} +
+ ${토스트} +
+ ${장바구니_비어있음} +
+ ${장바구니_선택없음} +
+ ${장바구니_선택있음} +
+ ${상세페이지_로딩} +
+ ${상세페이지_로딩완료} +
+ ${_404_} + `; } -// 앱 시작 -initApp().catch(console.error); +// 애플리케이션 시작 +if (import.meta.env.MODE !== "test") { + enableMocking().then(main); +} else { + main(); +} diff --git a/src/mocks/browser.js b/src/mocks/browser.js index e4d86a51..be3dedca 100644 --- a/src/mocks/browser.js +++ b/src/mocks/browser.js @@ -1,5 +1,5 @@ import { setupWorker } from "msw/browser"; -import { handlers } from "./handlers.js"; +import { handlers } from "./handlers"; // MSW 워커 설정 export const worker = setupWorker(...handlers); diff --git a/src/mocks/handlers.js b/src/mocks/handlers.js index 03578eec..e7dcb949 100644 --- a/src/mocks/handlers.js +++ b/src/mocks/handlers.js @@ -1,5 +1,7 @@ import { http, HttpResponse } from "msw"; -import items from "../items.json"; +import items from "./items.json"; + +const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); // 카테고리 추출 함수 function getUniqueCategories() { @@ -8,15 +10,9 @@ function getUniqueCategories() { items.forEach((item) => { const cat1 = item.category1; const cat2 = item.category2; - const cat3 = item.category3; - const cat4 = item.category4; if (!categories[cat1]) categories[cat1] = {}; if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; - if (cat3 && !categories[cat1][cat2][cat3]) - categories[cat1][cat2][cat3] = {}; - if (cat4 && !categories[cat1][cat2][cat3][cat4]) - categories[cat1][cat2][cat3][cat4] = true; }); return categories; @@ -30,9 +26,7 @@ function filterProducts(products, query) { if (query.search) { const searchTerm = query.search.toLowerCase(); filtered = filtered.filter( - (item) => - item.title.toLowerCase().includes(searchTerm) || - item.brand.toLowerCase().includes(searchTerm), + (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), ); } @@ -43,12 +37,6 @@ function filterProducts(products, query) { if (query.category2) { filtered = filtered.filter((item) => item.category2 === query.category2); } - if (query.category3) { - filtered = filtered.filter((item) => item.category3 === query.category3); - } - if (query.category4) { - filtered = filtered.filter((item) => item.category4 === query.category4); - } // 정렬 if (query.sort) { @@ -60,10 +48,10 @@ function filterProducts(products, query) { filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); break; case "name_asc": - filtered.sort((a, b) => a.title.localeCompare(b.title)); + filtered.sort((a, b) => a.title.localeCompare(b.title, "ko")); break; case "name_desc": - filtered.sort((a, b) => b.title.localeCompare(a.title)); + filtered.sort((a, b) => b.title.localeCompare(a.title, "ko")); break; default: // 기본은 가격 낮은 순 @@ -76,15 +64,13 @@ function filterProducts(products, query) { export const handlers = [ // 상품 목록 API - http.get("/api/products", ({ request }) => { + http.get("/api/products", async ({ request }) => { const url = new URL(request.url); - const page = parseInt(url.searchParams.get("page")) || 1; + const page = parseInt(url.searchParams.get("page") ?? url.searchParams.get("current")) || 1; const limit = parseInt(url.searchParams.get("limit")) || 20; const search = url.searchParams.get("search") || ""; const category1 = url.searchParams.get("category1") || ""; const category2 = url.searchParams.get("category2") || ""; - const category3 = url.searchParams.get("category3") || ""; - const category4 = url.searchParams.get("category4") || ""; const sort = url.searchParams.get("sort") || "price_asc"; // 필터링된 상품들 @@ -92,8 +78,6 @@ export const handlers = [ search, category1, category2, - category3, - category4, sort, }); @@ -117,17 +101,17 @@ export const handlers = [ search, category1, category2, - category3, - category4, sort, }, }; + await delay(); + return HttpResponse.json(response); }), // 상품 상세 API - http.get("/api/products/:id", ({ params }) => { + http.get("/api/products/:id", async ({ params }) => { const { id } = params; const product = items.find((item) => item.productId === id); @@ -142,19 +126,17 @@ export const handlers = [ rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤 reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤 stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤 - images: [ - product.image, - product.image.replace(".jpg", "_2.jpg"), - product.image.replace(".jpg", "_3.jpg"), - ], + images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], }; + await delay(); return HttpResponse.json(detailProduct); }), // 카테고리 목록 API - http.get("/api/categories", () => { + http.get("/api/categories", async () => { const categories = getUniqueCategories(); + await delay(); return HttpResponse.json(categories); }), ]; diff --git a/src/items.json b/src/mocks/items.json similarity index 59% rename from src/items.json rename to src/mocks/items.json index fb8c291c..eb998ea0 100644 --- a/src/items.json +++ b/src/mocks/items.json @@ -160,7 +160,7 @@ "category4": "제습제" }, { - "title": "생활<\/b>공작소 대용량제습제 옷장제습제 화장실제습제 24개", + "title": "생활공작소 대용량제습제 옷장제습제 화장실제습제 24개", "link": "https:\/\/smartstore.naver.com\/main\/products\/4905164407", "image": "https:\/\/shopping-phinf.pstatic.net\/main_8244968\/82449688071.14.jpg", "lprice": "20900", @@ -448,7 +448,7 @@ "category4": "리퀴드" }, { - "title": "생활<\/b>공작소 실리카겔제습제 옷장제습제 서랍제습제 20개", + "title": "생활공작소 실리카겔제습제 옷장제습제 서랍제습제 20개", "link": "https:\/\/smartstore.naver.com\/main\/products\/4573567912", "image": "https:\/\/shopping-phinf.pstatic.net\/main_8211808\/82118088066.9.jpg", "lprice": "11500", @@ -2896,7 +2896,7 @@ "category4": "차량용방향제" }, { - "title": "캠핑슬립 라이트 SUV 차박매트 트렁크 매트리스 차량용 평탄화 차박용품<\/b> 엠보그레이", + "title": "캠핑슬립 라이트 SUV 차박매트 트렁크 매트리스 차량용 평탄화 차박용품 엠보그레이", "link": "https:\/\/smartstore.naver.com\/main\/products\/5960280549", "image": "https:\/\/shopping-phinf.pstatic.net\/main_8350478\/83504780037.7.jpg", "lprice": "139000", @@ -3198,5 +3198,2245 @@ "category2": "생활용품", "category3": "섬유유연제", "category4": "고농축섬유유연제" + }, + { + "title": "바비온 슬리커 자동 털제거 빗 쓱싹 핀 브러쉬 112ZR 오렌지, M", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53663904900", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5366390\/53663904900.20250320100513.jpg", + "lprice": "15900", + "hprice": "", + "mallName": "네이버", + "productId": "53663904900", + "productType": "1", + "brand": "바비온", + "maker": "바비온", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "브러시\/빗" + }, + { + "title": "카스테라 강아지 방석 고양이 마약쿠션 커버분리 코스트코 켄넬 대형 대형견 방석 M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7223807949", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8476830\/84768308271.11.jpg", + "lprice": "24900", + "hprice": "", + "mallName": "킁킁펫", + "productId": "84768308271", + "productType": "2", + "brand": "킁킁펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "가르르 오로라 캣타워 고양이 캣폴 알루미늄+삼줄기둥 일반세트", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8406568596", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8595106\/85951068919.43.jpg", + "lprice": "230000", + "hprice": "", + "mallName": "가르르", + "productId": "85951068919", + "productType": "2", + "brand": "가르르", + "maker": "가르르", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "캣타워\/캣폴" + }, + { + "title": "스타일러그 강아지매트 고양이 애견 미끄럼방지 펫 반려견 카페트 바닥 방수 러그 거실", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53705940330", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5370594\/53705940330.20250404094459.jpg", + "lprice": "18900", + "hprice": "", + "mallName": "네이버", + "productId": "53705940330", + "productType": "1", + "brand": "스타일러그", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "LUAZ 강아지 밥그릇 물그릇 고양이 식기 물통 LUAZ-DW01", + "link": "https:\/\/search.shopping.naver.com\/catalog\/36321905955", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3632190\/36321905955.20240331031626.jpg", + "lprice": "8500", + "hprice": "", + "mallName": "네이버", + "productId": "36321905955", + "productType": "1", + "brand": "LUAZ", + "maker": "루아즈", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "토마고 강아지 고양이 바리깡 미니 미용기 발 부분 털 발털 클리퍼 발바닥 이발기 화이트", + "link": "https:\/\/smartstore.naver.com\/main\/products\/2184526789", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_1228498\/12284980671.36.jpg", + "lprice": "24800", + "hprice": "", + "mallName": "펫방앗간", + "productId": "12284980671", + "productType": "2", + "brand": "토마고", + "maker": "케이엘테크", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "이발기" + }, + { + "title": "강아지 고양이 숨숨집 하우스 텐트 실외 길냥이 길고양이 집 플라스틱 개집", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10037143546", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8758164\/87581646050.jpg", + "lprice": "35900", + "hprice": "", + "mallName": "미우프", + "productId": "87581646050", + "productType": "2", + "brand": "UNKNOWN", + "maker": "UNKNOWN", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "실리어스 푸우형 실리콘 강아지매트, 미끄럼방지 애견 롤매트 펫 러그 140x100cm", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8719169350", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8626366\/86263669673.1.jpg", + "lprice": "83000", + "hprice": "", + "mallName": "실리어스", + "productId": "86263669673", + "productType": "2", + "brand": "실리어스", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "사롬사리 강아지 쿨매트 고양이 애견 여름 냉감 패드", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53670171320", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5367017\/53670171320.20250408070603.jpg", + "lprice": "18500", + "hprice": "", + "mallName": "네이버", + "productId": "53670171320", + "productType": "1", + "brand": "사롬사리", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "[세이버 퐁고 2.0] 펫드라이룸 중형견케어 강아지 고양이 간편 털말리기 애견 애묘 건조기", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11102041334", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8864655\/88646551656.5.jpg", + "lprice": "1190000", + "hprice": "", + "mallName": "세이버 공식몰", + "productId": "88646551656", + "productType": "2", + "brand": "세이버", + "maker": "세이버", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "드라이기\/드라이룸" + }, + { + "title": "멍묘인 강아지 2.0텐트 M 집 고양이 숨숨집 예쁜 하우스 개 애견 방석 없음", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5776179111", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8332067\/83320678525.4.jpg", + "lprice": "22900", + "hprice": "", + "mallName": "멍묘인", + "productId": "83320678525", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "LUAZ 애견 강아지 방석 고양이 쿠션 담요 이불 LUAZ-DG6", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54279064807", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5427906\/54279064807.20250502103826.jpg", + "lprice": "7500", + "hprice": "", + "mallName": "네이버", + "productId": "54279064807", + "productType": "1", + "brand": "LUAZ", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "스니프 칠링칠링 듀라론 애견 강아지쿨매트 여름용 반려동물 쿨방석", + "link": "https:\/\/search.shopping.naver.com\/catalog\/33242151678", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3324215\/33242151678.20250514090745.jpg", + "lprice": "18900", + "hprice": "", + "mallName": "네이버", + "productId": "33242151678", + "productType": "1", + "brand": "스니프", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "접촉냉감 누빔 강아지 쿨매트 고양이 아이스 패드 냉감 매트 M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10615040891", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8815954\/88159546540.7.jpg", + "lprice": "26800", + "hprice": "", + "mallName": "올웨이즈올펫", + "productId": "88159546540", + "productType": "2", + "brand": "올웨이즈올펫", + "maker": "지오위즈", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "올웨이즈올펫 딩굴 강아지매트 고양이 미끄럼방지 슬개골예방 롤 매트 110x50x0.6cm", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5311346622", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8285583\/82855839069.40.jpg", + "lprice": "10800", + "hprice": "", + "mallName": "올웨이즈올펫", + "productId": "82855839069", + "productType": "2", + "brand": "올웨이즈올펫", + "maker": "지오위즈", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "비엔메이드 무드 롤 시공 강아지매트 애견 방수 미끄럼방지 고양이 매트 70cm X 0.5M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8490392547", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8603489\/86034892870.1.jpg", + "lprice": "9900", + "hprice": "", + "mallName": "비엔메이드", + "productId": "86034892870", + "productType": "2", + "brand": "비엔메이드", + "maker": "신영인더스", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "가티가티 고양이식기 강아지밥그릇 식탁 1구식기세트 빈티지로즈", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5354078062", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8289857\/82898571031.3.jpg", + "lprice": "26400", + "hprice": "", + "mallName": "가티몰", + "productId": "82898571031", + "productType": "2", + "brand": "가티가티", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "올웨이즈올펫 강아지 쿨방석 고양이 냉감 아이스 쿨쿠션 M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8501680564", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8604618\/86046180887.10.jpg", + "lprice": "49800", + "hprice": "", + "mallName": "올웨이즈올펫", + "productId": "86046180887", + "productType": "3", + "brand": "펫토", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "슈퍼벳 레날 에이드 280mg x 60캡슐, 1개", + "link": "https:\/\/search.shopping.naver.com\/catalog\/52539061038", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5253906\/52539061038.20250117155343.jpg", + "lprice": "28700", + "hprice": "", + "mallName": "네이버", + "productId": "52539061038", + "productType": "1", + "brand": "슈퍼벳", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "테일로그 탈출방지 고양이 방묘창 캣키퍼 1개 창문 높이 85", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53922016884", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5392201\/53922016884.20250403011953.jpg", + "lprice": "32000", + "hprice": "", + "mallName": "네이버", + "productId": "53922016884", + "productType": "1", + "brand": "테일로그", + "maker": "테일로그", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "안전문" + }, + { + "title": "[케어사이드] 강아지 고양이 헤파카디오 Q10 60정 심장보조영양제 CARESIDE HEPACARDIO", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7102910072", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8464741\/84647410394.5.jpg", + "lprice": "18990", + "hprice": "", + "mallName": "예쁘개냥", + "productId": "84647410394", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "접이식 강아지 고양이 해먹 침대 대형견해먹 캠핑 의자 S", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5769443200", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8331394\/83313942614.2.jpg", + "lprice": "28000", + "hprice": "", + "mallName": "멍심사냥", + "productId": "83313942614", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "침대\/해먹" + }, + { + "title": "[페스룸] 네이처 이어 클리너 강아지 고양이 귀세정제 귀청소 귓병 예방", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4792716744", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8233723\/82337239241.3.jpg", + "lprice": "15900", + "hprice": "", + "mallName": "PETHROOM", + "productId": "82337239241", + "productType": "2", + "brand": "페스룸", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "눈\/귀 관리용품" + }, + { + "title": "키즈온더블럭 펫도어 견문 강아지 고양이 안전문 베란다 펫도어 시공 미니", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7918440666", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8546294\/85462940989.10.jpg", + "lprice": "98000", + "hprice": "", + "mallName": "키즈온더블럭", + "productId": "85462940989", + "productType": "2", + "brand": "키즈온더블럭", + "maker": "아이작", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "안전문" + }, + { + "title": "퍼키퍼키 강아지밥그릇 고양이밥그릇 물그릇 애견 식기 높이조절 식탁 세트", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10268762667", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8781326\/87813266469.16.jpg", + "lprice": "27900", + "hprice": "", + "mallName": "퍼키퍼키", + "productId": "87813266469", + "productType": "2", + "brand": "퍼키퍼키", + "maker": "퍼키퍼키", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "펫테일 견분무취 200g, 1개", + "link": "https:\/\/search.shopping.naver.com\/catalog\/51929267504", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5192926\/51929267504.20241213211322.jpg", + "lprice": "18900", + "hprice": "", + "mallName": "네이버", + "productId": "51929267504", + "productType": "1", + "brand": "펫테일", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "펫코본 고양이밥그릇 물그릇 강아지 1구 투명 유리 식기 수반", + "link": "https:\/\/search.shopping.naver.com\/catalog\/51181438556", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5118143\/51181438556.20241211202407.jpg", + "lprice": "16900", + "hprice": "", + "mallName": "네이버", + "productId": "51181438556", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "가또나인 고양이스크래쳐 옐로 레오파드 3PC 스크래쳐 2개", + "link": "https:\/\/smartstore.naver.com\/main\/products\/2058243766", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_1185459\/11854591070.14.jpg", + "lprice": "17900", + "hprice": "", + "mallName": "GATO", + "productId": "11854591070", + "productType": "2", + "brand": "가또나인", + "maker": "빅트리", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "DUIT 올데이보드 고양이 스크래쳐 장난감", + "link": "https:\/\/search.shopping.naver.com\/catalog\/33691361489", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3369136\/33691361489.20241015154005.jpg", + "lprice": "28000", + "hprice": "", + "mallName": "네이버", + "productId": "33691361489", + "productType": "1", + "brand": "DUIT", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "루시몰 고양이 스크래쳐 원형 대형 특대형 기본 46cm", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6659642344", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8420414\/84204142666.13.jpg", + "lprice": "19000", + "hprice": "", + "mallName": "Lusi mall", + "productId": "84204142666", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "강아지 이불 블랭킷 고양이 담요 펫 애견 쿠션 더블유곰 소", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8671921224", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8621642\/86216421547.jpg", + "lprice": "10900", + "hprice": "", + "mallName": "해피앤퍼피", + "productId": "86216421547", + "productType": "2", + "brand": "", + "maker": "해피앤퍼피", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "씨리얼펫 젤리냥수기 고양이 세라믹 정수기 반려동물 필터 음수기 1.2L", + "link": "https:\/\/search.shopping.naver.com\/catalog\/30431203499", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3043120\/30431203499.20250222214801.jpg", + "lprice": "49900", + "hprice": "", + "mallName": "네이버", + "productId": "30431203499", + "productType": "1", + "brand": "씨리얼펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "정수기\/필터" + }, + { + "title": "수의사가 만든 라퓨클레르 강아지 고양이 샴푸 저자극 보습 목욕 300ml", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10582992973", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8812749\/88127498563.9.jpg", + "lprice": "19900", + "hprice": "", + "mallName": "라퓨클레르", + "productId": "88127498563", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "샴푸\/린스\/비누" + }, + { + "title": "22kg까지 견디는 고양이 해먹 윈도우 해먹 창문해먹", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4709037976", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8225355\/82253558998.2.jpg", + "lprice": "6900", + "hprice": "", + "mallName": "홈앤스위트", + "productId": "82253558998", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "침대\/해먹" + }, + { + "title": "바비온 9in1 올마스터 진공 흡입 미용기 강아지 고양이 이발기 바리깡 클리퍼 셀프미용", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10352906076", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8789741\/87897410549.18.jpg", + "lprice": "179000", + "hprice": "", + "mallName": "바비온코리아", + "productId": "87897410549", + "productType": "2", + "brand": "바비온", + "maker": "바비온", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "이발기" + }, + { + "title": "MOOQS 묵스 우드 스노우 펫 하우스 강아지집 숨숨집 고양이집", + "link": "https:\/\/search.shopping.naver.com\/catalog\/40031843151", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_4003184\/40031843151.20250316173117.jpg", + "lprice": "125000", + "hprice": "", + "mallName": "네이버", + "productId": "40031843151", + "productType": "1", + "brand": "MOOQS", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "강아지 샴푸 올인원 린스 100% 천연 약용 각질 비듬 아토피 피부병 고양이겸용 270ml", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4737618345", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8228213\/82282139809.9.jpg", + "lprice": "36000", + "hprice": "", + "mallName": "지켜줄개 댕댕아", + "productId": "82282139809", + "productType": "2", + "brand": "지켜줄개댕댕아", + "maker": "지켜줄개댕댕아", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "샴푸\/린스\/비누" + }, + { + "title": "강아지 고양이 넥카라 깔대기 목보호대 애견 중성화 쿠션 중형견 피너츠 엘리자베스 그레이M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/3973660933", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8151818\/81518181158.16.jpg", + "lprice": "9800", + "hprice": "", + "mallName": "르쁘띠숑", + "productId": "81518181158", + "productType": "2", + "brand": "패리스독", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "넥카라\/보호대" + }, + { + "title": "코드 헬씨에이징 항산화 영양 보조제 2g x 30포, 1개", + "link": "https:\/\/search.shopping.naver.com\/catalog\/51929018110", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5192901\/51929018110.20241213202545.jpg", + "lprice": "35900", + "hprice": "", + "mallName": "네이버", + "productId": "51929018110", + "productType": "1", + "brand": "", + "maker": "코스맥스펫", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "세이펫 접이식 안전문 1.5m 고양이 접이식 방묘문", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4937924597", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8248244\/82482448908.10.jpg", + "lprice": "142000", + "hprice": "", + "mallName": "세이펫", + "productId": "82482448908", + "productType": "2", + "brand": "세이펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "안전문" + }, + { + "title": "고양이 난간 안전망 복층 베란다 방묘창 방묘문 방충망 캣도어 일반형검정1mx1m", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6187449408", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8373194\/83731948985.5.jpg", + "lprice": "5000", + "hprice": "", + "mallName": "나이스메쉬", + "productId": "83731948985", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "안전문" + }, + { + "title": "티지오매트 우다다 강아지매트 애견 롤 미끄럼방지 거실 복도 펫 110x50cm (10T)", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5154283552", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8269880\/82698804475.15.jpg", + "lprice": "10900", + "hprice": "", + "mallName": "티지오매트", + "productId": "82698804475", + "productType": "2", + "brand": "티지오매트", + "maker": "티지오", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "[페스룸] 논슬립 폴더블 욕조 강아지 고양이 목욕 접이식 스파욕조 애견욕조", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5534035049", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8307853\/83078530731.2.jpg", + "lprice": "51900", + "hprice": "", + "mallName": "PETHROOM", + "productId": "83078530731", + "productType": "2", + "brand": "페스룸", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "샤워기\/욕조" + }, + { + "title": "제스퍼우드 원목 강아지 침대 S 애견 고양이 집 하우스 반려견 반려묘 반려동물 쿠션", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4504272686", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8204879\/82048795634.4.jpg", + "lprice": "55000", + "hprice": "", + "mallName": "제스퍼우드공방", + "productId": "82048795634", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "침대\/해먹" + }, + { + "title": "펫코본 강아지집 원목 고양이 숨숨집 애견방석 강아지하우스 아이보리, M", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54190213755", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5419021\/54190213755.20250414164048.jpg", + "lprice": "49000", + "hprice": "", + "mallName": "네이버", + "productId": "54190213755", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "[베토퀴놀][냉장배송] 강아지 고양이 아조딜 90캡슐 - 신장질환 보조제", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5572133410", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8311662\/83116629447.11.jpg", + "lprice": "75000", + "hprice": "", + "mallName": "블리펫89", + "productId": "83116629447", + "productType": "2", + "brand": "", + "maker": "베토퀴놀", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "오구구 강아지 고양이 정수기 분수대", + "link": "https:\/\/search.shopping.naver.com\/catalog\/29974021619", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_2997402\/29974021619.20211206154812.jpg", + "lprice": "29800", + "hprice": "", + "mallName": "네이버", + "productId": "29974021619", + "productType": "1", + "brand": "오구구", + "maker": "HOLYTACHI", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "정수기\/필터" + }, + { + "title": "강아지 방석 쿠션 애견 마약 반려견 꿀잠 개 본능 무중력 중형견 애완견 방석 S", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5783071611", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8332757\/83327571025.6.jpg", + "lprice": "29900", + "hprice": "", + "mallName": "알록달록댕댕샵", + "productId": "83327571025", + "productType": "2", + "brand": "쉼쉼", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "레토 고양이 숨숨집 2단 방석 쿠션 하우스 스크래쳐", + "link": "https:\/\/search.shopping.naver.com\/catalog\/45872181967", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_4587218\/45872181967.20250523124214.jpg", + "lprice": "18170", + "hprice": "", + "mallName": "네이버", + "productId": "45872181967", + "productType": "1", + "brand": "레토", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "바라바 강아지 안전문 견문 애견 고양이 방묘문 베란다 펫도어", + "link": "https:\/\/search.shopping.naver.com\/catalog\/35924635714", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3592463\/35924635714.20231129051432.jpg", + "lprice": "29800", + "hprice": "", + "mallName": "네이버", + "productId": "35924635714", + "productType": "1", + "brand": "바라바", + "maker": "바라바", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "안전문" + }, + { + "title": "라우라반 강아지밥그릇 물그릇 고양이 식탁 도자기 높이 조절 식기 그릇 수반", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10130414591", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8767491\/87674917667.1.jpg", + "lprice": "19500", + "hprice": "", + "mallName": "라우라반", + "productId": "87674917667", + "productType": "2", + "brand": "라우라반", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "강아지 고양이 빗 스팀 브러쉬 털청소기 스팀빗", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10069170353", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8761367\/87613672977.17.jpg", + "lprice": "11900", + "hprice": "", + "mallName": "캣트럴파크", + "productId": "87613672977", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "브러시\/빗" + }, + { + "title": "비니비니펫 아지트 스크래처 고양이 스크래쳐 대형 숨숨집 하우스 스크래쳐", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10280963095", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8782546\/87825466919.13.jpg", + "lprice": "37900", + "hprice": "", + "mallName": "비니비니펫", + "productId": "87825466919", + "productType": "2", + "brand": "비니비니펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "퓨어프렌즈 퓨어 밸런스 천연 강아지 샴푸 300ml, 1개", + "link": "https:\/\/search.shopping.naver.com\/catalog\/52203429639", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5220342\/52203429639.20250331163115.jpg", + "lprice": "23500", + "hprice": "", + "mallName": "네이버", + "productId": "52203429639", + "productType": "1", + "brand": "퓨어프렌즈", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "샴푸\/린스\/비누" + }, + { + "title": "고양이 밥그릇 도자기 세라믹 급체방지 슬로우 식기 그릇 높이 식탁", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6131993369", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8367649\/83676492857.2.jpg", + "lprice": "9400", + "hprice": "", + "mallName": "마브펫", + "productId": "83676492857", + "productType": "2", + "brand": "마브펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "강아지 고양이 아이스팩 파우치 여름 베개 젤리곰 M사이즈", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8554743594", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8609924\/86099243917.3.jpg", + "lprice": "9900", + "hprice": "", + "mallName": "예쁘개살고양", + "productId": "86099243917", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "강아지 고양이 애견 대형견 하우스 텐트 야외개집 숨숨집 S", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7626829741", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8517133\/85171330063.1.jpg", + "lprice": "24000", + "hprice": "", + "mallName": "미우프", + "productId": "85171330063", + "productType": "2", + "brand": "UNKNOWN", + "maker": "UNKNOWN", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "이너피스 원목 강아지집 애견하우스 고양이숨숨집 A", + "link": "https:\/\/smartstore.naver.com\/main\/products\/3307441934", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8080606\/80806066376.14.jpg", + "lprice": "79000", + "hprice": "", + "mallName": "innerpeace이너피스", + "productId": "80806066376", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "펫토 알러프리 강아지방석 고양이 애견 쿠션 쿨방석 범퍼형 라이트그레이, M", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54236867637", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5423686\/54236867637.20250416115734.jpg", + "lprice": "49800", + "hprice": "", + "mallName": "네이버", + "productId": "54236867637", + "productType": "1", + "brand": "펫토", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "원시림의곰 금빗", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54233894193", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5423389\/54233894193.20250416084020.jpg", + "lprice": "65700", + "hprice": "", + "mallName": "네이버", + "productId": "54233894193", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "브러시\/빗" + }, + { + "title": "원목 캣타워 캣워커 캣폴 고양이에버랜드 2 (고양이와나무꾼)", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4701485622", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8224600\/82246006480.11.jpg", + "lprice": "312000", + "hprice": "", + "mallName": "고양이와나무꾼", + "productId": "82246006480", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "캣타워\/캣폴" + }, + { + "title": "펫펫펫 고양이 스크래쳐 수직 대형", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5491461598", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8303595\/83035956658.4.jpg", + "lprice": "26700", + "hprice": "", + "mallName": "펫펫펫 PPPET", + "productId": "83035956658", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "슈퍼펫 강아지밥그릇 고양이 식기 물그릇 3단 높이조절 커브 도자기 식탁세트", + "link": "https:\/\/search.shopping.naver.com\/catalog\/55401583212", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5540158\/55401583212.20250621045841.jpg", + "lprice": "22900", + "hprice": "", + "mallName": "네이버", + "productId": "55401583212", + "productType": "1", + "brand": "슈퍼펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "펫테일 올독방석 강아지 방석 대형견 쿠션 극세사 면 M", + "link": "https:\/\/smartstore.naver.com\/main\/products\/4827270040", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8237179\/82371792892.3.jpg", + "lprice": "24900", + "hprice": "", + "mallName": "펫테일코리아", + "productId": "82371792892", + "productType": "2", + "brand": "펫테일", + "maker": "주떼인터내셔날", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "펫조은자리 듀라론 100% 강아지 쿨매트 3D에어매쉬 냉감패드 애견 고양이 여름방석", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11697645474", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8924215\/89242155941.1.jpg", + "lprice": "39800", + "hprice": "", + "mallName": "영메디칼바이오", + "productId": "89242155941", + "productType": "2", + "brand": "", + "maker": "영메디칼바이오", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "까치토 더보틀 휴대용 강아지 고양이 물통 애견 산책물병 원터치 급수기", + "link": "https:\/\/smartstore.naver.com\/main\/products\/9561639195", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8710614\/87106141465.7.jpg", + "lprice": "9800", + "hprice": "", + "mallName": "까치토", + "productId": "87106141465", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "급수기\/물병" + }, + { + "title": "펫모어 오메가침대 여름 방수 쿨매트 슬개골 강아지침대 펫 베드 애견 방석 고양이쇼파 소파 [국내생산]", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6096500544", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8364100\/83641000032.2.jpg", + "lprice": "59000", + "hprice": "", + "mallName": "미르공간", + "productId": "83641000032", + "productType": "2", + "brand": "펫모어", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "침대\/해먹" + }, + { + "title": "이츠독 강아지 고양이 쿨매트 인견 방석 여름 애견 쿨링 패드", + "link": "https:\/\/smartstore.naver.com\/main\/products\/2964096923", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8046184\/80461840901.1.jpg", + "lprice": "32000", + "hprice": "", + "mallName": "이츠독", + "productId": "80461840901", + "productType": "2", + "brand": "이츠독", + "maker": "이츠독", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "펫쭈 고양이 AI 자동급식기 강아지 360도 회전 카메라 반려동물 펫", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10420577952", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8796508\/87965082938.17.jpg", + "lprice": "273900", + "hprice": "", + "mallName": "펫쭈", + "productId": "87965082938", + "productType": "2", + "brand": "펫쭈", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "자동급식기" + }, + { + "title": "올웨이즈올펫 코닉 숨숨집 고양이 강아지 하우스 그레이, M", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53665784947", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5366578\/53665784947.20250320141714.jpg", + "lprice": "25400", + "hprice": "", + "mallName": "네이버", + "productId": "53665784947", + "productType": "1", + "brand": "올웨이즈올펫", + "maker": "지오위즈", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "펫초이스 댕피스텔 강아지 텐트 고양이 쿠션 숨숨 집 하우스 크림 크림, S", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54190191811", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5419019\/54190191811.20250429171332.jpg", + "lprice": "38900", + "hprice": "", + "mallName": "네이버", + "productId": "54190191811", + "productType": "1", + "brand": "펫초이스", + "maker": "프랑코모다", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "하우스" + }, + { + "title": "고양이 스크래쳐 숨숨집 하우스 대형 원목 스크레쳐 A type", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8137026692", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8568152\/85681527015.2.jpg", + "lprice": "14900", + "hprice": "", + "mallName": "미우프", + "productId": "85681527015", + "productType": "2", + "brand": "UNKNOWN", + "maker": "UNKNOWN", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "제로넥카라 강아지 고양이 초경량 가벼운 편안한 중성화 미용 깔대기 실내용 넥카라", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7499603619", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8504410\/85044103941.jpg", + "lprice": "24000", + "hprice": "", + "mallName": "루니펫", + "productId": "85044103941", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "넥카라\/보호대" + }, + { + "title": "펫토 클린펫 강아지 계단 고양이 논슬립 스텝 라이트그레이, 2단", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54892869310", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5489286\/54892869310.20250521143121.jpg", + "lprice": "49800", + "hprice": "", + "mallName": "네이버", + "productId": "54892869310", + "productType": "1", + "brand": "펫토", + "maker": "펫토", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "계단\/스텝" + }, + { + "title": "[폴딩70x60cm] 디팡 4mm 미끄럼방지 강아지 고양이매트 애견매트 슬개골탈구예방", + "link": "https:\/\/smartstore.naver.com\/main\/products\/2122490803", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_1206556\/12065560134.58.jpg", + "lprice": "14800", + "hprice": "", + "mallName": "디팡", + "productId": "12065560134", + "productType": "2", + "brand": "디팡", + "maker": "디팡", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "슈퍼벳 안티콜록 강아지 기관지 영양제 협착증 호흡기 기침 약x 60캡슐", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8470675034", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8601517\/86015175357.5.jpg", + "lprice": "25020", + "hprice": "", + "mallName": "슈퍼벳", + "productId": "86015175357", + "productType": "2", + "brand": "슈퍼벳", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "HAKKI 강아지 해먹 대형견쿨매트 애견침대 블랙색상 S", + "link": "https:\/\/smartstore.naver.com\/main\/products\/3477192248", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8102170\/81021709385.jpg", + "lprice": "18800", + "hprice": "", + "mallName": "돈키호테쇼핑몰", + "productId": "81021709385", + "productType": "2", + "brand": "", + "maker": "돈키호테", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "침대\/해먹" + }, + { + "title": "링펫 강아지 고양이 물그릇 밥그릇 식기 아크릴 유리수반 중형", + "link": "https:\/\/search.shopping.naver.com\/catalog\/33629233457", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3362923\/33629233457.20250512014917.jpg", + "lprice": "18900", + "hprice": "", + "mallName": "네이버", + "productId": "33629233457", + "productType": "1", + "brand": "링펫", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "페노비스 고양이 강아지 치약 바르는 입냄새 플라그 구취 치석 제거 임상균주 오랄벳", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10800961164", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8834546\/88345467154.4.jpg", + "lprice": "15900", + "hprice": "", + "mallName": "페노비스", + "productId": "88345467154", + "productType": "2", + "brand": "페노비스", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "치약" + }, + { + "title": "네네린도 수직 월 고양이 스크래쳐 웜 화이트, L(대형)", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54114571823", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5411457\/54114571823.20250411160223.jpg", + "lprice": "21400", + "hprice": "", + "mallName": "네이버", + "productId": "54114571823", + "productType": "1", + "brand": "네네린도", + "maker": "네네린도", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "스크래쳐" + }, + { + "title": "리포소펫 강아지매트 미끄럼방지 애견 반려견 거실 복도 셀프시공 롤매트 6T 110X50cm", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5151541190", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8269606\/82696062046.45.jpg", + "lprice": "11400", + "hprice": "", + "mallName": "리포소펫", + "productId": "82696062046", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "매트" + }, + { + "title": "페노비스 고양이 강아지 관절영양제 슬개골 연골 관절염 노견영양제 캡슐 벳 글루코사민", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11149454290", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8869396\/88693964612.5.jpg", + "lprice": "22900", + "hprice": "", + "mallName": "페노비스", + "productId": "88693964612", + "productType": "2", + "brand": "페노비스", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "펫코본 강아지계단 고양이 논슬립 애견 펫스텝 침대 슬라이드 A형", + "link": "https:\/\/search.shopping.naver.com\/catalog\/55343999616", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5534399\/55343999616.20250618102528.jpg", + "lprice": "59000", + "hprice": "", + "mallName": "네이버", + "productId": "55343999616", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "계단\/스텝" + }, + { + "title": "보울보울 고양이 밥그릇 강아지 식기 헬로볼 세트", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5108893506", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8265341\/82653415552.10.jpg", + "lprice": "31900", + "hprice": "", + "mallName": "보울보울", + "productId": "82653415552", + "productType": "2", + "brand": "보울보울", + "maker": "보울보울", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "강아지방석 고양이 쿠션 매트 유모차 개모차 개 꿀잠 이불 원터치 떠블유곰 소", + "link": "https:\/\/smartstore.naver.com\/main\/products\/8571815502", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8611631\/86116315825.jpg", + "lprice": "32000", + "hprice": "", + "mallName": "해피앤퍼피", + "productId": "86116315825", + "productType": "2", + "brand": "", + "maker": "해피앤퍼피", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "큐브플래닛 윈도우 고양이 선반 해먹 캣워커 캣선반 소형 (창문, 창틀에 설치하세요)", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5660301120", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8320479\/83204798455.9.jpg", + "lprice": "19800", + "hprice": "", + "mallName": "큐브 플래닛", + "productId": "83204798455", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "캣타워\/캣폴" + }, + { + "title": "아껴주다 저자극 천연 고양이 샴푸 500ml (고양이 비듬, 턱드름 관리)", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5054264001", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8259878\/82598785222.12.jpg", + "lprice": "18500", + "hprice": "", + "mallName": "아껴주다", + "productId": "82598785222", + "productType": "2", + "brand": "아껴주다", + "maker": "아껴주다", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "샴푸\/린스\/비누" + }, + { + "title": "하개랩 상쾌하개 강아지 고양이 기관지 영양제 협착증 기침 켁켁거림", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10078212989", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8762271\/87622715642.2.jpg", + "lprice": "25000", + "hprice": "", + "mallName": "하개 LAB", + "productId": "87622715642", + "productType": "2", + "brand": "하개LAB", + "maker": "칠명바이오", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "강아지 건강\/관리용품", + "category4": "영양제" + }, + { + "title": "강아지 방석 대형견 애견 쿠션 포근한 반려견 침대 그레이 L", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5652281382", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8319677\/83196778686.41.jpg", + "lprice": "19800", + "hprice": "", + "mallName": "펫브랜디", + "productId": "83196778686", + "productType": "2", + "brand": "펫브랜디", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "쿠션\/방석" + }, + { + "title": "네코이찌 고양이 발톱깍이", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53669243993", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5366924\/53669243993.20250320194701.jpg", + "lprice": "15900", + "hprice": "", + "mallName": "네이버", + "productId": "53669243993", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "발톱\/발 관리" + }, + { + "title": "펠리웨이 클래식 스타터키트 고양이 페로몬 디퓨저 이사 동물병원외출 스트레스완화 진정", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11486023143", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8903053\/89030533508.jpg", + "lprice": "34000", + "hprice": "", + "mallName": "MOKOA", + "productId": "89030533508", + "productType": "2", + "brand": "펠리웨이", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "에센스\/향수\/밤" + }, + { + "title": "위티 강아지 빗 콤빗 고양이 슬리커 브러쉬", + "link": "https:\/\/smartstore.naver.com\/main\/products\/9970804750", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8751530\/87515307023.2.jpg", + "lprice": "8900", + "hprice": "", + "mallName": "위티witty", + "productId": "87515307023", + "productType": "2", + "brand": "ouitt", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "브러시\/빗" + }, + { + "title": "보니렌 퓨어냥 고양이 정수기 강아지정수기 고양이 음수대 자동급수기", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11364128365", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8890863\/88908638730.5.jpg", + "lprice": "59900", + "hprice": "", + "mallName": "보니렌", + "productId": "88908638730", + "productType": "2", + "brand": "보니렌", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "정수기\/필터" + }, + { + "title": "탑컷 애견이발기 YD9000 프로 클리퍼 강아지 고양이 미용 바리깡", + "link": "https:\/\/smartstore.naver.com\/main\/products\/5238078134", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8278260\/82782600545.6.jpg", + "lprice": "90000", + "hprice": "", + "mallName": "탑컷", + "productId": "82782600545", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "이발기" + }, + { + "title": "세임스텝 [무선] 애견 미용기 클리퍼 강아지 고양이 바리깡 셀프 펫 진공 흡입 털 청소기", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11205843632", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8875035\/88750353963.2.jpg", + "lprice": "109900", + "hprice": "", + "mallName": "뉴트로 스토어", + "productId": "88750353963", + "productType": "2", + "brand": "세임스텝", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "미용\/목욕", + "category4": "이발기" + }, + { + "title": "독톡 강아지 커스텀 울타리 1P", + "link": "https:\/\/smartstore.naver.com\/main\/products\/2426030847", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_1325105\/13251055464.14.jpg", + "lprice": "22500", + "hprice": "", + "mallName": "독톡", + "productId": "13251055464", + "productType": "2", + "brand": "독톡", + "maker": "독톡", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "울타리" + }, + { + "title": "캣튜디오 고양이 유리 물그릇 강아지 밥그릇 식기 수반 유리화이트식기S", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6512908155", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8405740\/84057408488.7.jpg", + "lprice": "7400", + "hprice": "", + "mallName": "캣튜디오", + "productId": "84057408488", + "productType": "2", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "공간녹백 고양이 캣휠 무소음 켓휠 쳇바퀴 M 1개", + "link": "https:\/\/search.shopping.naver.com\/catalog\/49559295153", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_4955929\/49559295153.20240802032032.jpg", + "lprice": "82000", + "hprice": "", + "mallName": "네이버", + "productId": "49559295153", + "productType": "1", + "brand": "", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "캣타워\/스크래쳐", + "category4": "캣휠" + }, + { + "title": "바라바 강아지 밥그릇 고양이 물그릇 애견 도자기 그릇 높이조절 식기 식탁 수반 세트", + "link": "https:\/\/search.shopping.naver.com\/catalog\/50033034869", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5003303\/50033034869.20240829050921.jpg", + "lprice": "28800", + "hprice": "", + "mallName": "네이버", + "productId": "50033034869", + "productType": "1", + "brand": "바라바", + "maker": "바라바", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "식기\/급수기", + "category4": "식기\/식탁" + }, + { + "title": "이드몽 강아지 넥카라 고양이 애견 깔대기 쿠션 시즌2프라가S", + "link": "https:\/\/search.shopping.naver.com\/catalog\/36713411331", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3671341\/36713411331.20230618043123.jpg", + "lprice": "13900", + "hprice": "", + "mallName": "네이버", + "productId": "36713411331", + "productType": "1", + "brand": "이드몽", + "maker": "", + "category1": "생활\/건강", + "category2": "반려동물", + "category3": "리빙용품", + "category4": "넥카라\/보호대" + }, + { + "title": "Apple 아이패드 11세대 실버, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370909201", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337090\/53370909201.20250403155536.jpg", + "lprice": "520500", + "hprice": "", + "mallName": "네이버", + "productId": "53370909201", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 11세대 블루, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370758552", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337075\/53370758552.20250403155332.jpg", + "lprice": "525800", + "hprice": "", + "mallName": "네이버", + "productId": "53370758552", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 11세대 핑크, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370808130", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337080\/53370808130.20250403155104.jpg", + "lprice": "527700", + "hprice": "", + "mallName": "네이버", + "productId": "53370808130", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 11세대 옐로, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370875209", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337087\/53370875209.20250403155436.jpg", + "lprice": "525900", + "hprice": "", + "mallName": "네이버", + "productId": "53370875209", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 에어 11 7세대 M3 스페이스그레이, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53371237199", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337123\/53371237199.20250403153417.jpg", + "lprice": "884810", + "hprice": "", + "mallName": "네이버", + "productId": "53371237199", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 11세대 실버, 256GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370909202", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337090\/53370909202.20250403155553.jpg", + "lprice": "679000", + "hprice": "", + "mallName": "네이버", + "productId": "53370909202", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 미니 7세대 스페이스그레이, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53351852199", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5335185\/53351852199.20250304153610.jpg", + "lprice": "696570", + "hprice": "", + "mallName": "네이버", + "productId": "53351852199", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 에어 13 7세대 M3 스페이스그레이, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53371410788", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337141\/53371410788.20250403154146.jpg", + "lprice": "1199040", + "hprice": "", + "mallName": "네이버", + "productId": "53371410788", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 프로 11 5세대 M4 스탠다드 글래스 스페이스 블랙, 256GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53394317288", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5339431\/53394317288.20250306171208.jpg", + "lprice": "1393580", + "hprice": "", + "mallName": "네이버", + "productId": "53394317288", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 프로 13 7세대 M4 스탠다드 글래스 스페이스 블랙, 256GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53491820442", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5349182\/53491820442.20250311162829.jpg", + "lprice": "1897700", + "hprice": "", + "mallName": "네이버", + "productId": "53491820442", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 11세대 블루, 256GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53370758553", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337075\/53370758553.20250403155346.jpg", + "lprice": "679000", + "hprice": "", + "mallName": "네이버", + "productId": "53370758553", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "애플 아이패드 11세대 A16 WIFI 128GB 2025출시 관부포함 미국애플정품", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11553506634", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8909801\/89098017040.3.jpg", + "lprice": "459900", + "hprice": "", + "mallName": "제니퍼스토리", + "productId": "89098017040", + "productType": "2", + "brand": "아이패드", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 10세대 실버, 64GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53212173186", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5321217\/53212173186.20250225172035.jpg", + "lprice": "557000", + "hprice": "", + "mallName": "네이버", + "productId": "53212173186", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 2025 아이패드 에어 11 M3 스페이스그레이 128GB Wi-Fi MC9W4KH\/A", + "link": "https:\/\/link.coupang.com\/re\/PCSNAVERPCSDP?pageKey=8820001925&ctag=8820001925&lptag=I25079475724&itemId=25079475724&vendorItemId=92083407421&spec=10305197", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5393557\/53935570413.1.jpg", + "lprice": "884820", + "hprice": "", + "mallName": "쿠팡", + "productId": "53935570413", + "productType": "3", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "미사용 애플 아이패드 미니 5세대 WIFI 64GB 스페이스그레이", + "link": "https:\/\/smartstore.naver.com\/main\/products\/6555981468", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8410048\/84100481801.jpg", + "lprice": "398000", + "hprice": "", + "mallName": "도란:", + "productId": "84100481801", + "productType": "2", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 에어 11 7세대 M3 퍼플, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53371237381", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5337123\/53371237381.20250403153732.jpg", + "lprice": "897000", + "hprice": "", + "mallName": "네이버", + "productId": "53371237381", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 9세대 스페이스그레이, 64GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53352561711", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5335256\/53352561711.20250304165819.jpg", + "lprice": "434490", + "hprice": "", + "mallName": "네이버", + "productId": "53352561711", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "[미국당일출고]애플 아이패드 11세대 A16 WIFI 128GB 2025 신제품 미국 정품", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11553327971", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8909783\/89097838377.4.jpg", + "lprice": "459900", + "hprice": "", + "mallName": "뉴욕 스토리", + "productId": "89097838377", + "productType": "2", + "brand": "아이패드", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 에어 13 6세대 M2 퍼플, 128GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53318261103", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5331826\/53318261103.20250303172440.jpg", + "lprice": "1019140", + "hprice": "", + "mallName": "네이버", + "productId": "53318261103", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "Apple 아이패드 프로 11 5세대 M4 스탠다드 글래스 실버, 256GB, WiFi전용", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53394328115", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5339432\/53394328115.20250306172608.jpg", + "lprice": "1392840", + "hprice": "", + "mallName": "네이버", + "productId": "53394328115", + "productType": "1", + "brand": "Apple", + "maker": "Apple", + "category1": "디지털\/가전", + "category2": "태블릿PC", + "category3": "", + "category4": "" + }, + { + "title": "삼성 노트북 i7 윈도우11프로 사무용 인강용 업무용 교육용 학생 노트북 NT551XDA [소상공인\/기업체 우대]", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10532359076", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8807686\/88076864436.4.jpg", + "lprice": "2598990", + "hprice": "", + "mallName": "삼성온라인몰", + "productId": "88076864436", + "productType": "2", + "brand": "삼성", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53902497170", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5390249\/53902497170.20250401141458.jpg", + "lprice": "3749000", + "hprice": "", + "mallName": "네이버", + "productId": "53902497170", + "productType": "1", + "brand": "ASUS", + "maker": "ASUS", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "ASUS 노트북 비보북15 라이젠7 8GB 512GB 대학생 인강용 사무용 저렴한 포토샵", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11577222869", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8912173\/89121733275.4.jpg", + "lprice": "519000", + "hprice": "", + "mallName": "창이로운", + "productId": "89121733275", + "productType": "2", + "brand": "ASUS", + "maker": "ASUS", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북5 프로 NT960XHA-KP72G 32GB, 512GB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54024331464", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5402433\/54024331464.20250407101024.jpg", + "lprice": "2309980", + "hprice": "", + "mallName": "네이버", + "productId": "54024331464", + "productType": "1", + "brand": "갤럭시북5 프로", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "ASUS 젠북 A14 퀄컴 스냅드래곤X 초경량 사무용 대학생 업무용 노트북 Win11", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11359933656", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8890444\/88904444007.jpg", + "lprice": "1166000", + "hprice": "", + "mallName": "ASUS공식총판 에스라이즈", + "productId": "88904444007", + "productType": "2", + "brand": "ASUS", + "maker": "ASUS", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북5 프로360 NT960QHA-KC71G", + "link": "https:\/\/search.shopping.naver.com\/catalog\/51340833624", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5134083\/51340833624.20241111121622.jpg", + "lprice": "2224980", + "hprice": "", + "mallName": "네이버", + "productId": "51340833624", + "productType": "1", + "brand": "갤럭시북5 프로360", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "주연테크 캐리북e J3GW", + "link": "https:\/\/search.shopping.naver.com\/catalog\/24875454523", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_2487545\/24875454523.20201117114806.jpg", + "lprice": "219000", + "hprice": "", + "mallName": "네이버", + "productId": "24875454523", + "productType": "1", + "brand": "주연테크", + "maker": "주연테크", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "엘지 그램 14세대 울트라 7 AI 인공지능 32GB 1TB 17Z90S 터치 병행", + "link": "https:\/\/smartstore.naver.com\/main\/products\/7049938391", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8459443\/84594438713.11.jpg", + "lprice": "1749000", + "hprice": "", + "mallName": "G-스토어", + "productId": "84594438713", + "productType": "2", + "brand": "LG그램", + "maker": "LG전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북4 NT750XGR-A71A 사무용 업무용 i7 노트북", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10093514318", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8763801\/87638016995.14.jpg", + "lprice": "1098000", + "hprice": "", + "mallName": "삼성공식파트너 코인비엠에스", + "productId": "87638016995", + "productType": "3", + "brand": "갤럭시북4", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "레노버 아이디어패드 Slim3 15ABR8 82XM00ELKR RAM 16GB, 512GB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54909327778", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5490932\/54909327778.20250522125003.jpg", + "lprice": "559000", + "hprice": "", + "mallName": "네이버", + "productId": "54909327778", + "productType": "1", + "brand": "아이디어패드", + "maker": "레노버", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "MSI 게이밍노트북 17 영상편집 캐드 고사양 i9 13980HX RTX 4070 노트북", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11205471249", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8874998\/88749981580.1.jpg", + "lprice": "1999000", + "hprice": "", + "mallName": "에이치텍 스토어", + "productId": "88749981580", + "productType": "2", + "brand": "MSI", + "maker": "MSI", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북3 NT750XFT-A51A", + "link": "https:\/\/search.shopping.naver.com\/catalog\/39746112618", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_3974611\/39746112618.20230502165309.jpg", + "lprice": "798990", + "hprice": "", + "mallName": "네이버", + "productId": "39746112618", + "productType": "1", + "brand": "갤럭시북3", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북4 NT750XGQ-A51A", + "link": "https:\/\/search.shopping.naver.com\/catalog\/46633068618", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_4663306\/46633068618.20240325185204.jpg", + "lprice": "1098990", + "hprice": "", + "mallName": "네이버", + "productId": "46633068618", + "productType": "1", + "brand": "갤럭시북4", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "LG전자 울트라PC 15UD50R-GX56K 8GB, 256GB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54398511102", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5439851\/54398511102.20250424175153.jpg", + "lprice": "558890", + "hprice": "", + "mallName": "네이버", + "productId": "54398511102", + "productType": "1", + "brand": "울트라PC", + "maker": "LG전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "LG전자 그램 프로 16ZD90SP-GX56K 16GB, 256GB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/52647379133", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5264737\/52647379133.20250124115648.jpg", + "lprice": "1466380", + "hprice": "", + "mallName": "네이버", + "productId": "52647379133", + "productType": "1", + "brand": "그램 프로", + "maker": "LG전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "LG전자 LG그램 15ZD90T-GX59K 32GB, 256GB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/54672053704", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5467205\/54672053704.20250509164753.jpg", + "lprice": "1668940", + "hprice": "", + "mallName": "네이버", + "productId": "54672053704", + "productType": "1", + "brand": "LG그램", + "maker": "LG전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "HP OMEN 16-xf0052ax 16GB, 1TB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53663904780", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5366390\/53663904780.20250320095528.jpg", + "lprice": "1888950", + "hprice": "", + "mallName": "네이버", + "productId": "53663904780", + "productType": "1", + "brand": "HP", + "maker": "HP", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성노트북 2024 갤럭시북4 NT750XGR-A51A SSD 총 512GB 윈도우11홈", + "link": "https:\/\/smartstore.naver.com\/main\/products\/10164369375", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8770887\/87708872717.jpg", + "lprice": "949000", + "hprice": "", + "mallName": "더하기Shop", + "productId": "87708872717", + "productType": "2", + "brand": "갤럭시북4", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "삼성전자 갤럭시북5 프로360 NT960QHA-KD72 32GB, 1TB", + "link": "https:\/\/search.shopping.naver.com\/catalog\/53666908447", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_5366690\/53666908447.20250320160726.jpg", + "lprice": "2698990", + "hprice": "", + "mallName": "네이버", + "productId": "53666908447", + "productType": "1", + "brand": "갤럭시북5 프로360", + "maker": "삼성전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" + }, + { + "title": "LG그램 노트북 가벼운 가성비 그램 AI AMD 크라켄5 16GB", + "link": "https:\/\/smartstore.naver.com\/main\/products\/11859744023", + "image": "https:\/\/shopping-phinf.pstatic.net\/main_8940425\/89404254616.jpg", + "lprice": "1199000", + "hprice": "", + "mallName": "카인드스토어몰", + "productId": "89404254616", + "productType": "2", + "brand": "LG전자", + "maker": "LG전자", + "category1": "디지털\/가전", + "category2": "노트북", + "category3": "", + "category4": "" } ] diff --git a/src/setupTests.js b/src/setupTests.js index d0de870d..d72b8905 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1 +1,16 @@ import "@testing-library/jest-dom"; +import { configure } from "@testing-library/dom"; +import { afterAll, beforeAll } from "vitest"; +import { server } from "./__tests__/mockServerHandler.js"; + +configure({ + asyncUtilTimeout: 5000, +}); + +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); +}); + +afterAll(() => { + server.close(); +}); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 00000000..3824a8de --- /dev/null +++ b/src/styles.css @@ -0,0 +1,157 @@ +/* 추가 CSS 스타일 */ + +/* 상품 상세 페이지용 스타일 */ +.product-detail-container { + min-height: calc(100vh - 80px); +} + +.breadcrumb-link { + transition: color 0.2s ease; +} + +.breadcrumb-link:hover { + color: #3b82f6; +} + +.related-product-card { + transition: all 0.2s ease; +} + +.related-product-card:hover { + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.product-detail-image { + max-height: 400px; + object-fit: contain; +} + +/* 상품 카드 호버 효과 개선 */ +.product-card { + transition: all 0.2s ease; +} + +.product-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +/* 토스트 애니메이션 */ +@keyframes slide-up { + from { + transform: translateY(100px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.animate-slide-up { + animation: slide-up 0.3s ease-out; +} + +/* 모달 애니메이션 */ +.modal-overlay { + animation: fade-in 0.2s ease-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* 스켈레톤 로딩 */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* 버튼 비활성화 스타일 */ +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 로딩 스피너 */ +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +/* 반응형 그리드 개선 */ +@media (min-width: 640px) { + .responsive-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 768px) { + .responsive-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 1024px) { + .responsive-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* 스크롤바 스타일링 */ +.overflow-y-auto::-webkit-scrollbar { + width: 6px; +} + +.overflow-y-auto::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* 모바일 터치 최적화 */ +@media (max-width: 640px) { + .product-card { + transition: transform 0.1s ease-out; + } + + .product-card:active { + transform: scale(0.98); + } + + button:active { + transform: scale(0.95); + } +} diff --git a/vite.config.js b/vite.config.js index ced41c4c..2eef1c44 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,5 +6,10 @@ export default defineConfig({ environment: "jsdom", setupFiles: "./src/setupTests.js", exclude: ["**/e2e/**", "**/*.e2e.spec.js", "**/node_modules/**"], + poolOptions: { + threads: { + singleThread: true, + }, + }, }, }); From 2379c7bdf8923d9ee9cd1d53117c6768e57241ca Mon Sep 17 00:00:00 2001 From: 1lmean Date: Mon, 10 Nov 2025 18:17:40 +0900 Subject: [PATCH 04/43] =?UTF-8?q?feat:=20=EB=B0=9C=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/mockServiceWorker.js | 14 +- src/components/Footer.js | 9 + src/components/Header.js | 22 + src/components/ProductDetali.js | 143 ++++ src/components/ProductList.js | 85 +++ src/components/SearchForm.js | 67 ++ src/components/index.js | 5 + src/main.js | 1175 +------------------------------ src/pages/DetailPage.js | 6 + src/pages/Homepage.js | 9 + src/pages/PageLayout.js | 12 + src/template.js | 1115 +++++++++++++++++++++++++++++ 12 files changed, 1515 insertions(+), 1147 deletions(-) create mode 100644 src/components/Footer.js create mode 100644 src/components/Header.js create mode 100644 src/components/ProductDetali.js create mode 100644 src/components/ProductList.js create mode 100644 src/components/SearchForm.js create mode 100644 src/components/index.js create mode 100644 src/pages/DetailPage.js create mode 100644 src/pages/Homepage.js create mode 100644 src/pages/PageLayout.js create mode 100644 src/template.js diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index d2b72964..b1f186b6 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -100,10 +100,7 @@ addEventListener("fetch", function (event) { // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === "only-if-cached" && - event.request.mode !== "same-origin" - ) { + if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") { return; } @@ -219,9 +216,7 @@ async function getResponse(event, client, requestId) { const acceptHeader = headers.get("accept"); if (acceptHeader) { const values = acceptHeader.split(",").map((value) => value.trim()); - const filteredValues = values.filter( - (value) => value !== "msw/passthrough", - ); + const filteredValues = values.filter((value) => value !== "msw/passthrough"); if (filteredValues.length > 0) { headers.set("accept", filteredValues.join(", ")); @@ -291,10 +286,7 @@ function sendToClient(client, message, transferrables = []) { resolve(event.data); }; - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]); + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); }); } diff --git a/src/components/Footer.js b/src/components/Footer.js new file mode 100644 index 00000000..e484faba --- /dev/null +++ b/src/components/Footer.js @@ -0,0 +1,9 @@ +export const Footer = () => { + return ` +
+
+

© ${new Date().getFullYear()} 항해플러스 프론트엔드 쇼핑몰

+
+
+ `; +}; diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 00000000..4ef44978 --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,22 @@ +export const Header = () => { + return ` +
+
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+
+ `; +}; diff --git a/src/components/ProductDetali.js b/src/components/ProductDetali.js new file mode 100644 index 00000000..7883c0da --- /dev/null +++ b/src/components/ProductDetali.js @@ -0,0 +1,143 @@ +export const ProductDetail = ({ loading, ...product }) => { + return ` + ${ + loading + ? ` +
+
+
+

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

+
+
+ ` + : ` +
+ + + +
+ +
+
+ ${product.title} +
+ +
+

+

${product.title}

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

관련 상품

+

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

+
+
+
+ + +
+
+
+
+ ` + } + `; +}; diff --git a/src/components/ProductList.js b/src/components/ProductList.js new file mode 100644 index 00000000..6fe45a04 --- /dev/null +++ b/src/components/ProductList.js @@ -0,0 +1,85 @@ +export const Skeleton = ` +
+
+
+
+
+
+
+
+
+`; + +export const Loading = ` +
+
+ + + + + 상품을 불러오는 중... +
+
+`; + +const ProductItem = ({ title, image, lprice }) => { + return ` +
+ +
+ ${title} +
+ +
+
+

+ ${title} +

+

+

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

+
+ + +
+
+ `; +}; + +export const ProductList = ({ products, loading }) => { + return ` +
+
+ + ${ + loading + ? ` +
+ + ${Skeleton.repeat(4)} +
+ ${Loading} + ` + : ` +
+ 총 ${products.length}의 상품 +
+
+ ${products.map(ProductItem).join("")} +
+ ` + } + +
+
+ `; +}; diff --git a/src/components/SearchForm.js b/src/components/SearchForm.js new file mode 100644 index 00000000..6624b441 --- /dev/null +++ b/src/components/SearchForm.js @@ -0,0 +1,67 @@ +export const SearchForm = () => { + return ` +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ `; +}; diff --git a/src/components/index.js b/src/components/index.js new file mode 100644 index 00000000..943d8ebb --- /dev/null +++ b/src/components/index.js @@ -0,0 +1,5 @@ +export * from "./Header.js"; +export * from "./Footer.js"; +export * from "./SearchForm.js"; +export * from "./ProductList.js"; +export * from "./ProductDetali.js"; diff --git a/src/main.js b/src/main.js index 4b055b89..32c3703a 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3 +1,7 @@ +import { Homepage } from "./pages/Homepage.js"; +import { getProduct, getProducts } from "./api/productApi.js"; +import { DetailPage } from "./pages/DetailPage.js"; + const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => worker.start({ @@ -5,1143 +9,42 @@ 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 push = ({ path }) => { +// history.pushState(null, null, path); +// render(); +// }; + +async function render() { + const $root = document.querySelector("#root"); + if (location.pathname === "/") { + $root.innerHTML = Homepage({ loading: true }); + + const data = await getProducts(); + $root.innerHTML = Homepage({ loading: false, ...data }); + + document.body.addEventListener("click", (e) => { + if (e.target.closest(".product-card")) { + const productId = e.target.closest(".product-card").dataset.productId; + history.pushState(null, null, `/products/${productId}`); + // router.push(`/products/${productId}`); + + render(); + } + }); + } else { + const productId = location.pathname.split("/").pop(); + const data = await getProduct(productId); + $root.innerHTML = DetailPage({ loading: false, ...data }); + } + + window.addEventListener("popstate", () => { + render(); + }); + // router.onpopstate = render +} - document.body.innerHTML = ` - ${상품목록_레이아웃_로딩} -
- ${상품목록_레이아웃_로딩완료} -
- ${상품목록_레이아웃_카테고리_1Depth} -
- ${상품목록_레이아웃_카테고리_2Depth} -
- ${토스트} -
- ${장바구니_비어있음} -
- ${장바구니_선택없음} -
- ${장바구니_선택있음} -
- ${상세페이지_로딩} -
- ${상세페이지_로딩완료} -
- ${_404_} - `; +function main() { + render(); } // 애플리케이션 시작 diff --git a/src/pages/DetailPage.js b/src/pages/DetailPage.js new file mode 100644 index 00000000..0a9be0cd --- /dev/null +++ b/src/pages/DetailPage.js @@ -0,0 +1,6 @@ +import { PageLayout } from "./PageLayout"; +import { ProductDetail } from "../components/index.js"; + +export const DetailPage = ({ loading, ...product }) => { + return PageLayout(ProductDetail({ loading, ...product })); +}; diff --git a/src/pages/Homepage.js b/src/pages/Homepage.js new file mode 100644 index 00000000..6264915a --- /dev/null +++ b/src/pages/Homepage.js @@ -0,0 +1,9 @@ +import { PageLayout } from "./PageLayout.js"; +import { SearchForm, ProductList } from "../components/index.js"; + +export const Homepage = ({ filters, products, pagination, loading = false }) => { + return PageLayout(` + ${SearchForm({ filters, pagination })} + ${ProductList({ products, loading })} + `); +}; diff --git a/src/pages/PageLayout.js b/src/pages/PageLayout.js new file mode 100644 index 00000000..49a4c378 --- /dev/null +++ b/src/pages/PageLayout.js @@ -0,0 +1,12 @@ +import { Header, Footer } from "../components/index.js"; +export const PageLayout = (children) => { + return ` +
+ ${Header()} +
+ ${children} +
+ ${Footer()} +
+ `; +}; diff --git a/src/template.js b/src/template.js new file mode 100644 index 00000000..8e571dba --- /dev/null +++ b/src/template.js @@ -0,0 +1,1115 @@ +const 상품목록_레이아웃_로딩 = ` +
+ +
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + 상품을 불러오는 중... +
+
+
+
+
+ +
+ `; + +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 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+
+ `; + +document.body.innerHTML = ` + ${상품목록_레이아웃_로딩} + //
+ // ${상품목록_레이아웃_로딩완료} + //
+ // ${상품목록_레이아웃_카테고리_1Depth} + //
+ // ${상품목록_레이아웃_카테고리_2Depth} + //
+ // ${토스트} + //
+ // ${장바구니_비어있음} + //
+ // ${장바구니_선택없음} + //
+ // ${장바구니_선택있음} + //
+ // ${상세페이지_로딩} + //
+ // ${상세페이지_로딩완료} + //
+ // ${_404_} + `; From e0fdfbf4a13b8010a7cd082529c6b41774b340fc Mon Sep 17 00:00:00 2001 From: 1lmean Date: Mon, 10 Nov 2025 19:51:57 +0900 Subject: [PATCH 05/43] =?UTF-8?q?chore:=20es6-string-html=20extension=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Footer.js | 13 +- src/components/Header.js | 35 ++--- src/components/ProductDetali.js | 251 ++++++++++++++++---------------- src/components/ProductList.js | 93 ++++++------ src/components/SearchForm.js | 121 +++++++-------- 5 files changed, 257 insertions(+), 256 deletions(-) diff --git a/src/components/Footer.js b/src/components/Footer.js index e484faba..c777f4ef 100644 --- a/src/components/Footer.js +++ b/src/components/Footer.js @@ -1,9 +1,10 @@ export const Footer = () => { - return ` -
-
-

© ${new Date().getFullYear()} 항해플러스 프론트엔드 쇼핑몰

-
-
+ const contentView = /*html*/ ` +
+
+

© ${new Date().getFullYear()} 항해플러스 프론트엔드 쇼핑몰

+
+
`; + return contentView; }; diff --git a/src/components/Header.js b/src/components/Header.js index 4ef44978..077f33ac 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -1,22 +1,23 @@ export const Header = () => { - return ` -
-
-
-

- 쇼핑몰 -

-
- - -
+ const contentView = /*html*/ ` +
+
+
+

+ 쇼핑몰 +

+
+ +
-
+
+
`; + return contentView; }; diff --git a/src/components/ProductDetali.js b/src/components/ProductDetali.js index 7883c0da..84442deb 100644 --- a/src/components/ProductDetali.js +++ b/src/components/ProductDetali.js @@ -1,143 +1,142 @@ export const ProductDetail = ({ loading, ...product }) => { - return ` - ${ - loading - ? ` -
-
-
-

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

-
+ const loadingView = /*html*/ ` +
+
+
+

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

+
+
+`; + const contentView = /*html*/ ` +
+ + + +
+ +
+
+ ${product.title}
- -
-
- 수량 + +
+

+

${product.title}

+ +
- - - + + + + + + + + + + + + + + +
+ 4.0 (749개 리뷰) +
+ +
+ ${Number(product.lprice).toLocaleString()}원 +
+ +
+ 재고 ${product.stock}개 +
+ +
+ ${product.description}
- -
- -
- + + +
+
+ +
- -
-
-

관련 상품

-

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

-
-
-
- + +
+ +
+ +
+
+

관련 상품

+

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

+
+
+
+
-
- ` - } +
+ + `; + + return ` + ${loading ? loadingView : contentView} `; }; diff --git a/src/components/ProductList.js b/src/components/ProductList.js index 6fe45a04..89f0a7bc 100644 --- a/src/components/ProductList.js +++ b/src/components/ProductList.js @@ -1,4 +1,4 @@ -export const Skeleton = ` +export const Skeleton = /*html*/ `
@@ -10,7 +10,7 @@ export const Skeleton = `
`; -export const Loading = ` +export const Loading = /*html*/ `
@@ -24,60 +24,59 @@ export const Loading = ` `; const ProductItem = ({ title, image, lprice }) => { - return ` + const contentView = /*html*/ `
- -
- ${title} -
- -
-
-

- ${title} -

-

-

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

+ data-product-id="85067212996"> + +
+ ${title} +
+ +
+
+

+ ${title} +

+

+

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

+
+ +
- -
-
- `; + `; + return contentView; }; -export const ProductList = ({ products, loading }) => { +export const ProductList = ({ products = [], loading = false }) => { + const loadingView = /*html*/ ` +
+ + ${Skeleton.repeat(4)} +
+ ${Loading} + `; + const contentView = /*html*/ ` +
+ 총 ${products.length}의 상품 +
+
+ ${products.map(ProductItem).join("")} +
+ `; return `
- ${ - loading - ? ` -
- - ${Skeleton.repeat(4)} -
- ${Loading} - ` - : ` -
- 총 ${products.length}의 상품 -
-
- ${products.map(ProductItem).join("")} -
- ` - } + ${loading ? loadingView : contentView}
diff --git a/src/components/SearchForm.js b/src/components/SearchForm.js index 6624b441..f1a7e2f8 100644 --- a/src/components/SearchForm.js +++ b/src/components/SearchForm.js @@ -1,67 +1,68 @@ export const SearchForm = () => { - return ` -
- -
-
- -
- - - + const contentView = /*html*/ ` +
+ +
+
+ +
+ + + +
-
-
- -
- -
-
- -
- -
-
카테고리 로딩 중...
+ +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+
- -
- -
- -
- - + +
+ +
+ + +
+ +
+ + +
- -
- -
-
-
-
- `; +
+ `; + return contentView; }; From b7a68e7a593aae51b5d78e2309bc94d152b492b2 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Mon, 10 Nov 2025 20:29:55 +0900 Subject: [PATCH 06/43] =?UTF-8?q?refactor:=20=EB=9D=BC=EC=9A=B0=ED=8C=85?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.js | 101 +++++++++++++----------- src/router/Router.js | 182 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 47 deletions(-) create mode 100644 src/router/Router.js diff --git a/src/main.js b/src/main.js index 32c3703a..9ee5013e 100644 --- a/src/main.js +++ b/src/main.js @@ -1,55 +1,62 @@ +import { createRouter } from "./router/Router.js"; import { Homepage } from "./pages/Homepage.js"; -import { getProduct, getProducts } from "./api/productApi.js"; import { DetailPage } from "./pages/DetailPage.js"; +import { getProducts, getProduct } from "./api/productApi.js"; const enableMocking = () => - import("./mocks/browser.js").then(({ worker }) => - worker.start({ - onUnhandledRequest: "bypass", - }), - ); - -// const push = ({ path }) => { -// history.pushState(null, null, path); -// render(); -// }; - -async function render() { - const $root = document.querySelector("#root"); - if (location.pathname === "/") { - $root.innerHTML = Homepage({ loading: true }); - - const data = await getProducts(); - $root.innerHTML = Homepage({ loading: false, ...data }); - - document.body.addEventListener("click", (e) => { - if (e.target.closest(".product-card")) { - const productId = e.target.closest(".product-card").dataset.productId; - history.pushState(null, null, `/products/${productId}`); - // router.push(`/products/${productId}`); - - render(); - } - }); - } else { - const productId = location.pathname.split("/").pop(); - const data = await getProduct(productId); - $root.innerHTML = DetailPage({ loading: false, ...data }); - } - - window.addEventListener("popstate", () => { - render(); - }); - // router.onpopstate = render -} - -function main() { - render(); -} + import("./mocks/browser.js").then(({ worker }) => worker.start({ onUnhandledRequest: "bypass" })); + +// 1) 라우트 정의 +const routes = [ + { + path: "/", + element: async () => { + const root = document.querySelector("#root"); + root.innerHTML = Homepage({ loading: true }); + + const data = await getProducts(); + return Homepage({ loading: false, ...data }); + }, + }, + { + path: "/products/:id", + element: async ({ params }) => { + const root = document.querySelector("#root"); + root.innerHTML = DetailPage({ loading: true }); + + const product = await getProduct(params.id); + return DetailPage({ loading: false, ...product }); + }, + }, +]; + +// 2) 라우터 생성 +const router = createRouter({ + routes, + rootSelector: "#root", +}); + +// 3) 카드 클릭 시 SPA 네비게이션 +const handleCardClick = (event) => { + const card = event.target.closest(".product-card"); + if (!card) return; + console.log("card", card); + + const productId = card.dataset.productId; + if (!productId) return; + + router.push(`/products/${productId}`); +}; + +document.body.addEventListener("click", handleCardClick); + +// 4) 애플리케이션 시작 +const startApp = () => { + router.start(); +}; -// 애플리케이션 시작 if (import.meta.env.MODE !== "test") { - enableMocking().then(main); + enableMocking().then(startApp); } else { - main(); + startApp(); } diff --git a/src/router/Router.js b/src/router/Router.js new file mode 100644 index 00000000..f9f97010 --- /dev/null +++ b/src/router/Router.js @@ -0,0 +1,182 @@ +const DEFAULT_NOT_FOUND = () => /*html*/ ` +
+

페이지를 찾을 수 없어요

+

잘못된 주소이거나 이동된 페이지일 수 있습니다.

+ 홈으로 돌아가기 +
+`; + +const trimSlashes = (value = "") => value.replace(/\/+$/, "") || "/"; + +const splitPath = (pattern) => trimSlashes(pattern).split("/").filter(Boolean); + +const matchPath = (pattern, pathname) => { + const patternParts = splitPath(pattern); + const pathParts = splitPath(pathname); + + if (patternParts.length !== pathParts.length) { + return null; + } + + return patternParts.reduce((params, segment, index) => { + if (!params) { + return null; + } + + const value = pathParts[index]; + + if (segment.startsWith(":")) { + const key = segment.slice(1); + return { ...params, [key]: decodeURIComponent(value) }; + } + + return segment === value ? params : null; + }, {}); +}; + +export class Router { + constructor({ routes = [], rootSelector = "#root", notFound = DEFAULT_NOT_FOUND } = {}) { + this.routes = [...routes]; + this.rootSelector = rootSelector; + this.notFound = notFound; + this.listeners = new Set(); + this.isStarted = false; + + this.handleLinkClick = this.handleLinkClick.bind(this); + this.handlePopState = this.handlePopState.bind(this); + } + + static create(options) { + return new Router(options); + } + + register(route) { + this.routes.push(route); + return this; + } + + use(routes = []) { + routes.forEach((route) => this.register(route)); + return this; + } + + match(pathname) { + for (const route of this.routes) { + const params = matchPath(route.path, pathname); + if (params) { + return { route, params }; + } + } + return null; + } + + async render(state = {}) { + const target = document.querySelector(this.rootSelector); + + if (!target) { + throw new Error(`Router: DOM element "${this.rootSelector}"를 찾을 수 없습니다.`); + } + + const pathname = trimSlashes(window.location.pathname); + const matched = this.match(pathname); + const context = { + pathname, + state, + params: matched?.params ?? {}, + query: Object.fromEntries(new URLSearchParams(window.location.search)), + }; + + const viewFactory = matched?.route?.element ?? this.notFound; + const html = await Promise.resolve(viewFactory(context)); + + target.innerHTML = html; + this.notify(context); + } + + start() { + if (this.isStarted) { + return; + } + + document.addEventListener("click", this.handleLinkClick); + window.addEventListener("popstate", this.handlePopState); + + this.isStarted = true; + this.render(); + } + + stop() { + if (!this.isStarted) { + return; + } + + document.removeEventListener("click", this.handleLinkClick); + window.removeEventListener("popstate", this.handlePopState); + + this.isStarted = false; + } + + navigate(url, { replace = false, state = {} } = {}) { + if (replace) { + window.history.replaceState(state, "", url); + } else { + window.history.pushState(state, "", url); + } + + return this.render(state); + } + + push(url, state) { + return this.navigate(url, { state }); + } + + replace(url, state) { + return this.navigate(url, { replace: true, state }); + } + + back() { + window.history.back(); + } + + subscribe(listener) { + this.listeners.add(listener); + return () => this.unsubscribe(listener); + } + + unsubscribe(listener) { + this.listeners.delete(listener); + } + + notify(context) { + this.listeners.forEach((listener) => { + try { + listener(context); + } catch (error) { + console.error("Router listener error:", error); + } + }); + } + + handlePopState(event) { + this.render(event.state); + } + + handleLinkClick(event) { + const anchor = event.target.closest("a[data-link]"); + + if (!anchor || anchor.target === "_blank" || anchor.hasAttribute("download")) { + return; + } + + const href = anchor.getAttribute("href") ?? anchor.dataset.href; + + if (!href || href.startsWith("http")) { + return; + } + + event.preventDefault(); + this.push(href); + } +} + +export const createRouter = (options) => new Router(options); From 4f7832b73d656159002a523c272644bb8ff82388 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Mon, 10 Nov 2025 20:30:35 +0900 Subject: [PATCH 07/43] =?UTF-8?q?fix:=20=EC=83=81=ED=92=88=EC=83=81?= =?UTF-8?q?=EC=84=B8=20id=20param=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductList.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ProductList.js b/src/components/ProductList.js index 89f0a7bc..b45a1b54 100644 --- a/src/components/ProductList.js +++ b/src/components/ProductList.js @@ -23,10 +23,10 @@ export const Loading = /*html*/ `
`; -const ProductItem = ({ title, image, lprice }) => { +const ProductItem = ({ productId, title, image, lprice }) => { const contentView = /*html*/ `
+ data-product-id=${productId}>
Date: Tue, 11 Nov 2025 14:13:46 +0900 Subject: [PATCH 08/43] =?UTF-8?q?feat(SearchForm):=20=ED=95=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=82=B4=20=EC=83=81=ED=92=88=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20=EC=84=A0=ED=83=9D=20=EB=B0=8F=20=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SearchForm.js | 26 +++++++++------- src/main.js | 29 ++++++++++++++---- src/router/enhancers/searchForm.js | 48 ++++++++++++++++++++++++++++++ src/utils/EventBus.js | 32 ++++++++++++++++++++ 4 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 src/router/enhancers/searchForm.js create mode 100644 src/utils/EventBus.js diff --git a/src/components/SearchForm.js b/src/components/SearchForm.js index f1a7e2f8..37ede0fa 100644 --- a/src/components/SearchForm.js +++ b/src/components/SearchForm.js @@ -1,10 +1,14 @@ -export const SearchForm = () => { +export const SearchForm = ({ filters = {} } = {}) => { + const limit = String(filters.limit ?? "20"); + const sort = String(filters.sort ?? "price_asc"); + const keyword = filters.search ?? filters.query ?? ""; + const contentView = /*html*/ ` -
+
-
@@ -35,16 +39,16 @@ export const SearchForm = () => { @@ -54,10 +58,10 @@ export const SearchForm = () => {
diff --git a/src/main.js b/src/main.js index 9ee5013e..104d1c9f 100644 --- a/src/main.js +++ b/src/main.js @@ -2,6 +2,8 @@ import { createRouter } from "./router/Router.js"; import { Homepage } from "./pages/Homepage.js"; import { DetailPage } from "./pages/DetailPage.js"; import { getProducts, getProduct } from "./api/productApi.js"; +import { eventBus } from "./utils/EventBus.js"; +import { attachSearchFormEnhancer } from "./router/enhancers/searchForm.js"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => worker.start({ onUnhandledRequest: "bypass" })); @@ -10,12 +12,22 @@ const enableMocking = () => const routes = [ { path: "/", - element: async () => { + element: async ({ query }) => { const root = document.querySelector("#root"); - root.innerHTML = Homepage({ loading: true }); + root.innerHTML = Homepage({ loading: true, filters: query }); - const data = await getProducts(); - return Homepage({ loading: false, ...data }); + const data = await getProducts(query); + const mergedFilters = { + ...query, + ...(data?.filters ?? {}), + }; + + return Homepage({ + loading: false, + filters: mergedFilters, + products: data?.products ?? [], + pagination: data?.pagination, + }); }, }, { @@ -36,11 +48,18 @@ const router = createRouter({ rootSelector: "#root", }); +eventBus.on("filters:change", (params) => { + const search = params instanceof URLSearchParams ? params.toString() : new URLSearchParams(params).toString(); + const url = search ? `/?${search}` : "/"; + router.push(url); +}); + +attachSearchFormEnhancer(router); + // 3) 카드 클릭 시 SPA 네비게이션 const handleCardClick = (event) => { const card = event.target.closest(".product-card"); if (!card) return; - console.log("card", card); const productId = card.dataset.productId; if (!productId) return; diff --git a/src/router/enhancers/searchForm.js b/src/router/enhancers/searchForm.js new file mode 100644 index 00000000..61f6da22 --- /dev/null +++ b/src/router/enhancers/searchForm.js @@ -0,0 +1,48 @@ +import { eventBus } from "../../utils/EventBus.js"; + +const handleFormChange = (event) => { + const target = event.target; + if (!target) return; + + const params = new URLSearchParams(window.location.search); + + if (target.matches("#limit-select")) { + params.set("limit", target.value); + params.delete("page"); + eventBus.emit("filters:change", params); + return; + } + + if (target.matches("#sort-select")) { + params.set("sort", target.value); + params.delete("page"); + eventBus.emit("filters:change", params); + } +}; + +const bindSearchFormListeners = () => { + const root = document.querySelector("#root"); + if (!root) { + return () => {}; + } + + const searchForm = root.querySelector("[data-search-form]"); + if (!searchForm) { + return () => {}; + } + + searchForm.addEventListener("change", handleFormChange); + + return () => { + searchForm.removeEventListener("change", handleFormChange); + }; +}; + +export const attachSearchFormEnhancer = (router) => { + let cleanup = () => {}; + + router.subscribe(() => { + cleanup(); + cleanup = bindSearchFormListeners(); + }); +}; diff --git a/src/utils/EventBus.js b/src/utils/EventBus.js new file mode 100644 index 00000000..75b71e3d --- /dev/null +++ b/src/utils/EventBus.js @@ -0,0 +1,32 @@ +// src/utils/eventBus.js +const listeners = new Map(); // eventName → Set(handler) + +export const eventBus = { + on(event, handler) { + if (!listeners.has(event)) { + listeners.set(event, new Set()); + } + listeners.get(event).add(handler); + return () => this.off(event, handler); + }, + off(event, handler) { + const handlers = listeners.get(event); + if (!handlers) return; + handlers.delete(handler); + if (handlers.size === 0) listeners.delete(event); + }, + emit(event, payload) { + const handlers = listeners.get(event); + if (!handlers) return; + handlers.forEach((handler) => { + try { + handler(payload); + } catch (err) { + console.error(`[eventBus] handler for "${event}" failed`, err); + } + }); + }, + clear() { + listeners.clear(); + }, +}; From 1e3605481e7e38dd5c8fe2cd4447b78859bf5f40 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Tue, 11 Nov 2025 15:59:10 +0900 Subject: [PATCH 09/43] =?UTF-8?q?feat(ProductList):=20=EC=9D=B8=ED=94=BC?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductList.js | 23 ++++++- src/main.js | 100 +++++++++++++++++++++++++++- src/pages/Homepage.js | 2 +- src/router/enhancers/productList.js | 69 +++++++++++++++++++ 4 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 src/router/enhancers/productList.js diff --git a/src/components/ProductList.js b/src/components/ProductList.js index b45a1b54..956f21cc 100644 --- a/src/components/ProductList.js +++ b/src/components/ProductList.js @@ -56,7 +56,13 @@ const ProductItem = ({ productId, title, image, lprice }) => { return contentView; }; -export const ProductList = ({ products = [], loading = false }) => { +export const renderProductItems = (products = []) => products.map(ProductItem).join(""); + +export const ProductList = ({ products = [], loading = false, pagination = {} } = {}) => { + const hasNext = Boolean(pagination?.hasNext); + const currentPage = Number(pagination?.page ?? 1); + const nextPage = currentPage + 1; + const loadingView = /*html*/ `
@@ -69,11 +75,22 @@ export const ProductList = ({ products = [], loading = false }) => { 총 ${products.length}의 상품
- ${products.map(ProductItem).join("")} + ${renderProductItems(products)} +
+
+ + ${hasNext ? "아래로 스크롤하면 더 많은 상품을 불러옵니다" : "모든 상품을 불러왔습니다"} +
`; return ` -
+
${loading ? loadingView : contentView} diff --git a/src/main.js b/src/main.js index 104d1c9f..96893165 100644 --- a/src/main.js +++ b/src/main.js @@ -4,6 +4,23 @@ import { DetailPage } from "./pages/DetailPage.js"; import { getProducts, getProduct } from "./api/productApi.js"; import { eventBus } from "./utils/EventBus.js"; import { attachSearchFormEnhancer } from "./router/enhancers/searchForm.js"; +import { attachProductListEnhancer } from "./router/enhancers/productList.js"; +import { renderProductItems } from "./components/ProductList.js"; + +const homepageState = { + filters: {}, + products: [], + pagination: null, +}; + +let isLoadingMore = false; + +const resetHomepageState = () => { + homepageState.filters = {}; + homepageState.products = []; + homepageState.pagination = null; + isLoadingMore = false; +}; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => worker.start({ onUnhandledRequest: "bypass" })); @@ -13,6 +30,8 @@ const routes = [ { path: "/", element: async ({ query }) => { + resetHomepageState(); + const root = document.querySelector("#root"); root.innerHTML = Homepage({ loading: true, filters: query }); @@ -21,12 +40,19 @@ const routes = [ ...query, ...(data?.filters ?? {}), }; + const products = Array.isArray(data?.products) ? data.products : []; + const pagination = data?.pagination ?? null; + + homepageState.filters = mergedFilters; + homepageState.products = products; + homepageState.pagination = pagination; + isLoadingMore = false; return Homepage({ loading: false, filters: mergedFilters, - products: data?.products ?? [], - pagination: data?.pagination, + products, + pagination, }); }, }, @@ -55,6 +81,76 @@ eventBus.on("filters:change", (params) => { }); attachSearchFormEnhancer(router); +attachProductListEnhancer(router); + +eventBus.on("products:loadMore", async () => { + if (isLoadingMore) { + return; + } + + const pagination = homepageState.pagination; + if (!pagination || !pagination.hasNext) { + const sentinel = document.querySelector("[data-infinite-trigger]"); + if (sentinel) { + sentinel.dataset.loading = "false"; + sentinel.dataset.hasNext = "false"; + sentinel.innerHTML = `모든 상품을 불러왔습니다`; + } + return; + } + + isLoadingMore = true; + + const nextPage = Number(pagination.page ?? 1) + 1; + const params = { + ...homepageState.filters, + page: nextPage, + }; + + try { + const data = await getProducts(params); + const newProducts = Array.isArray(data?.products) ? data.products : []; + const updatedPagination = data?.pagination ?? { + ...pagination, + page: nextPage, + hasNext: false, + }; + + if (newProducts.length) { + const grid = document.querySelector("#products-grid"); + if (grid) { + grid.insertAdjacentHTML("beforeend", renderProductItems(newProducts)); + } + } + + homepageState.products = homepageState.products.concat(newProducts); + homepageState.pagination = updatedPagination; + + const sentinel = document.querySelector("[data-infinite-trigger]"); + if (sentinel) { + sentinel.dataset.loading = "false"; + sentinel.dataset.hasNext = updatedPagination.hasNext ? "true" : "false"; + sentinel.dataset.nextPage = String((updatedPagination.page ?? nextPage) + 1); + const message = updatedPagination.hasNext + ? "아래로 스크롤하면 더 많은 상품을 불러옵니다" + : "모든 상품을 불러왔습니다"; + sentinel.innerHTML = `${message}`; + } + } catch (error) { + console.error("상품 추가 로딩에 실패했습니다.", error); + const sentinel = document.querySelector("[data-infinite-trigger]"); + if (sentinel) { + sentinel.dataset.loading = "false"; + sentinel.innerHTML = ` + + `; + } + } finally { + isLoadingMore = false; + } +}); // 3) 카드 클릭 시 SPA 네비게이션 const handleCardClick = (event) => { diff --git a/src/pages/Homepage.js b/src/pages/Homepage.js index 6264915a..fef0a7c2 100644 --- a/src/pages/Homepage.js +++ b/src/pages/Homepage.js @@ -4,6 +4,6 @@ import { SearchForm, ProductList } from "../components/index.js"; export const Homepage = ({ filters, products, pagination, loading = false }) => { return PageLayout(` ${SearchForm({ filters, pagination })} - ${ProductList({ products, loading })} + ${ProductList({ loading, products, pagination })} `); }; diff --git a/src/router/enhancers/productList.js b/src/router/enhancers/productList.js new file mode 100644 index 00000000..29fceec5 --- /dev/null +++ b/src/router/enhancers/productList.js @@ -0,0 +1,69 @@ +import { eventBus } from "../../utils/EventBus.js"; + +const bindProductListObserver = () => { + const root = document.querySelector("#root"); + if (!root) { + return () => {}; + } + + const sentinel = root.querySelector("[data-infinite-trigger]"); + if (!sentinel) { + return () => {}; + } + + const handleClick = (event) => { + const retryButton = event.target.closest("[data-infinite-retry]"); + if (!retryButton) { + return; + } + + sentinel.dataset.loading = "true"; + sentinel.innerHTML = `상품을 불러오는 중...`; + eventBus.emit("products:loadMore"); + }; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) { + return; + } + + const hasNext = sentinel.dataset.hasNext !== "false"; + const isLoading = sentinel.dataset.loading === "true"; + + if (!hasNext || isLoading) { + return; + } + + sentinel.dataset.loading = "true"; + sentinel.innerHTML = `상품을 불러오는 중...`; + eventBus.emit("products:loadMore"); + }); + }, + { + root: null, + threshold: 0, + rootMargin: "200px 0px", + }, + ); + + sentinel.addEventListener("click", handleClick); + observer.observe(sentinel); + + return () => { + observer.disconnect(); + sentinel.removeEventListener("click", handleClick); + }; +}; + +export const attachProductListEnhancer = (router) => { + let cleanup = () => {}; + + const rebind = () => { + cleanup(); + cleanup = bindProductListObserver(); + }; + + router.subscribe(rebind); +}; From 5ecb49dfdbaa4c3ff08cee2eb5cf2b2cf2d92fb0 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Tue, 11 Nov 2025 17:23:29 +0900 Subject: [PATCH 10/43] =?UTF-8?q?feat(CartModal):=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C?= =?UTF-8?q?,=20=EC=9E=A5=EB=B0=94=EA=B5=AC=EB=8B=88=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20=ED=91=9C=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CartModal.js | 146 ++++++++++++++++++++++++++++++++++ src/components/ProductList.js | 7 +- src/components/index.js | 1 + src/main.js | 58 ++++++++++++-- src/pages/Homepage.js | 3 +- 5 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 src/components/CartModal.js diff --git a/src/components/CartModal.js b/src/components/CartModal.js new file mode 100644 index 00000000..36c40264 --- /dev/null +++ b/src/components/CartModal.js @@ -0,0 +1,146 @@ +export const CartModal = () => { + const emptyView = /*html*/ ` +
+
+
+ + + +
+

장바구니가 비어있습니다

+

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

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

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

+ //

+ // 220원 + //

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

+ // 440원 + //

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

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

+ //

+ // 230원 + //

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

+ // 230원 + //

+ // + //
+ //
+ //
+ //
+ // `; + return /*html*/ ` + + `; +}; diff --git a/src/components/ProductList.js b/src/components/ProductList.js index 956f21cc..36bb2ea0 100644 --- a/src/components/ProductList.js +++ b/src/components/ProductList.js @@ -26,7 +26,7 @@ export const Loading = /*html*/ ` const ProductItem = ({ productId, title, image, lprice }) => { const contentView = /*html*/ `
+ data-product-id="${productId}">
{
@@ -89,7 +90,7 @@ export const ProductList = ({ products = [], loading = false, pagination = {} }
`; - return ` + return /*html*/ `
diff --git a/src/components/index.js b/src/components/index.js index 943d8ebb..d72d9f6b 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -3,3 +3,4 @@ export * from "./Footer.js"; export * from "./SearchForm.js"; export * from "./ProductList.js"; export * from "./ProductDetali.js"; +export * from "./CartModal.js"; diff --git a/src/main.js b/src/main.js index 96893165..dc424cd1 100644 --- a/src/main.js +++ b/src/main.js @@ -152,18 +152,60 @@ eventBus.on("products:loadMore", async () => { } }); -// 3) 카드 클릭 시 SPA 네비게이션 -const handleCardClick = (event) => { - const card = event.target.closest(".product-card"); - if (!card) return; +// 3) 카드/헤더 인터랙션 핸들러 +const handleClick = (event) => { + // Header + const cartIconButton = event.target.closest("#cart-icon-btn"); + if (cartIconButton) { + const cartModal = document.querySelector("#cart-modal"); + if (!cartModal) { + return; + } + + const isHidden = cartModal.classList.toggle("hidden"); + cartModal.setAttribute("aria-hidden", isHidden ? "true" : "false"); + + return; + } - const productId = card.dataset.productId; - if (!productId) return; + // Cart Modal + const cartModalCloseButton = event.target.closest("#cart-modal-close-btn"); + if (cartModalCloseButton) { + const cartModal = document.querySelector("#cart-modal"); + if (!cartModal) { + return; + } + + const isHidden = cartModal.classList.toggle("hidden"); + cartModal.setAttribute("aria-hidden", isHidden ? "true" : "false"); + + return; + } - router.push(`/products/${productId}`); + // Product Card + const addToCartButton = event.target.closest(".add-to-cart-btn"); + if (addToCartButton) { + const productId = addToCartButton.dataset.productId; + if (!productId) { + return; + } + + console.log(productId); + + return; + } + + const card = event.target.closest(".product-card"); + if (card) { + const productId = card.dataset.productId; + if (!productId) return; + + router.push(`/products/${productId}`); + return; + } }; -document.body.addEventListener("click", handleCardClick); +document.body.addEventListener("click", handleClick); // 4) 애플리케이션 시작 const startApp = () => { diff --git a/src/pages/Homepage.js b/src/pages/Homepage.js index fef0a7c2..8125d4db 100644 --- a/src/pages/Homepage.js +++ b/src/pages/Homepage.js @@ -1,9 +1,10 @@ import { PageLayout } from "./PageLayout.js"; -import { SearchForm, ProductList } from "../components/index.js"; +import { SearchForm, ProductList, CartModal } from "../components/index.js"; export const Homepage = ({ filters, products, pagination, loading = false }) => { return PageLayout(` ${SearchForm({ filters, pagination })} ${ProductList({ loading, products, pagination })} + ${CartModal()} `); }; From 16706c74682289d3c7dee5b273b6715a36cbcac9 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Wed, 12 Nov 2025 21:36:58 +0900 Subject: [PATCH 11/43] =?UTF-8?q?refactor(main):=20=EC=A0=84=EC=97=AD?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B0=8F=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/events/homepageEvents.js | 163 +++++++++++++++++++++++++++++++++++ src/main.js | 160 ++-------------------------------- src/store/appStore.js | 88 +++++++++++++++++++ 3 files changed, 260 insertions(+), 151 deletions(-) create mode 100644 src/events/homepageEvents.js create mode 100644 src/store/appStore.js diff --git a/src/events/homepageEvents.js b/src/events/homepageEvents.js new file mode 100644 index 00000000..2fec31da --- /dev/null +++ b/src/events/homepageEvents.js @@ -0,0 +1,163 @@ +import { getProducts } from "../api/productApi.js"; +import { renderProductItems } from "../components/ProductList.js"; +import { appendHomepageProducts, getHomepageState, setHomepageLoadingMore } from "../store/appStore.js"; +import { eventBus } from "../utils/EventBus.js"; + +const updateSentinel = ({ loading, hasNext, nextPage, message, error }) => { + const sentinel = document.querySelector("[data-infinite-trigger]"); + if (!sentinel) { + return; + } + + if (loading !== undefined) { + sentinel.dataset.loading = loading ? "true" : "false"; + } + + if (hasNext !== undefined) { + sentinel.dataset.hasNext = hasNext ? "true" : "false"; + } + + if (nextPage !== undefined) { + sentinel.dataset.nextPage = String(nextPage); + } + + if (error) { + sentinel.innerHTML = ` + + `; + return; + } + + if (message) { + sentinel.innerHTML = `${message}`; + } +}; + +const handleFiltersChange = (router) => (params) => { + const searchParams = params instanceof URLSearchParams ? params : new URLSearchParams(params); + const search = searchParams.toString(); + const url = search ? `/?${search}` : "/"; + router.push(url); +}; + +const handleProductsLoadMore = async () => { + const homepageState = getHomepageState(); + + if (homepageState.isLoadingMore) { + return; + } + + const pagination = homepageState.pagination; + if (!pagination || !pagination.hasNext) { + updateSentinel({ + loading: false, + hasNext: false, + message: "모든 상품을 불러왔습니다", + }); + return; + } + + setHomepageLoadingMore(true); + + const nextPage = Number(pagination.page ?? 1) + 1; + const params = { + ...homepageState.filters, + page: nextPage, + }; + + updateSentinel({ + loading: true, + nextPage, + }); + + try { + const data = await getProducts(params); + const newProducts = Array.isArray(data?.products) ? data.products : []; + const updatedPagination = data?.pagination ?? { + ...pagination, + page: nextPage, + hasNext: false, + }; + + if (newProducts.length) { + const grid = document.querySelector("#products-grid"); + if (grid) { + grid.insertAdjacentHTML("beforeend", renderProductItems(newProducts)); + } + } + + appendHomepageProducts(newProducts, updatedPagination); + + updateSentinel({ + loading: false, + hasNext: updatedPagination.hasNext, + nextPage: (updatedPagination.page ?? nextPage) + 1, + message: updatedPagination.hasNext ? "아래로 스크롤하면 더 많은 상품을 불러옵니다" : "모든 상품을 불러왔습니다", + }); + } catch (error) { + console.error("상품 추가 로딩에 실패했습니다.", error); + updateSentinel({ + loading: false, + error: true, + }); + } finally { + setHomepageLoadingMore(false); + } +}; + +const handleDocumentClick = (router) => (event) => { + const cartIconButton = event.target.closest("#cart-icon-btn"); + if (cartIconButton) { + const cartModal = document.querySelector("#cart-modal"); + if (!cartModal) { + return; + } + + const isHidden = cartModal.classList.toggle("hidden"); + cartModal.setAttribute("aria-hidden", isHidden ? "true" : "false"); + + return; + } + + const cartModalCloseButton = event.target.closest("#cart-modal-close-btn"); + if (cartModalCloseButton) { + const cartModal = document.querySelector("#cart-modal"); + if (!cartModal) { + return; + } + + const isHidden = cartModal.classList.toggle("hidden"); + cartModal.setAttribute("aria-hidden", isHidden ? "true" : "false"); + + return; + } + + const addToCartButton = event.target.closest(".add-to-cart-btn"); + if (addToCartButton) { + const productId = addToCartButton.dataset.productId; + if (!productId) { + return; + } + + console.log(productId); + return; + } + + const card = event.target.closest(".product-card"); + if (card) { + const productId = card.dataset.productId; + if (!productId) { + return; + } + + router.push(`/products/${productId}`); + } +}; + +export const registerHomepageEvents = (router) => { + eventBus.on("filters:change", handleFiltersChange(router)); + eventBus.on("products:loadMore", handleProductsLoadMore); + document.body.addEventListener("click", handleDocumentClick(router)); +}; diff --git a/src/main.js b/src/main.js index dc424cd1..fe050a32 100644 --- a/src/main.js +++ b/src/main.js @@ -2,25 +2,10 @@ import { createRouter } from "./router/Router.js"; import { Homepage } from "./pages/Homepage.js"; import { DetailPage } from "./pages/DetailPage.js"; import { getProducts, getProduct } from "./api/productApi.js"; -import { eventBus } from "./utils/EventBus.js"; import { attachSearchFormEnhancer } from "./router/enhancers/searchForm.js"; import { attachProductListEnhancer } from "./router/enhancers/productList.js"; -import { renderProductItems } from "./components/ProductList.js"; - -const homepageState = { - filters: {}, - products: [], - pagination: null, -}; - -let isLoadingMore = false; - -const resetHomepageState = () => { - homepageState.filters = {}; - homepageState.products = []; - homepageState.pagination = null; - isLoadingMore = false; -}; +import { registerHomepageEvents } from "./events/homepageEvents.js"; +import { resetHomepageState, setHomepageState } from "./store/appStore.js"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => worker.start({ onUnhandledRequest: "bypass" })); @@ -43,10 +28,12 @@ const routes = [ const products = Array.isArray(data?.products) ? data.products : []; const pagination = data?.pagination ?? null; - homepageState.filters = mergedFilters; - homepageState.products = products; - homepageState.pagination = pagination; - isLoadingMore = false; + setHomepageState({ + filters: mergedFilters, + products, + pagination, + isLoadingMore: false, + }); return Homepage({ loading: false, @@ -74,138 +61,9 @@ const router = createRouter({ rootSelector: "#root", }); -eventBus.on("filters:change", (params) => { - const search = params instanceof URLSearchParams ? params.toString() : new URLSearchParams(params).toString(); - const url = search ? `/?${search}` : "/"; - router.push(url); -}); - attachSearchFormEnhancer(router); attachProductListEnhancer(router); - -eventBus.on("products:loadMore", async () => { - if (isLoadingMore) { - return; - } - - const pagination = homepageState.pagination; - if (!pagination || !pagination.hasNext) { - const sentinel = document.querySelector("[data-infinite-trigger]"); - if (sentinel) { - sentinel.dataset.loading = "false"; - sentinel.dataset.hasNext = "false"; - sentinel.innerHTML = `모든 상품을 불러왔습니다`; - } - return; - } - - isLoadingMore = true; - - const nextPage = Number(pagination.page ?? 1) + 1; - const params = { - ...homepageState.filters, - page: nextPage, - }; - - try { - const data = await getProducts(params); - const newProducts = Array.isArray(data?.products) ? data.products : []; - const updatedPagination = data?.pagination ?? { - ...pagination, - page: nextPage, - hasNext: false, - }; - - if (newProducts.length) { - const grid = document.querySelector("#products-grid"); - if (grid) { - grid.insertAdjacentHTML("beforeend", renderProductItems(newProducts)); - } - } - - homepageState.products = homepageState.products.concat(newProducts); - homepageState.pagination = updatedPagination; - - const sentinel = document.querySelector("[data-infinite-trigger]"); - if (sentinel) { - sentinel.dataset.loading = "false"; - sentinel.dataset.hasNext = updatedPagination.hasNext ? "true" : "false"; - sentinel.dataset.nextPage = String((updatedPagination.page ?? nextPage) + 1); - const message = updatedPagination.hasNext - ? "아래로 스크롤하면 더 많은 상품을 불러옵니다" - : "모든 상품을 불러왔습니다"; - sentinel.innerHTML = `${message}`; - } - } catch (error) { - console.error("상품 추가 로딩에 실패했습니다.", error); - const sentinel = document.querySelector("[data-infinite-trigger]"); - if (sentinel) { - sentinel.dataset.loading = "false"; - sentinel.innerHTML = ` - - `; - } - } finally { - isLoadingMore = false; - } -}); - -// 3) 카드/헤더 인터랙션 핸들러 -const handleClick = (event) => { - // Header - const cartIconButton = event.target.closest("#cart-icon-btn"); - if (cartIconButton) { - const cartModal = document.querySelector("#cart-modal"); - if (!cartModal) { - return; - } - - const isHidden = cartModal.classList.toggle("hidden"); - cartModal.setAttribute("aria-hidden", isHidden ? "true" : "false"); - - return; - } - - // Cart Modal - const cartModalCloseButton = event.target.closest("#cart-modal-close-btn"); - if (cartModalCloseButton) { - const cartModal = document.querySelector("#cart-modal"); - if (!cartModal) { - return; - } - - const isHidden = cartModal.classList.toggle("hidden"); - cartModal.setAttribute("aria-hidden", isHidden ? "true" : "false"); - - return; - } - - // Product Card - const addToCartButton = event.target.closest(".add-to-cart-btn"); - if (addToCartButton) { - const productId = addToCartButton.dataset.productId; - if (!productId) { - return; - } - - console.log(productId); - - return; - } - - const card = event.target.closest(".product-card"); - if (card) { - const productId = card.dataset.productId; - if (!productId) return; - - router.push(`/products/${productId}`); - return; - } -}; - -document.body.addEventListener("click", handleClick); +registerHomepageEvents(router); // 4) 애플리케이션 시작 const startApp = () => { diff --git a/src/store/appStore.js b/src/store/appStore.js new file mode 100644 index 00000000..aa268282 --- /dev/null +++ b/src/store/appStore.js @@ -0,0 +1,88 @@ +const createHomepageState = () => ({ + filters: {}, + products: [], + pagination: null, + isLoadingMore: false, +}); + +let state = { + homepage: createHomepageState(), +}; + +const listeners = new Set(); + +const notify = () => { + listeners.forEach((listener) => { + try { + listener(state); + } catch (error) { + console.error("appStore listener execution failed.", error); + } + }); +}; + +const updateHomepage = (updater) => { + const currentHomepage = state.homepage; + const nextHomepage = typeof updater === "function" ? updater(currentHomepage) : updater; + + if (!nextHomepage || nextHomepage === currentHomepage) { + return; + } + + state = { + ...state, + homepage: nextHomepage, + }; + + notify(); +}; + +export const getState = () => state; + +export const getHomepageState = () => state.homepage; + +export const subscribe = (listener) => { + if (typeof listener !== "function") { + return () => {}; + } + + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const resetHomepageState = () => { + updateHomepage(createHomepageState()); +}; + +export const setHomepageState = ({ filters, products, pagination, isLoadingMore }) => { + updateHomepage((prev) => ({ + ...prev, + ...(filters !== undefined ? { filters } : {}), + ...(products !== undefined ? { products } : {}), + ...(pagination !== undefined ? { pagination } : {}), + ...(isLoadingMore !== undefined ? { isLoadingMore } : {}), + })); +}; + +export const appendHomepageProducts = (newProducts, pagination) => { + updateHomepage((prev) => ({ + ...prev, + products: prev.products.concat(newProducts ?? []), + pagination: pagination ?? prev.pagination, + })); +}; + +export const setHomepageLoadingMore = (value) => { + updateHomepage((prev) => { + if (prev.isLoadingMore === value) { + return prev; + } + + return { + ...prev, + isLoadingMore: value, + }; + }); +}; From dd675d4a4fd6e549f57353972396242f327b520e Mon Sep 17 00:00:00 2001 From: 1lmean Date: Wed, 12 Nov 2025 21:45:33 +0900 Subject: [PATCH 12/43] =?UTF-8?q?refactor(Event):=20homePageEvent=EC=97=90?= =?UTF-8?q?=EC=84=9C=20uiEvent=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/events/homepageEvents.js | 30 ++---------------------------- src/events/uiEvents.js | 26 ++++++++++++++++++++++++++ src/main.js | 2 ++ src/pages/PageLayout.js | 2 +- 4 files changed, 31 insertions(+), 29 deletions(-) create mode 100644 src/events/uiEvents.js diff --git a/src/events/homepageEvents.js b/src/events/homepageEvents.js index 2fec31da..7054d6cc 100644 --- a/src/events/homepageEvents.js +++ b/src/events/homepageEvents.js @@ -107,33 +107,7 @@ const handleProductsLoadMore = async () => { } }; -const handleDocumentClick = (router) => (event) => { - const cartIconButton = event.target.closest("#cart-icon-btn"); - if (cartIconButton) { - const cartModal = document.querySelector("#cart-modal"); - if (!cartModal) { - return; - } - - const isHidden = cartModal.classList.toggle("hidden"); - cartModal.setAttribute("aria-hidden", isHidden ? "true" : "false"); - - return; - } - - const cartModalCloseButton = event.target.closest("#cart-modal-close-btn"); - if (cartModalCloseButton) { - const cartModal = document.querySelector("#cart-modal"); - if (!cartModal) { - return; - } - - const isHidden = cartModal.classList.toggle("hidden"); - cartModal.setAttribute("aria-hidden", isHidden ? "true" : "false"); - - return; - } - +const handleHomepageClick = (router) => (event) => { const addToCartButton = event.target.closest(".add-to-cart-btn"); if (addToCartButton) { const productId = addToCartButton.dataset.productId; @@ -159,5 +133,5 @@ const handleDocumentClick = (router) => (event) => { export const registerHomepageEvents = (router) => { eventBus.on("filters:change", handleFiltersChange(router)); eventBus.on("products:loadMore", handleProductsLoadMore); - document.body.addEventListener("click", handleDocumentClick(router)); + document.body.addEventListener("click", handleHomepageClick(router)); }; diff --git a/src/events/uiEvents.js b/src/events/uiEvents.js new file mode 100644 index 00000000..8d2c89ab --- /dev/null +++ b/src/events/uiEvents.js @@ -0,0 +1,26 @@ +const toggleCartModalVisibility = () => { + const cartModal = document.querySelector("#cart-modal"); + if (!cartModal) { + return; + } + + const isHidden = cartModal.classList.toggle("hidden"); + cartModal.setAttribute("aria-hidden", isHidden ? "true" : "false"); +}; + +const handleUIClick = (event) => { + const cartIconButton = event.target.closest("#cart-icon-btn"); + if (cartIconButton) { + toggleCartModalVisibility(); + return; + } + + const cartModalCloseButton = event.target.closest("#cart-modal-close-btn"); + if (cartModalCloseButton) { + toggleCartModalVisibility(); + } +}; + +export const registerUIEvents = () => { + document.body.addEventListener("click", handleUIClick); +}; diff --git a/src/main.js b/src/main.js index fe050a32..b8e11b36 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,7 @@ import { getProducts, getProduct } from "./api/productApi.js"; import { attachSearchFormEnhancer } from "./router/enhancers/searchForm.js"; import { attachProductListEnhancer } from "./router/enhancers/productList.js"; import { registerHomepageEvents } from "./events/homepageEvents.js"; +import { registerUIEvents } from "./events/uiEvents.js"; import { resetHomepageState, setHomepageState } from "./store/appStore.js"; const enableMocking = () => @@ -64,6 +65,7 @@ const router = createRouter({ attachSearchFormEnhancer(router); attachProductListEnhancer(router); registerHomepageEvents(router); +registerUIEvents(); // 4) 애플리케이션 시작 const startApp = () => { diff --git a/src/pages/PageLayout.js b/src/pages/PageLayout.js index 49a4c378..61268f65 100644 --- a/src/pages/PageLayout.js +++ b/src/pages/PageLayout.js @@ -1,6 +1,6 @@ import { Header, Footer } from "../components/index.js"; export const PageLayout = (children) => { - return ` + return /*html*/ `
${Header()}
From 5dc9a127dc038e260a01e68fd1a50890babd77de Mon Sep 17 00:00:00 2001 From: 1lmean Date: Thu, 13 Nov 2025 00:14:56 +0900 Subject: [PATCH 13/43] =?UTF-8?q?feat(DetailPage):=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C?= =?UTF-8?q?,=20=EC=9E=A5=EB=B0=94=EA=B5=AC=EB=8B=88=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20=ED=91=9C=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/DetailPage.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pages/DetailPage.js b/src/pages/DetailPage.js index 0a9be0cd..211c3efa 100644 --- a/src/pages/DetailPage.js +++ b/src/pages/DetailPage.js @@ -1,6 +1,9 @@ -import { PageLayout } from "./PageLayout"; -import { ProductDetail } from "../components/index.js"; +import { PageLayout } from "./PageLayout.js"; +import { CartModal, ProductDetail } from "../components/index.js"; export const DetailPage = ({ loading, ...product }) => { - return PageLayout(ProductDetail({ loading, ...product })); + return PageLayout(` + ${ProductDetail({ loading, ...product })} + ${CartModal()} + `); }; From 4fb927f1ffa0603cc524b305e0fbc93ee35435fe Mon Sep 17 00:00:00 2001 From: 1lmean Date: Thu, 13 Nov 2025 13:18:59 +0900 Subject: [PATCH 14/43] =?UTF-8?q?feat(Cart):=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EC=B6=94=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CartModal.js | 230 +++++++++++++++++------------------ src/events/homepageEvents.js | 31 ++++- src/events/uiEvents.js | 36 +++++- src/main.js | 2 + src/store/appStore.js | 66 ++++++++++ 5 files changed, 238 insertions(+), 127 deletions(-) diff --git a/src/components/CartModal.js b/src/components/CartModal.js index 36c40264..72ad988e 100644 --- a/src/components/CartModal.js +++ b/src/components/CartModal.js @@ -1,123 +1,111 @@ -export const CartModal = () => { - const emptyView = /*html*/ ` -
-
-
- - - -
-

장바구니가 비어있습니다

-

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

+import { getCartState, subscribe } from "../store/appStore.js"; + +const EMPTY_VIEW = /*html*/ ` +
+
+
+ + + +
+

장바구니가 비어있습니다

+

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

+
+
+`; + +const renderCartItem = ({ id, title, price, image, quantity }) => { + const totalPrice = Number(price) * Number(quantity ?? 1); + return /*html*/ ` +
+
+ ${title} +
+
+

+ ${title} +

+

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

+
+ 수량: ${quantity}
-
`; - - // const contentView = /*html*/ ` - // - //
- // - //
- // - //
- //
- //
- // - // - // - //
- // PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 - //
- // - //
- //

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

- //

- // 220원 - //

- // - //
- // - // - // - //
- //
- // - //
- //

- // 440원 - //

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

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

- //

- // 230원 - //

- // - //
- // - // - // - //
- //
- // - //
- //

- // 230원 - //

- // - //
- //
- //
- //
- // `; +
+
+

${totalPrice.toLocaleString()}원

+
+
+ `; +}; + +const renderCartItems = (items = []) => { + if (!Array.isArray(items) || !items.length) { + return EMPTY_VIEW; + } + + return /*html*/ ` +
+
+ ${items.map(renderCartItem).join("")} +
+
+
+ +
+ `; +}; + +const getInitialItems = (cartProducts = []) => { + if (Array.isArray(cartProducts) && cartProducts.length) { + return cartProducts; + } + + const cartState = getCartState(); + return Array.isArray(cartState?.items) ? cartState.items : []; +}; + +const updateCartModalContent = () => { + const cartModal = document.querySelector("#cart-modal"); + if (!cartModal) { + return; + } + + const contentContainer = cartModal.querySelector("[data-cart-modal-content]"); + if (!contentContainer) { + return; + } + + const cartState = getCartState(); + contentContainer.innerHTML = renderCartItems(cartState.items); +}; + +let isSetup = false; + +export const setupCartModal = (router) => { + if (isSetup) { + return; + } + + isSetup = true; + + const ensureRendered = () => { + requestAnimationFrame(updateCartModalContent); + }; + + subscribe(ensureRendered); + router?.subscribe?.(ensureRendered); + + ensureRendered(); +}; + +export const CartModal = ({ cartProducts = [] } = {}) => { + const initialItems = getInitialItems(cartProducts); + return /*html*/ ` diff --git a/src/events/homepageEvents.js b/src/events/homepageEvents.js index 7054d6cc..24d8f6ba 100644 --- a/src/events/homepageEvents.js +++ b/src/events/homepageEvents.js @@ -1,7 +1,13 @@ import { getProducts } from "../api/productApi.js"; import { renderProductItems } from "../components/ProductList.js"; -import { appendHomepageProducts, getHomepageState, setHomepageLoadingMore } from "../store/appStore.js"; +import { + appendCartProduct, + appendHomepageProducts, + getHomepageState, + setHomepageLoadingMore, +} from "../store/appStore.js"; import { eventBus } from "../utils/EventBus.js"; +import { showCartModal } from "./uiEvents.js"; const updateSentinel = ({ loading, hasNext, nextPage, message, error }) => { const sentinel = document.querySelector("[data-infinite-trigger]"); @@ -115,7 +121,28 @@ const handleHomepageClick = (router) => (event) => { return; } - console.log(productId); + const card = addToCartButton.closest(".product-card"); + if (!card) { + return; + } + + const titleElement = card.querySelector("h3"); + const priceElement = card.querySelector(".text-lg.font-bold"); + const imageElement = card.querySelector("img"); + + const title = titleElement?.textContent?.trim() ?? ""; + const priceText = priceElement?.textContent ?? ""; + const price = Number(priceText.replace(/[^\d]/g, "")) || 0; + const image = imageElement?.getAttribute("src") ?? ""; + + appendCartProduct({ + id: productId, + title, + price, + image, + }); + + showCartModal(); return; } diff --git a/src/events/uiEvents.js b/src/events/uiEvents.js index 8d2c89ab..5b33d4f8 100644 --- a/src/events/uiEvents.js +++ b/src/events/uiEvents.js @@ -1,5 +1,31 @@ -const toggleCartModalVisibility = () => { - const cartModal = document.querySelector("#cart-modal"); +const getCartModal = () => document.querySelector("#cart-modal"); + +const showCartModal = () => { + const cartModal = getCartModal(); + if (!cartModal) { + return; + } + + if (cartModal.classList.contains("hidden")) { + cartModal.classList.remove("hidden"); + cartModal.setAttribute("aria-hidden", "false"); + } +}; + +const hideCartModal = () => { + const cartModal = getCartModal(); + if (!cartModal) { + return; + } + + if (!cartModal.classList.contains("hidden")) { + cartModal.classList.add("hidden"); + cartModal.setAttribute("aria-hidden", "true"); + } +}; + +const toggleCartModal = () => { + const cartModal = getCartModal(); if (!cartModal) { return; } @@ -11,16 +37,18 @@ const toggleCartModalVisibility = () => { const handleUIClick = (event) => { const cartIconButton = event.target.closest("#cart-icon-btn"); if (cartIconButton) { - toggleCartModalVisibility(); + toggleCartModal(); return; } const cartModalCloseButton = event.target.closest("#cart-modal-close-btn"); if (cartModalCloseButton) { - toggleCartModalVisibility(); + toggleCartModal(); } }; export const registerUIEvents = () => { document.body.addEventListener("click", handleUIClick); }; + +export { showCartModal, hideCartModal }; diff --git a/src/main.js b/src/main.js index b8e11b36..9377468f 100644 --- a/src/main.js +++ b/src/main.js @@ -6,6 +6,7 @@ import { attachSearchFormEnhancer } from "./router/enhancers/searchForm.js"; import { attachProductListEnhancer } from "./router/enhancers/productList.js"; import { registerHomepageEvents } from "./events/homepageEvents.js"; import { registerUIEvents } from "./events/uiEvents.js"; +import { setupCartModal } from "./components/CartModal.js"; import { resetHomepageState, setHomepageState } from "./store/appStore.js"; const enableMocking = () => @@ -66,6 +67,7 @@ attachSearchFormEnhancer(router); attachProductListEnhancer(router); registerHomepageEvents(router); registerUIEvents(); +setupCartModal(router); // 4) 애플리케이션 시작 const startApp = () => { diff --git a/src/store/appStore.js b/src/store/appStore.js index aa268282..c39f7a11 100644 --- a/src/store/appStore.js +++ b/src/store/appStore.js @@ -5,8 +5,13 @@ const createHomepageState = () => ({ isLoadingMore: false, }); +const createCartState = () => ({ + items: [], +}); + let state = { homepage: createHomepageState(), + cart: createCartState(), }; const listeners = new Set(); @@ -37,10 +42,28 @@ const updateHomepage = (updater) => { notify(); }; +const updateCart = (updater) => { + const currentCart = state.cart; + const nextCart = typeof updater === "function" ? updater(currentCart) : updater; + + if (!nextCart || nextCart === currentCart) { + return; + } + + state = { + ...state, + cart: nextCart, + }; + + notify(); +}; + export const getState = () => state; export const getHomepageState = () => state.homepage; +export const getCartState = () => state.cart; + export const subscribe = (listener) => { if (typeof listener !== "function") { return () => {}; @@ -86,3 +109,46 @@ export const setHomepageLoadingMore = (value) => { }; }); }; + +export const resetCartState = () => { + updateCart(createCartState()); +}; + +export const appendCartProduct = ({ id, title, price, image }) => { + if (!id) { + return; + } + + updateCart((prev) => { + const existingIndex = prev.items.findIndex((item) => item.id === id); + + if (existingIndex >= 0) { + const items = prev.items.map((item, index) => + index === existingIndex + ? { + ...item, + quantity: item.quantity + 1, + } + : item, + ); + + return { + ...prev, + items, + }; + } + + const nextItem = { + id, + title, + price, + image, + quantity: 1, + }; + + return { + ...prev, + items: prev.items.concat(nextItem), + }; + }); +}; From a7f0a384127782dea62cf9a65c2b1752b759b62a Mon Sep 17 00:00:00 2001 From: 1lmean Date: Thu, 13 Nov 2025 15:52:09 +0900 Subject: [PATCH 15/43] =?UTF-8?q?feat(Cart):=20=EC=83=81=EC=84=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EC=B6=94=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Header.js | 45 ++++++++++++++++++++++++++++++++++ src/events/detailPageEvents.js | 41 +++++++++++++++++++++++++++++++ src/main.js | 4 +++ 3 files changed, 90 insertions(+) create mode 100644 src/events/detailPageEvents.js diff --git a/src/components/Header.js b/src/components/Header.js index 077f33ac..d19764cd 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -1,3 +1,48 @@ +import { getCartState, subscribe } from "../store/appStore.js"; + +const updateCartBadge = () => { + const cartIconButton = document.querySelector("#cart-icon-btn"); + if (!cartIconButton) { + return; + } + + let badge = cartIconButton.querySelector("[data-cart-badge]"); + const cartState = getCartState(); + const itemCount = cartState.items.reduce((sum, item) => sum + (item.quantity ?? 1), 0); + + if (itemCount > 0) { + if (!badge) { + badge = document.createElement("span"); + badge.setAttribute("data-cart-badge", ""); + badge.className = + "absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"; + cartIconButton.appendChild(badge); + } + badge.textContent = itemCount > 99 ? "99+" : String(itemCount); + } else if (badge) { + badge.remove(); + } +}; + +let isSetup = false; + +export const setupHeader = (router) => { + if (isSetup) { + return; + } + + isSetup = true; + + const ensureUpdated = () => { + requestAnimationFrame(updateCartBadge); + }; + + subscribe(ensureUpdated); + router?.subscribe?.(ensureUpdated); + + ensureUpdated(); +}; + export const Header = () => { const contentView = /*html*/ `
diff --git a/src/events/detailPageEvents.js b/src/events/detailPageEvents.js new file mode 100644 index 00000000..5da40778 --- /dev/null +++ b/src/events/detailPageEvents.js @@ -0,0 +1,41 @@ +import { appendCartProduct } from "../store/appStore.js"; + +const handleDetailPageClick = (event) => { + // DetailPage 장바구니 담기 버튼 + const addToCartButton = event.target.closest("#add-to-cart-btn"); + if (addToCartButton) { + const productId = addToCartButton.dataset.productId; + if (!productId) { + return; + } + + // 상품 상세 정보에서 데이터 추출 + const productImage = document.querySelector(".product-detail-image"); + const productTitle = document.querySelector("h1"); + const productPrice = document.querySelector(".text-2xl.font-bold.text-blue-600"); + const quantityInput = document.querySelector("#quantity-input"); + + const title = productTitle?.textContent?.trim() ?? ""; + const priceText = productPrice?.textContent ?? ""; + const price = Number(priceText.replace(/[^\d]/g, "")) || 0; + const image = productImage?.getAttribute("src") ?? ""; + const quantity = Number(quantityInput?.value ?? 1); + + // 수량만큼 장바구니에 추가 + for (let i = 0; i < quantity; i++) { + appendCartProduct({ + id: productId, + title, + price, + image, + }); + } + + // DetailPage에서는 모달을 띄우지 않음 + return; + } +}; + +export const registerDetailPageEvents = () => { + document.body.addEventListener("click", handleDetailPageClick); +}; diff --git a/src/main.js b/src/main.js index 9377468f..778f1d7d 100644 --- a/src/main.js +++ b/src/main.js @@ -6,7 +6,9 @@ import { attachSearchFormEnhancer } from "./router/enhancers/searchForm.js"; import { attachProductListEnhancer } from "./router/enhancers/productList.js"; import { registerHomepageEvents } from "./events/homepageEvents.js"; import { registerUIEvents } from "./events/uiEvents.js"; +import { registerDetailPageEvents } from "./events/detailPageEvents.js"; import { setupCartModal } from "./components/CartModal.js"; +import { setupHeader } from "./components/Header.js"; import { resetHomepageState, setHomepageState } from "./store/appStore.js"; const enableMocking = () => @@ -67,7 +69,9 @@ attachSearchFormEnhancer(router); attachProductListEnhancer(router); registerHomepageEvents(router); registerUIEvents(); +registerDetailPageEvents(); setupCartModal(router); +setupHeader(router); // 4) 애플리케이션 시작 const startApp = () => { From ab762d7136892ba142fc0fb44b9523982dd03292 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Thu, 13 Nov 2025 16:44:42 +0900 Subject: [PATCH 16/43] =?UTF-8?q?feat(Home):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=B4=9D=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=20=ED=91=9C=EC=B6=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductList.js | 5 +++-- src/pages/Homepage.js | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/ProductList.js b/src/components/ProductList.js index 36bb2ea0..490d3090 100644 --- a/src/components/ProductList.js +++ b/src/components/ProductList.js @@ -59,10 +59,11 @@ const ProductItem = ({ productId, title, image, lprice }) => { export const renderProductItems = (products = []) => products.map(ProductItem).join(""); -export const ProductList = ({ products = [], loading = false, pagination = {} } = {}) => { +export const ProductList = ({ products = [], loading = false, pagination = {}, totalCount = 0 } = {}) => { const hasNext = Boolean(pagination?.hasNext); const currentPage = Number(pagination?.page ?? 1); const nextPage = currentPage + 1; + const displayTotal = totalCount > 0 ? totalCount : products.length; const loadingView = /*html*/ `
@@ -73,7 +74,7 @@ export const ProductList = ({ products = [], loading = false, pagination = {} } `; const contentView = /*html*/ `
- 총 ${products.length}의 상품 + 총 ${displayTotal}개의 상품
${renderProductItems(products)} diff --git a/src/pages/Homepage.js b/src/pages/Homepage.js index 8125d4db..dea1bcfd 100644 --- a/src/pages/Homepage.js +++ b/src/pages/Homepage.js @@ -2,9 +2,10 @@ import { PageLayout } from "./PageLayout.js"; import { SearchForm, ProductList, CartModal } from "../components/index.js"; export const Homepage = ({ filters, products, pagination, loading = false }) => { + const totalCount = pagination?.total ?? 0; return PageLayout(` ${SearchForm({ filters, pagination })} - ${ProductList({ loading, products, pagination })} + ${ProductList({ loading, products, pagination, totalCount })} ${CartModal()} `); }; From 0307c17d44d6a9a92c9021d306aa5e580b2fe651 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Thu, 13 Nov 2025 22:12:27 +0900 Subject: [PATCH 17/43] =?UTF-8?q?feat(Home):=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EC=83=81=ED=83=9C=20localStorage=EB=A1=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/appStore.js | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/store/appStore.js b/src/store/appStore.js index c39f7a11..37fa0b1c 100644 --- a/src/store/appStore.js +++ b/src/store/appStore.js @@ -5,13 +5,43 @@ const createHomepageState = () => ({ isLoadingMore: false, }); +const CART_STORAGE_KEY = "cart"; + const createCartState = () => ({ items: [], }); +const loadCartFromStorage = () => { + try { + const stored = localStorage.getItem(CART_STORAGE_KEY); + if (!stored) { + return createCartState(); + } + + const parsed = JSON.parse(stored); + if (parsed && Array.isArray(parsed.items)) { + return { + items: parsed.items, + }; + } + } catch (error) { + console.error("Failed to load cart from localStorage:", error); + } + + return createCartState(); +}; + +const saveCartToStorage = (cartState) => { + try { + localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cartState)); + } catch (error) { + console.error("Failed to save cart to localStorage:", error); + } +}; + let state = { homepage: createHomepageState(), - cart: createCartState(), + cart: loadCartFromStorage(), }; const listeners = new Set(); @@ -55,6 +85,7 @@ const updateCart = (updater) => { cart: nextCart, }; + saveCartToStorage(nextCart); notify(); }; From 05bce51438458dde7da2da4aca5d768eddc18603 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 00:24:10 +0900 Subject: [PATCH 18/43] =?UTF-8?q?fix:=20vite=20base=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20"/"=EB=A1=9C=20=EC=84=A4=EC=A0=95=ED=95=98=EC=97=AC?= =?UTF-8?q?=20dev/preview=20=EC=A0=95=EC=83=81=20=EC=9E=91=EB=8F=99?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 49 ++++++++++++++++++++++++++++++++++++ src/main.js | 9 ++++++- vite.config.js | 7 ++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..e726b2df --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Deploy to GitHub Pages + +on: + push: # push trigger + branches: + - main + - feature-* # Feature 브랜치도 배포 + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "./dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/src/main.js b/src/main.js index 778f1d7d..b75724b0 100644 --- a/src/main.js +++ b/src/main.js @@ -12,7 +12,14 @@ import { setupHeader } from "./components/Header.js"; import { resetHomepageState, setHomepageState } from "./store/appStore.js"; const enableMocking = () => - import("./mocks/browser.js").then(({ worker }) => worker.start({ onUnhandledRequest: "bypass" })); + import("./mocks/browser.js").then(({ worker }) => + worker.start({ + serviceWorker: { + url: `${import.meta.env.BASE_URL}mockServiceWorker.js`, + }, + onUnhandledRequest: "bypass", + }), + ); // 1) 라우트 정의 const routes = [ diff --git a/vite.config.js b/vite.config.js index 2eef1c44..44ed0b11 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + base: "/", test: { globals: true, environment: "jsdom", @@ -12,4 +13,10 @@ export default defineConfig({ }, }, }, + build: { + outDir: "dist", + }, + preview: { + port: 4173, + }, }); From 983c5e60e343c8e4420f3dff83c871898b743637 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 00:30:01 +0900 Subject: [PATCH 19/43] =?UTF-8?q?feat(Home):=20=EA=B2=80=EC=83=89=EC=96=B4?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=ED=9B=84,=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=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 --- src/router/enhancers/searchForm.js | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/router/enhancers/searchForm.js b/src/router/enhancers/searchForm.js index 61f6da22..2f6e908e 100644 --- a/src/router/enhancers/searchForm.js +++ b/src/router/enhancers/searchForm.js @@ -20,6 +20,31 @@ const handleFormChange = (event) => { } }; +const handleSearchInputKeydown = (event) => { + if (event.key !== "Enter") { + return; + } + + const searchInput = event.target; + if (!searchInput || !searchInput.matches("#search-input")) { + return; + } + + event.preventDefault(); + + const searchValue = searchInput.value.trim(); + const params = new URLSearchParams(window.location.search); + + if (searchValue) { + params.set("search", searchValue); + } else { + params.delete("search"); + } + params.delete("page"); + + eventBus.emit("filters:change", params); +}; + const bindSearchFormListeners = () => { const root = document.querySelector("#root"); if (!root) { @@ -31,10 +56,18 @@ const bindSearchFormListeners = () => { return () => {}; } + const searchInput = searchForm.querySelector("#search-input"); + searchForm.addEventListener("change", handleFormChange); + if (searchInput) { + searchInput.addEventListener("keydown", handleSearchInputKeydown); + } return () => { searchForm.removeEventListener("change", handleFormChange); + if (searchInput) { + searchInput.removeEventListener("keydown", handleSearchInputKeydown); + } }; }; From fc9e5b1354590e9eb5d2c9ca680d2b5b4a676716 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 00:40:05 +0900 Subject: [PATCH 20/43] =?UTF-8?q?feat(Home):=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=84=A0=ED=83=9D=20=EB=B0=8F=20=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EB=93=9C=ED=81=AC=EB=9F=BC=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=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 --- src/components/SearchForm.js | 90 ++++++++++++++++++++++++++++-- src/main.js | 16 ++++-- src/pages/Homepage.js | 4 +- src/router/enhancers/searchForm.js | 64 +++++++++++++++++++++ 4 files changed, 160 insertions(+), 14 deletions(-) diff --git a/src/components/SearchForm.js b/src/components/SearchForm.js index 37ede0fa..ef9adb78 100644 --- a/src/components/SearchForm.js +++ b/src/components/SearchForm.js @@ -1,7 +1,83 @@ -export const SearchForm = ({ filters = {} } = {}) => { +const renderCategory1Buttons = (categories = {}, selectedCategory1 = "") => { + const category1Names = Object.keys(categories); + if (category1Names.length === 0) { + return '
카테고리 로딩 중...
'; + } + + return category1Names + .map((cat1) => { + const isSelected = cat1 === selectedCategory1; + return ``; + }) + .join(""); +}; + +const renderCategory2Buttons = (categories = {}, selectedCategory1 = "", selectedCategory2 = "") => { + if (!selectedCategory1 || !categories[selectedCategory1]) { + return ""; + } + + const category2Names = Object.keys(categories[selectedCategory1]); + if (category2Names.length === 0) { + return ""; + } + + return `
+ ${category2Names + .map((cat2) => { + const isSelected = cat2 === selectedCategory2; + return ``; + }) + .join("")} +
`; +}; + +const renderBreadcrumb = (selectedCategory1 = "", selectedCategory2 = "", allCategory1Names = []) => { + const breadcrumbParts = ['']; + + breadcrumbParts.push( + '', + ); + + if (selectedCategory1) { + breadcrumbParts.push('>'); + breadcrumbParts.push( + ``, + ); + } + + if (selectedCategory2) { + breadcrumbParts.push('>'); + breadcrumbParts.push(`${selectedCategory2}`); + } + + if (!selectedCategory1 && !selectedCategory2 && allCategory1Names.length > 0) { + breadcrumbParts.push(`${allCategory1Names.join(" ")}`); + } + + return breadcrumbParts.join(""); +}; + +export const SearchForm = ({ filters = {}, categories = {} } = {}) => { const limit = String(filters.limit ?? "20"); const sort = String(filters.sort ?? "price_asc"); const keyword = filters.search ?? filters.query ?? ""; + const selectedCategory1 = filters.category1 ?? ""; + const selectedCategory2 = filters.category2 ?? ""; + const allCategory1Names = Object.keys(categories); const contentView = /*html*/ `
@@ -22,15 +98,17 @@ export const SearchForm = ({ filters = {} } = {}) => {
-
- - +
+ ${renderBreadcrumb(selectedCategory1, selectedCategory2, allCategory1Names)}
-
-
카테고리 로딩 중...
+
+ ${renderCategory1Buttons(categories, selectedCategory1)}
+
+ ${renderCategory2Buttons(categories, selectedCategory1, selectedCategory2)} +
diff --git a/src/main.js b/src/main.js index b75724b0..c825cb7b 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,7 @@ import { createRouter } from "./router/Router.js"; import { Homepage } from "./pages/Homepage.js"; import { DetailPage } from "./pages/DetailPage.js"; -import { getProducts, getProduct } from "./api/productApi.js"; +import { getProducts, getProduct, getCategories } from "./api/productApi.js"; import { attachSearchFormEnhancer } from "./router/enhancers/searchForm.js"; import { attachProductListEnhancer } from "./router/enhancers/productList.js"; import { registerHomepageEvents } from "./events/homepageEvents.js"; @@ -29,15 +29,18 @@ const routes = [ resetHomepageState(); const root = document.querySelector("#root"); - root.innerHTML = Homepage({ loading: true, filters: query }); - const data = await getProducts(query); + // 카테고리 목록과 상품 목록을 병렬로 가져오기 + const [categoriesData, productsData] = await Promise.all([getCategories().catch(() => ({})), getProducts(query)]); + + root.innerHTML = Homepage({ loading: true, filters: query, categories: categoriesData }); + const mergedFilters = { ...query, - ...(data?.filters ?? {}), + ...(productsData?.filters ?? {}), }; - const products = Array.isArray(data?.products) ? data.products : []; - const pagination = data?.pagination ?? null; + const products = Array.isArray(productsData?.products) ? productsData.products : []; + const pagination = productsData?.pagination ?? null; setHomepageState({ filters: mergedFilters, @@ -51,6 +54,7 @@ const routes = [ filters: mergedFilters, products, pagination, + categories: categoriesData, }); }, }, diff --git a/src/pages/Homepage.js b/src/pages/Homepage.js index dea1bcfd..89488023 100644 --- a/src/pages/Homepage.js +++ b/src/pages/Homepage.js @@ -1,10 +1,10 @@ import { PageLayout } from "./PageLayout.js"; import { SearchForm, ProductList, CartModal } from "../components/index.js"; -export const Homepage = ({ filters, products, pagination, loading = false }) => { +export const Homepage = ({ filters, products, pagination, loading = false, categories = {} }) => { const totalCount = pagination?.total ?? 0; return PageLayout(` - ${SearchForm({ filters, pagination })} + ${SearchForm({ filters, pagination, categories })} ${ProductList({ loading, products, pagination, totalCount })} ${CartModal()} `); diff --git a/src/router/enhancers/searchForm.js b/src/router/enhancers/searchForm.js index 2f6e908e..1cd5df39 100644 --- a/src/router/enhancers/searchForm.js +++ b/src/router/enhancers/searchForm.js @@ -45,6 +45,64 @@ const handleSearchInputKeydown = (event) => { eventBus.emit("filters:change", params); }; +const handleCategoryClick = (event) => { + // 브레드크럼 버튼이면 무시 + if (event.target.closest("button[data-breadcrumb]")) { + return; + } + + const button = event.target.closest("button[data-category1], button[data-category2]"); + if (!button) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const params = new URLSearchParams(window.location.search); + const category1 = button.dataset.category1 || ""; + const category2 = button.dataset.category2 || ""; + + if (category2) { + // 2차 카테고리 선택 + params.set("category1", category1); + params.set("category2", category2); + } else if (category1) { + // 1차 카테고리 선택 + params.set("category1", category1); + params.delete("category2"); + } + + params.delete("page"); + eventBus.emit("filters:change", params); +}; + +const handleBreadcrumbClick = (event) => { + const button = event.target.closest("button[data-breadcrumb]"); + if (!button) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + const params = new URLSearchParams(window.location.search); + const breadcrumbType = button.dataset.breadcrumb; + + if (breadcrumbType === "reset") { + // 전체 클릭 - 모든 카테고리 제거 (검색어와 다른 필터는 유지) + params.delete("category1"); + params.delete("category2"); + } else if (breadcrumbType === "category1") { + // 1차 카테고리 브레드크럼 클릭 - 2차 카테고리만 제거 (1차 카테고리와 다른 필터는 유지) + params.delete("category2"); + } + + params.delete("page"); + eventBus.emit("filters:change", params); +}; + const bindSearchFormListeners = () => { const root = document.querySelector("#root"); if (!root) { @@ -59,12 +117,18 @@ const bindSearchFormListeners = () => { const searchInput = searchForm.querySelector("#search-input"); searchForm.addEventListener("change", handleFormChange); + // 브레드크럼 클릭 이벤트를 먼저 등록하여 우선 처리 + searchForm.addEventListener("click", handleBreadcrumbClick, true); // capture phase에서 처리 + searchForm.addEventListener("click", handleCategoryClick); + if (searchInput) { searchInput.addEventListener("keydown", handleSearchInputKeydown); } return () => { searchForm.removeEventListener("change", handleFormChange); + searchForm.removeEventListener("click", handleCategoryClick); + searchForm.removeEventListener("click", handleBreadcrumbClick, true); // capture phase 리스너 제거 if (searchInput) { searchInput.removeEventListener("keydown", handleSearchInputKeydown); } From 8c780d5438d0b747719397398ed12c17ae9444d3 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 01:02:32 +0900 Subject: [PATCH 21/43] =?UTF-8?q?feat(Detail):=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=8F=99=EC=9E=91=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/events/detailPageEvents.js | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/events/detailPageEvents.js b/src/events/detailPageEvents.js index 5da40778..c4a21524 100644 --- a/src/events/detailPageEvents.js +++ b/src/events/detailPageEvents.js @@ -1,6 +1,42 @@ import { appendCartProduct } from "../store/appStore.js"; +const handleQuantityChange = (event) => { + const quantityInput = document.querySelector("#quantity-input"); + if (!quantityInput) { + return; + } + + const button = event.target.closest("#quantity-increase, #quantity-decrease"); + if (!button) { + return; + } + + event.preventDefault(); + + const currentValue = Number(quantityInput.value) || 1; + const min = Number(quantityInput.min) || 1; + const max = Number(quantityInput.max) || 999; + let newValue = currentValue; + + if (button.id === "quantity-increase") { + // 증가 버튼 + newValue = Math.min(currentValue + 1, max); + } else if (button.id === "quantity-decrease") { + // 감소 버튼 + newValue = Math.max(currentValue - 1, min); + } + + quantityInput.value = String(newValue); +}; + const handleDetailPageClick = (event) => { + // 수량 버튼 클릭 처리 + const quantityButton = event.target.closest("#quantity-increase, #quantity-decrease"); + if (quantityButton) { + handleQuantityChange(event); + return; + } + // DetailPage 장바구니 담기 버튼 const addToCartButton = event.target.closest("#add-to-cart-btn"); if (addToCartButton) { From ba7be61c32721ff8cc648fc33f4b2853e5f7a9d5 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 01:11:23 +0900 Subject: [PATCH 22/43] =?UTF-8?q?feat(Detail):=20DetailPage=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=20=EB=B2=84=ED=8A=BC=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9E=A5=EB=B0=94=EA=B5=AC=EB=8B=88=20localStorage=20?= =?UTF-8?q?=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 --- src/components/Header.js | 3 ++- src/events/detailPageEvents.js | 17 ++++++++--------- src/store/appStore.js | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/Header.js b/src/components/Header.js index d19764cd..6ef55405 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -8,7 +8,8 @@ const updateCartBadge = () => { let badge = cartIconButton.querySelector("[data-cart-badge]"); const cartState = getCartState(); - const itemCount = cartState.items.reduce((sum, item) => sum + (item.quantity ?? 1), 0); + // 장바구니에 담긴 아이템 수 (고유 상품 개수) + const itemCount = cartState.items.length; if (itemCount > 0) { if (!badge) { diff --git a/src/events/detailPageEvents.js b/src/events/detailPageEvents.js index c4a21524..f7d2f19f 100644 --- a/src/events/detailPageEvents.js +++ b/src/events/detailPageEvents.js @@ -57,15 +57,14 @@ const handleDetailPageClick = (event) => { const image = productImage?.getAttribute("src") ?? ""; const quantity = Number(quantityInput?.value ?? 1); - // 수량만큼 장바구니에 추가 - for (let i = 0; i < quantity; i++) { - appendCartProduct({ - id: productId, - title, - price, - image, - }); - } + // 수량을 한 번에 반영하여 장바구니에 추가 (localStorage에 자동 저장됨) + appendCartProduct({ + id: productId, + title, + price, + image, + quantity, + }); // DetailPage에서는 모달을 띄우지 않음 return; diff --git a/src/store/appStore.js b/src/store/appStore.js index 37fa0b1c..4ef0a654 100644 --- a/src/store/appStore.js +++ b/src/store/appStore.js @@ -145,7 +145,7 @@ export const resetCartState = () => { updateCart(createCartState()); }; -export const appendCartProduct = ({ id, title, price, image }) => { +export const appendCartProduct = ({ id, title, price, image, quantity = 1 }) => { if (!id) { return; } @@ -158,7 +158,7 @@ export const appendCartProduct = ({ id, title, price, image }) => { index === existingIndex ? { ...item, - quantity: item.quantity + 1, + quantity: (item.quantity ?? 1) + quantity, } : item, ); @@ -174,7 +174,7 @@ export const appendCartProduct = ({ id, title, price, image }) => { title, price, image, - quantity: 1, + quantity: quantity || 1, }; return { From 7550e8dc0133b63d39cb4c6d2192aca152487d40 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 01:13:38 +0900 Subject: [PATCH 23/43] =?UTF-8?q?feat(Cart):=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EB=AA=A8=EB=8B=AC=20=EB=B0=B0=EA=B2=BD=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=8B=9C=20=EB=8B=AB=EA=B8=B0=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/events/uiEvents.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/events/uiEvents.js b/src/events/uiEvents.js index 5b33d4f8..2878fb54 100644 --- a/src/events/uiEvents.js +++ b/src/events/uiEvents.js @@ -43,7 +43,15 @@ const handleUIClick = (event) => { const cartModalCloseButton = event.target.closest("#cart-modal-close-btn"); if (cartModalCloseButton) { - toggleCartModal(); + hideCartModal(); + return; + } + + // 모달 배경 클릭 시 닫기 + const cartModal = event.target.closest("#cart-modal"); + if (cartModal && event.target === cartModal) { + hideCartModal(); + return; } }; From 55a27a4a413daa3bba28a10d2abf1cb5f404c99d Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 01:20:49 +0900 Subject: [PATCH 24/43] =?UTF-8?q?feat(Detail):=20DetailPage=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=8B=9C=20Homepage=EB=A1=9C=20=ED=95=84=ED=84=B0=EB=A7=81?= =?UTF-8?q?=EB=90=9C=20=EC=83=81=ED=83=9C=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=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 --- src/components/ProductDetali.js | 2 +- src/events/detailPageEvents.js | 30 +++++++++++++++++++++++++++++- src/main.js | 2 +- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/components/ProductDetali.js b/src/components/ProductDetali.js index 84442deb..17965e02 100644 --- a/src/components/ProductDetali.js +++ b/src/components/ProductDetali.js @@ -22,7 +22,7 @@ export const ProductDetail = ({ loading, ...product }) => { -
diff --git a/src/events/detailPageEvents.js b/src/events/detailPageEvents.js index f7d2f19f..3d75df7d 100644 --- a/src/events/detailPageEvents.js +++ b/src/events/detailPageEvents.js @@ -1,5 +1,8 @@ import { appendCartProduct } from "../store/appStore.js"; +// router는 registerDetailPageEvents에서 주입받을 수 있도록 클로저로 관리 +let routerInstance = null; + const handleQuantityChange = (event) => { const quantityInput = document.querySelector("#quantity-input"); if (!quantityInput) { @@ -30,6 +33,30 @@ const handleQuantityChange = (event) => { }; const handleDetailPageClick = (event) => { + // 카테고리 브레드크럼 클릭 처리 + const breadcrumbLink = event.target.closest(".breadcrumb-link"); + if (breadcrumbLink && routerInstance) { + event.preventDefault(); + + const category1 = breadcrumbLink.dataset.category1; + const category2 = breadcrumbLink.dataset.category2; + + const params = new URLSearchParams(); + + if (category2) { + // 2차 카테고리 클릭 시 1차와 2차 모두 선택 + params.set("category1", category1 || ""); + params.set("category2", category2); + } else if (category1) { + // 1차 카테고리 클릭 시 1차만 선택 + params.set("category1", category1); + } + + const url = params.toString() ? `/?${params.toString()}` : "/"; + routerInstance.push(url); + return; + } + // 수량 버튼 클릭 처리 const quantityButton = event.target.closest("#quantity-increase, #quantity-decrease"); if (quantityButton) { @@ -71,6 +98,7 @@ const handleDetailPageClick = (event) => { } }; -export const registerDetailPageEvents = () => { +export const registerDetailPageEvents = (router) => { + routerInstance = router; document.body.addEventListener("click", handleDetailPageClick); }; diff --git a/src/main.js b/src/main.js index c825cb7b..4c63a98a 100644 --- a/src/main.js +++ b/src/main.js @@ -80,7 +80,7 @@ attachSearchFormEnhancer(router); attachProductListEnhancer(router); registerHomepageEvents(router); registerUIEvents(); -registerDetailPageEvents(); +registerDetailPageEvents(router); setupCartModal(router); setupHeader(router); From 74032bb6c61c781115a890060f9c62eb3d7cacbd Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 01:33:13 +0900 Subject: [PATCH 25/43] =?UTF-8?q?feat(Detail):=20DetailPage=20=EC=83=81?= =?UTF-8?q?=ED=92=88=EB=AA=A9=EB=A1=9D=EC=9C=BC=EB=A1=9C=20=EB=8F=8C?= =?UTF-8?q?=EC=95=84=EA=B0=80=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20=EA=B8=B0?= =?UTF-8?q?=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 --- src/components/ProductDetali.js | 2 +- src/events/detailPageEvents.js | 41 +++++++++++++++++++++++++++++++++ src/events/homepageEvents.js | 4 ++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/components/ProductDetali.js b/src/components/ProductDetali.js index 17965e02..2819af58 100644 --- a/src/components/ProductDetali.js +++ b/src/components/ProductDetali.js @@ -57,7 +57,7 @@ export const ProductDetail = ({ loading, ...product }) => {
- 4.0 (749개 리뷰) + 4.0 (${product.reviewCount}개 리뷰)
diff --git a/src/events/detailPageEvents.js b/src/events/detailPageEvents.js index 3d75df7d..8b9fe9a0 100644 --- a/src/events/detailPageEvents.js +++ b/src/events/detailPageEvents.js @@ -33,6 +33,47 @@ const handleQuantityChange = (event) => { }; const handleDetailPageClick = (event) => { + // 상품목록으로 돌아가기 버튼 클릭 처리 + const goToProductListButton = event.target.closest(".go-to-product-list"); + if (goToProductListButton && routerInstance) { + event.preventDefault(); + + // 브라우저 히스토리를 확인하여 이전 페이지가 Homepage인지 확인 + // SPA에서는 document.referrer가 항상 정확하지 않을 수 있으므로 + // sessionStorage에 이전 URL을 저장하거나 history를 활용 + + // 먼저 history에 이전 경로가 있는지 확인 (브라우저 뒤로가기) + // 하지만 더 나은 방법은 sessionStorage에 이전 필터 상태를 저장하는 것 + + // sessionStorage에서 이전 필터 상태 가져오기 + const previousFilters = sessionStorage.getItem("previousHomepageFilters"); + + if (previousFilters) { + try { + const filters = JSON.parse(previousFilters); + const params = new URLSearchParams(); + + // 필터 상태 복원 + if (filters.category1) params.set("category1", filters.category1); + if (filters.category2) params.set("category2", filters.category2); + if (filters.search) params.set("search", filters.search); + if (filters.limit) params.set("limit", filters.limit); + if (filters.sort) params.set("sort", filters.sort); + + const url = params.toString() ? `/?${params.toString()}` : "/"; + routerInstance.push(url); + } catch { + // 파싱 실패 시 홈으로 이동 + routerInstance.push("/"); + } + } else { + // 이전 필터 상태가 없으면 홈으로 이동 + routerInstance.push("/"); + } + + return; + } + // 카테고리 브레드크럼 클릭 처리 const breadcrumbLink = event.target.closest(".breadcrumb-link"); if (breadcrumbLink && routerInstance) { diff --git a/src/events/homepageEvents.js b/src/events/homepageEvents.js index 24d8f6ba..b2297cba 100644 --- a/src/events/homepageEvents.js +++ b/src/events/homepageEvents.js @@ -153,6 +153,10 @@ const handleHomepageClick = (router) => (event) => { return; } + // 상품 상세 페이지로 이동하기 전에 현재 필터 상태를 sessionStorage에 저장 + const currentFilters = Object.fromEntries(new URLSearchParams(window.location.search)); + sessionStorage.setItem("previousHomepageFilters", JSON.stringify(currentFilters)); + router.push(`/products/${productId}`); } }; From 6697f4f95e2c5bf034cbec4854f92f328eb95cbc Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 02:22:22 +0900 Subject: [PATCH 26/43] =?UTF-8?q?feat(Detail):=20DetailPage=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=EC=83=81=ED=92=88=20=EB=AA=A9=EB=A1=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductDetali.js | 47 +++++++++++++++++++++------------ src/events/detailPageEvents.js | 18 +++++++++++++ src/main.js | 16 ++++++++++- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/components/ProductDetali.js b/src/components/ProductDetali.js index 2819af58..857c0fe1 100644 --- a/src/components/ProductDetali.js +++ b/src/components/ProductDetali.js @@ -1,4 +1,32 @@ -export const ProductDetail = ({ loading, ...product }) => { +const renderRelatedProduct = ({ productId, title, image, lprice }) => { + return /*html*/ ` + + `; +}; + +const renderRelatedProducts = (relatedProducts = []) => { + if (!Array.isArray(relatedProducts) || relatedProducts.length === 0) { + return /*html*/ ` +
+

관련 상품이 없습니다.

+
+ `; + } + + return /*html*/ ` +
+ ${relatedProducts.map(renderRelatedProduct).join("")} +
+ `; +}; + +export const ProductDetail = ({ loading, relatedProducts = [], ...product }) => { const loadingView = /*html*/ `
@@ -115,22 +143,7 @@ export const ProductDetail = ({ loading, ...product }) => {

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

-
- - -
+ ${renderRelatedProducts(relatedProducts)}
diff --git a/src/events/detailPageEvents.js b/src/events/detailPageEvents.js index 8b9fe9a0..4f23af97 100644 --- a/src/events/detailPageEvents.js +++ b/src/events/detailPageEvents.js @@ -105,6 +105,24 @@ const handleDetailPageClick = (event) => { return; } + // 관련 상품 카드 클릭 처리 + const relatedProductCard = event.target.closest(".related-product-card"); + if (relatedProductCard && routerInstance) { + event.preventDefault(); + + const productId = relatedProductCard.dataset.productId; + if (!productId) { + return; + } + + // 현재 필터 상태 저장 + const currentFilters = Object.fromEntries(new URLSearchParams(window.location.search)); + sessionStorage.setItem("previousHomepageFilters", JSON.stringify(currentFilters)); + + routerInstance.push(`/products/${productId}`); + return; + } + // DetailPage 장바구니 담기 버튼 const addToCartButton = event.target.closest("#add-to-cart-btn"); if (addToCartButton) { diff --git a/src/main.js b/src/main.js index 4c63a98a..370bdca1 100644 --- a/src/main.js +++ b/src/main.js @@ -65,7 +65,21 @@ const routes = [ root.innerHTML = DetailPage({ loading: true }); const product = await getProduct(params.id); - return DetailPage({ loading: false, ...product }); + + // 관련 상품 가져오기 (같은 category2, 현재 상품 제외) + let relatedProducts = []; + if (product.category2) { + const relatedProductsData = await getProducts({ + category2: product.category2, + limit: 10, + sort: "price_asc", + }); + relatedProducts = (relatedProductsData?.products || []) + .filter((item) => item.productId !== product.productId) + .slice(0, 4); // 최대 4개만 표시 + } + + return DetailPage({ loading: false, relatedProducts, ...product }); }, }, ]; From e0ac137c8e5a6bafebf21e44d09d8730c81f650c Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 02:36:36 +0900 Subject: [PATCH 27/43] =?UTF-8?q?feat(Cart):=20CartModal=20UI=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20esc=20=ED=82=A4=20=EB=88=8C=EB=A0=80?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=20close=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CartModal.js | 58 ++++++++++++++++++++++++++++++------- src/events/uiEvents.js | 11 +++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/components/CartModal.js b/src/components/CartModal.js index 72ad988e..85ebcca1 100644 --- a/src/components/CartModal.js +++ b/src/components/CartModal.js @@ -18,6 +18,10 @@ const renderCartItem = ({ id, title, price, image, quantity }) => { const totalPrice = Number(price) * Number(quantity ?? 1); return /*html*/ `
+
${title}
@@ -26,12 +30,28 @@ const renderCartItem = ({ id, title, price, image, quantity }) => { ${title}

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

-
- 수량: ${quantity} +
+ + +

${totalPrice.toLocaleString()}원

+
`; @@ -43,19 +63,37 @@ const renderCartItems = (items = []) => { } return /*html*/ ` +
+ +
${items.map(renderCartItem).join("")}
-
- +
+ + +
+ 총 금액 + 670원 +
+ +
+
+ + +
+
`; }; diff --git a/src/events/uiEvents.js b/src/events/uiEvents.js index 2878fb54..6f0db717 100644 --- a/src/events/uiEvents.js +++ b/src/events/uiEvents.js @@ -55,8 +55,19 @@ const handleUIClick = (event) => { } }; +const handleUIKeydown = (event) => { + // ESC 키로 모달 닫기 + if (event.key === "Escape" || event.key === "Esc") { + const cartModal = getCartModal(); + if (cartModal && !cartModal.classList.contains("hidden")) { + hideCartModal(); + } + } +}; + export const registerUIEvents = () => { document.body.addEventListener("click", handleUIClick); + document.addEventListener("keydown", handleUIKeydown); }; export { showCartModal, hideCartModal }; From 069cfdbeb918a0eb7d671e204697fc86bcd3dd64 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 02:47:46 +0900 Subject: [PATCH 28/43] =?UTF-8?q?feat(Cart):=20CartModal=20chekcbox=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B4=9D=20=EA=B8=88=EC=95=A1=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=ED=91=9C=EC=B6=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CartModal.js | 88 +++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/src/components/CartModal.js b/src/components/CartModal.js index 85ebcca1..53677788 100644 --- a/src/components/CartModal.js +++ b/src/components/CartModal.js @@ -57,16 +57,31 @@ const renderCartItem = ({ id, title, price, image, quantity }) => { `; }; +const calculateTotalPrice = (items = []) => { + if (!Array.isArray(items) || !items.length) { + return 0; + } + + return items.reduce((total, item) => { + const price = Number(item.price) || 0; + const quantity = Number(item.quantity) || 1; + return total + price * quantity; + }, 0); +}; + const renderCartItems = (items = []) => { if (!Array.isArray(items) || !items.length) { return EMPTY_VIEW; } + const itemCount = items.length; + const totalPrice = calculateTotalPrice(items); + return /*html*/ `
@@ -79,7 +94,7 @@ const renderCartItems = (items = []) => {
총 금액 - 670원 + ${totalPrice.toLocaleString()}원
@@ -119,7 +134,57 @@ const updateCartModalContent = () => { } const cartState = getCartState(); + const itemCount = cartState.items.length; + const totalPrice = calculateTotalPrice(cartState.items); + + // 장바구니 아이템 개수 업데이트 + const itemCountSpan = cartModal.querySelector("#cart-modal-item-count"); + if (itemCountSpan) { + itemCountSpan.textContent = itemCount > 0 ? `(${itemCount})` : ""; + } + + // 총금액 업데이트 + const totalPriceSpan = cartModal.querySelector("#cart-modal-total-price"); + if (totalPriceSpan) { + totalPriceSpan.textContent = `${totalPrice.toLocaleString()}원`; + } + contentContainer.innerHTML = renderCartItems(cartState.items); + + // 렌더링 후 총금액 다시 업데이트 (동적으로 생성된 요소에 대해) + requestAnimationFrame(() => { + const newTotalPriceSpan = cartModal.querySelector("#cart-modal-total-price"); + if (newTotalPriceSpan) { + newTotalPriceSpan.textContent = `${totalPrice.toLocaleString()}원`; + } + }); +}; + +const handleSelectAll = (event) => { + const selectAllCheckbox = event.target.closest("#cart-modal-select-all-checkbox"); + if (!selectAllCheckbox) { + return; + } + + const isChecked = selectAllCheckbox.checked; + const itemCheckboxes = document.querySelectorAll(".cart-item-checkbox"); + + itemCheckboxes.forEach((checkbox) => { + checkbox.checked = isChecked; + }); +}; + +const handleItemCheckboxChange = () => { + const selectAllCheckbox = document.querySelector("#cart-modal-select-all-checkbox"); + if (!selectAllCheckbox) { + return; + } + + const itemCheckboxes = document.querySelectorAll(".cart-item-checkbox"); + const checkedCount = Array.from(itemCheckboxes).filter((cb) => cb.checked).length; + + // 모든 아이템이 선택되었으면 전체선택 체크박스도 체크 + selectAllCheckbox.checked = checkedCount === itemCheckboxes.length && itemCheckboxes.length > 0; }; let isSetup = false; @@ -132,7 +197,22 @@ export const setupCartModal = (router) => { isSetup = true; const ensureRendered = () => { - requestAnimationFrame(updateCartModalContent); + requestAnimationFrame(() => { + updateCartModalContent(); + + // 이벤트 리스너 재바인딩 (동적으로 생성된 요소에 대해) + const selectAllCheckbox = document.querySelector("#cart-modal-select-all-checkbox"); + if (selectAllCheckbox) { + selectAllCheckbox.removeEventListener("change", handleSelectAll); + selectAllCheckbox.addEventListener("change", handleSelectAll); + } + + const itemCheckboxes = document.querySelectorAll(".cart-item-checkbox"); + itemCheckboxes.forEach((checkbox) => { + checkbox.removeEventListener("change", handleItemCheckboxChange); + checkbox.addEventListener("change", handleItemCheckboxChange); + }); + }); }; subscribe(ensureRendered); @@ -143,6 +223,7 @@ export const setupCartModal = (router) => { export const CartModal = ({ cartProducts = [] } = {}) => { const initialItems = getInitialItems(cartProducts); + const initialItemCount = initialItems.length; return /*html*/ ` @@ -98,6 +98,10 @@ const renderCartItems = (items = []) => {
+
+
+
+
+ `; +}; diff --git a/src/components/index.js b/src/components/index.js index d72d9f6b..f05c7dbb 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -4,3 +4,4 @@ export * from "./SearchForm.js"; export * from "./ProductList.js"; export * from "./ProductDetali.js"; export * from "./CartModal.js"; +export * from "./Toast.js"; diff --git a/src/events/detailPageEvents.js b/src/events/detailPageEvents.js index 4f23af97..68898ac6 100644 --- a/src/events/detailPageEvents.js +++ b/src/events/detailPageEvents.js @@ -1,4 +1,5 @@ import { appendCartProduct } from "../store/appStore.js"; +import { showToast } from "./uiEvents.js"; // router는 registerDetailPageEvents에서 주입받을 수 있도록 클로저로 관리 let routerInstance = null; @@ -152,7 +153,7 @@ const handleDetailPageClick = (event) => { quantity, }); - // DetailPage에서는 모달을 띄우지 않음 + showToast(); return; } }; diff --git a/src/events/homepageEvents.js b/src/events/homepageEvents.js index b2297cba..bd38f370 100644 --- a/src/events/homepageEvents.js +++ b/src/events/homepageEvents.js @@ -7,7 +7,7 @@ import { setHomepageLoadingMore, } from "../store/appStore.js"; import { eventBus } from "../utils/EventBus.js"; -import { showCartModal } from "./uiEvents.js"; +import { showToast } from "./uiEvents.js"; const updateSentinel = ({ loading, hasNext, nextPage, message, error }) => { const sentinel = document.querySelector("[data-infinite-trigger]"); @@ -142,7 +142,7 @@ const handleHomepageClick = (router) => (event) => { image, }); - showCartModal(); + showToast(); return; } diff --git a/src/events/uiEvents.js b/src/events/uiEvents.js index 6f0db717..ce9ad0fb 100644 --- a/src/events/uiEvents.js +++ b/src/events/uiEvents.js @@ -1,4 +1,5 @@ const getCartModal = () => document.querySelector("#cart-modal"); +const getToastContainer = () => document.querySelector("#toast-container"); const showCartModal = () => { const cartModal = getCartModal(); @@ -53,6 +54,13 @@ const handleUIClick = (event) => { hideCartModal(); return; } + + // Toast 닫기 버튼 클릭 + const toastCloseButton = event.target.closest("#toast-close-btn"); + if (toastCloseButton) { + hideToast(); + return; + } }; const handleUIKeydown = (event) => { @@ -65,9 +73,75 @@ const handleUIKeydown = (event) => { } }; +let toastTimeout = null; + +const showToast = () => { + // 즉시 실행 (Toast는 PageLayout에 항상 있음) + let toastContainer = getToastContainer(); + + if (!toastContainer) { + // Toast 컨테이너가 없으면 재시도 (라우터 렌더링 중일 수 있음) + requestAnimationFrame(() => { + toastContainer = getToastContainer(); + if (!toastContainer) { + // 한 번 더 재시도 + setTimeout(() => { + toastContainer = getToastContainer(); + if (!toastContainer) { + console.error("Toast container not found in DOM"); + return; + } + displayToast(toastContainer); + }, 100); + return; + } + displayToast(toastContainer); + }); + return; + } + + displayToast(toastContainer); +}; + +const displayToast = (toastContainer) => { + if (!toastContainer) { + return; + } + + // 기존 타이머가 있으면 취소 + if (toastTimeout) { + clearTimeout(toastTimeout); + toastTimeout = null; + } + + // Toast 표시 - hidden 클래스 제거 + toastContainer.classList.remove("hidden"); + + // 3초 후 자동으로 숨김 + toastTimeout = setTimeout(() => { + hideToast(); + }, 3000); +}; + +const hideToast = () => { + const toastContainer = getToastContainer(); + if (!toastContainer) { + return; + } + + // 타이머 취소 + if (toastTimeout) { + clearTimeout(toastTimeout); + toastTimeout = null; + } + + // Toast 숨김 - hidden 클래스 추가 + toastContainer.classList.add("hidden"); +}; + export const registerUIEvents = () => { document.body.addEventListener("click", handleUIClick); document.addEventListener("keydown", handleUIKeydown); }; -export { showCartModal, hideCartModal }; +export { showCartModal, hideCartModal, showToast, hideToast }; diff --git a/src/pages/PageLayout.js b/src/pages/PageLayout.js index 61268f65..b46f647a 100644 --- a/src/pages/PageLayout.js +++ b/src/pages/PageLayout.js @@ -1,4 +1,4 @@ -import { Header, Footer } from "../components/index.js"; +import { Header, Footer, Toast } from "../components/index.js"; export const PageLayout = (children) => { return /*html*/ `
@@ -7,6 +7,7 @@ export const PageLayout = (children) => { ${children} ${Footer()} + ${Toast()}
`; }; From 4338e93b28611e13204fe1559f8e9a2218a3ea10 Mon Sep 17 00:00:00 2001 From: 1lmean Date: Fri, 14 Nov 2025 04:06:04 +0900 Subject: [PATCH 32/43] =?UTF-8?q?refactor(Toast):=20state=EB=B3=84=20?= =?UTF-8?q?=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 --- src/components/Toast.js | 63 ++++++++++++++++++++++++++++++++--------- src/events/uiEvents.js | 38 +++++++++++++++++-------- 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/src/components/Toast.js b/src/components/Toast.js index 625e70ec..58c6be07 100644 --- a/src/components/Toast.js +++ b/src/components/Toast.js @@ -1,21 +1,56 @@ +// Toast 타입별 설정 +const toastConfig = { + success: { + bgColor: "bg-green-600", + icon: ` + + `, + defaultMessage: "장바구니에 추가되었습니다", + }, + info: { + bgColor: "bg-blue-600", + icon: ` + + `, + defaultMessage: "선택된 상품들이 삭제되었습니다", + }, + error: { + bgColor: "bg-red-600", + icon: ` + + `, + defaultMessage: "오류가 발생했습니다.", + }, +}; + export const Toast = () => { return /*html*/ `