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..de7781b3 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { - "name": "front-6th-chapter1-1", + "name": "front-chapter1-1", "private": true, "version": "0.0.0", "type": "module", + "packageManager": "pnpm@9.0.0", "scripts": { "dev": "vite", "dev:hash": "vite --open ./index.hash.html", @@ -10,11 +11,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 +27,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 +43,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/components/Footer.js b/src/components/Footer.js new file mode 100644 index 00000000..f2089912 --- /dev/null +++ b/src/components/Footer.js @@ -0,0 +1,9 @@ +export const Footer = () => { + return /* html */ ` + + `; +}; diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 00000000..cead029d --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,52 @@ +import { router } from "../core/router.js"; +import { store } from "../core/store.js"; + +export const Header = () => { + const currentPath = router.currentPath; + const isList = currentPath === "/"; + + return /* html */ ` +
+
+
+ ${ + isList + ? /* html */ ` +

+ 쇼핑몰 +

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

상품 상세

+
+ ` + } +
+ + +
+
+
+
+ `; +}; diff --git a/src/components/ProductDetail.js b/src/components/ProductDetail.js new file mode 100644 index 00000000..843a02aa --- /dev/null +++ b/src/components/ProductDetail.js @@ -0,0 +1,166 @@ +const Loading = /*html*/ ` +
+
+
+

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

+
+
+`; + +const navigation = ({ category1, category2 }) => { + return /* html */ ` + + + `; +}; + +const detailItem = ({ productId, title, description, image, brand, lprice, stock, reviewCount, rating }) => { + const quantityCnt = 1; + + return /* html */ ` + +
+ +
+
+ ${title} +
+ +
+

${brand}

+

${title}

+ +
+
+ ${Array.from({ length: rating }) + .map( + () => /* html */ ` + + + + `, + ) + .join("")} + ${Array.from({ length: 5 - rating }) + .map( + () => /* html */ ` + + + + `, + ) + .join("")} +
+ ${Number(rating).toFixed(1)} (${reviewCount}개 리뷰) +
+ +
+ ${Number(lprice).toLocaleString()}원 +
+ +
+ 재고 ${stock}개 +
+ +
+ ${description} +
+
+
+ +
+
+ 수량 +
+ + + +
+
+ + +
+
+ `; +}; + +// 관련 상품 개별 카드 +const RelatedProductCard = ({ title, productId, lprice, image }) => { + return /* html */ ` + + `; +}; + +export const ProductDetail = ({ loading, product, relatedProducts }) => { + // 현재 상품 제외 + const filteredRelatedProducts = relatedProducts + ? relatedProducts.filter((p) => p.productId !== product?.productId) + : []; + + return /* html */ ` +
+ ${ + loading + ? Loading + : /* html */ ` + ${navigation({ category1: product.category1, category2: product.category2 })} + ${detailItem({ ...product })} + + ${ + filteredRelatedProducts.length > 0 + ? /* html */ ` +
+
+

관련 상품

+

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

+
+
+
+ ${filteredRelatedProducts.map(RelatedProductCard).join("")} +
+
+
+ ` + : "" + } + ` + } +
+ `; +}; diff --git a/src/components/ProductList.js b/src/components/ProductList.js new file mode 100644 index 00000000..dd93188b --- /dev/null +++ b/src/components/ProductList.js @@ -0,0 +1,85 @@ +const Skeleton = /* html */ ` +
+
+
+
+
+
+
+
+
+`; + +const Loading = /* html */ ` +
+
+ + + + + 상품을 불러오는 중... +
+
+`; +const ProductItem = ({ title, productId, image, lprice, brand }) => { + return /* html */ ` +
+ +
+ ${title} +
+ +
+
+

+ ${title} +

+

${brand}

+

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

+
+ + +
+
+ `; +}; + +export const ProductList = ({ loading, products }) => { + return /* html */ ` + +
+
+ ${ + loading + ? /* html */ ` + +
+ + ${Skeleton.repeat(4)} +
+ ${Loading} + ` + : /* html */ ` + +
+ 총 ${products.length}개의 상품 +
+
+ ${products.map(ProductItem).join("")} +
+ ` + } +
+
+ `; +}; diff --git a/src/components/SearchForm.js b/src/components/SearchForm.js new file mode 100644 index 00000000..727b422d --- /dev/null +++ b/src/components/SearchForm.js @@ -0,0 +1,128 @@ +const categoryLoading = /* html */ ` +
+
카테고리 로딩 중...
+
+ `; + +const categoryList = ({ categories }) => { + return /* html */ ` +
+ ${Object.keys(categories) + .map( + (category1) => /* html */ ` + + `, + ) + .join("")} +
+ `; +}; + +const category2List = ({ categories, category1, selectedCategory2 }) => { + if (!category1 || !categories[category1]) return ""; + + const subCategories = categories[category1]; + + return /* html */ ` +
+ ${Object.keys(subCategories) + .map((category2) => { + // 선택된 2depth 카테고리인지 확인 + const isActive = selectedCategory2 === category2; + const activeClasses = isActive + ? "bg-blue-100 border-blue-300 text-blue-800" + : "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"; + + return /* html */ ` + + `; + }) + .join("")} +
+ `; +}; + +export const SearchForm = ({ + loading, + categories, + category1, + category2, + limit = 20, + sort = "price_asc", + search = "", +}) => { + return /* html */ ` + +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + + ${category1 ? `>` : ""} + ${category1 ? `` : ""} + ${category2 ? `>` : ""} + ${category2 ? `` : ""} +
+ + ${!category1 ? (loading ? categoryLoading : categoryList({ categories })) : ""} + + ${category1 ? category2List({ categories, category1, selectedCategory2: category2 }) : ""} +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ `; +}; diff --git a/src/components/Toast.js b/src/components/Toast.js new file mode 100644 index 00000000..e814f64e --- /dev/null +++ b/src/components/Toast.js @@ -0,0 +1,100 @@ +// 토스트 컴포넌트 +const ToastComponent = ({ message, type = "success" }) => { + const configs = { + success: { + bgColor: "bg-green-600", + icon: /* html */ ` + + + + `, + }, + info: { + bgColor: "bg-blue-600", + icon: /* html */ ` + + + + `, + }, + error: { + bgColor: "bg-red-600", + icon: /* html */ ` + + + + `, + }, + }; + + const config = configs[type] || configs.success; + + return /* html */ ` +
+
+ ${config.icon} +
+

${message}

+ +
+ `; +}; + +// 토스트 컨테이너 관리 +let toastContainer = null; + +const initToastContainer = () => { + if (!toastContainer) { + toastContainer = document.createElement("div"); + toastContainer.id = "toast-container"; + toastContainer.className = "fixed bottom-20 left-1/2 transform -translate-x-1/2 z-50 flex flex-col gap-2"; + toastContainer.style.cssText = "pointer-events: none;"; + document.body.appendChild(toastContainer); + } + return toastContainer; +}; + +// 토스트 제거 함수 +const removeToast = (toastElement) => { + if (toastElement && toastElement.parentNode) { + toastElement.style.opacity = "0"; + toastElement.style.transform = "translateY(-20px)"; + toastElement.style.transition = "all 0.3s ease-out"; + + setTimeout(() => { + if (toastElement.parentNode) { + toastElement.remove(); + } + }, 300); + } +}; + +// 토스트 표시 함수 +const createToast = (message, type = "success") => { + const container = initToastContainer(); + + // 토스트 생성 + const toastWrapper = document.createElement("div"); + toastWrapper.style.cssText = "pointer-events: auto;"; + toastWrapper.innerHTML = ToastComponent({ message, type }); + + container.appendChild(toastWrapper); + + // 닫기 버튼 이벤트 + const closeBtn = toastWrapper.querySelector(".toast-close-btn"); + if (closeBtn) { + closeBtn.addEventListener("click", () => removeToast(toastWrapper)); + } + + setTimeout(() => removeToast(toastWrapper), 3000); +}; + +export const showToast = { + success: (message) => createToast(message, "success"), + info: (message) => createToast(message, "info"), + error: (message) => createToast(message, "error"), +}; diff --git a/src/components/index.js b/src/components/index.js new file mode 100644 index 00000000..269ab34e --- /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 "./ProductDetail.js"; diff --git a/src/core/observer.js b/src/core/observer.js new file mode 100644 index 00000000..ff22babc --- /dev/null +++ b/src/core/observer.js @@ -0,0 +1,37 @@ +// observer pattern +export const createObserver = () => { + const listeners = new Map(); // stateKey별 리스너 저장소 + + return { + subscribe(fn, stateKey = null) { + // stateKey가 없으면 전체 구독, 있으면 특정 상태만 구독 + if (!listeners.has(stateKey)) { + listeners.set(stateKey, new Set()); + } + listeners.get(stateKey).add(fn); + }, + unsubscribe(fn, stateKey = null) { + // 이벤트 리스너 제거 + if (listeners.has(stateKey)) { + listeners.get(stateKey).delete(fn); + } + }, + notify(stateKey) { + // 해당 stateKey를 구독하는 리스너만 실행 (전파 없음) + const notifyListeners = new Set(); + + // 전체 구독자 추가 (stateKey가 null인 경우만) + if (listeners.has(null)) { + listeners.get(null).forEach((fn) => notifyListeners.add(fn)); + } + + // 해당 경로 구독자만 추가 (상위/하위 전파 없음) + if (stateKey && listeners.has(stateKey)) { + listeners.get(stateKey).forEach((fn) => notifyListeners.add(fn)); + } + + // 중복 제거된 리스너 실행 + notifyListeners.forEach((fn) => fn()); + }, + }; +}; diff --git a/src/core/render.js b/src/core/render.js new file mode 100644 index 00000000..a81a36cd --- /dev/null +++ b/src/core/render.js @@ -0,0 +1,20 @@ +// render 함수 +// 라이프 사이클 포함 +import { router } from "./router.js"; + +const $root = document.querySelector("#root"); + +// 렌더 함수들 +export const render = () => { + // 현재 페이지의 render 메서드 호출 + const currentPage = router.getCurrentPage(); + + if (currentPage && currentPage.render) { + $root.innerHTML = currentPage.render(); + } + + // 페이지의 mounted 호출 (이벤트 리스너 등록) + if (currentPage && currentPage.mounted) { + currentPage.mounted(); + } +}; diff --git a/src/core/router.js b/src/core/router.js new file mode 100644 index 00000000..67d54272 --- /dev/null +++ b/src/core/router.js @@ -0,0 +1,156 @@ +// 라우터 +// url 경로 감지 -> 해당 페이지 렌더 +import { createObserver } from "./observer.js"; + +const observer = createObserver(); + +// base URL 제거 헬퍼 함수 +const getPathWithoutBase = () => { + const base = import.meta.env.BASE_URL || "/"; + const pathname = window.location.pathname; + + // base가 '/'가 아닌 경우 제거 + if (base !== "/" && pathname.startsWith(base)) { + return pathname.slice(base.length - 1) || "/"; + } + return pathname; +}; + +// router 초기화 +export const router = { + routes: {}, + currentPath: getPathWithoutBase(), + currentPage: null, // 현재 활성화된 페이지 추적 + subscribe: observer.subscribe, + notify: observer.notify, + + setup(routes) { + this.routes = routes; + + window.addEventListener("popstate", () => { + this.currentPath = getPathWithoutBase(); + this.handleRouteChange(); + }); + + // 초기 라우팅 + this.handleRouteChange(); + }, + + navigate(path) { + const base = import.meta.env.BASE_URL || "/"; + const fullPath = base === "/" ? path : base.slice(0, -1) + path; + history.pushState(null, null, fullPath); + + const pathWithoutQuery = path.split("?")[0]; + this.currentPath = pathWithoutQuery; + + this.handleRouteChange(); + }, + + // 쿼리 파라미터 읽기 + getQueryParams() { + return new URLSearchParams(window.location.search); + }, + + // 특정 쿼리 파라미터 값 가져오기 + getQueryParam(key, defaultValue = null) { + const params = this.getQueryParams(); + return params.get(key) || defaultValue; + }, + + // 쿼리 파라미터 업데이트 (기존 파라미터 유지) + updateQueryParams(newParams, options = { replace: true }) { + const params = this.getQueryParams(); + + // 새로운 파라미터로 업데이트 (null이면 삭제) + Object.entries(newParams).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") { + params.delete(key); + } else { + params.set(key, value.toString()); + } + }); + + const queryString = params.toString(); + const newURL = `${window.location.pathname}${queryString ? "?" + queryString : ""}`; + + if (options.replace) { + window.history.replaceState({}, "", newURL); + } else { + window.history.pushState({}, "", newURL); + } + }, + + // 쿼리 파라미터 전체 설정 (기존 것 덮어쓰기) + setQueryParams(params, options = { replace: true }) { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== null && value !== undefined && value !== "") { + searchParams.set(key, value.toString()); + } + }); + + const queryString = searchParams.toString(); + const newURL = `${window.location.pathname}${queryString ? "?" + queryString : ""}`; + + if (options.replace) { + window.history.replaceState({}, "", newURL); + } else { + window.history.pushState({}, "", newURL); + } + }, + + // 쿼리 파라미터 제거 (경로만 남기기) + clearQueryParams() { + window.history.replaceState({}, "", window.location.pathname); + }, + + // 경로 변경 시 페이지 라이프사이클 관리 + handleRouteChange() { + const { page, props } = this.getPageConfig(); + + // 이전 페이지 정리 (destroy 호출) + if (this.currentPage && this.currentPage.destroy) { + console.log("🔄 이전 페이지 destroy 호출"); + this.currentPage.destroy(); + } + // 새 페이지로 전환 + this.currentPage = page; + console.log("🔄 새 페이지 currentPage 전환", this.currentPage); + // 3. 새 페이지 초기화 (init 호출) + if (this.currentPage && this.currentPage.init) { + console.log("🔄 새 페이지 init 호출"); + this.currentPage.init(() => this.notify(), props); + } + // 렌더링 + this.notify(); + }, + + getPageConfig() { + const path = this.currentPath; + + if (path === "/") { + return { + page: this.routes["/"].page, + props: {}, + }; + } else if (path.startsWith("/product/")) { + const productId = path.split("/").pop(); + return { + page: this.routes["/product/:id"].page, + props: { productId }, + }; + } else { + return { + page: this.routes["*"].page, + props: {}, + }; + } + }, + + // 현재 페이지 가져오기 + getCurrentPage() { + return this.currentPage; + }, +}; diff --git a/src/core/store.js b/src/core/store.js new file mode 100644 index 00000000..11537cea --- /dev/null +++ b/src/core/store.js @@ -0,0 +1,147 @@ +// 전역 상태 관리 +import { createObserver } from "./observer.js"; +import { getProducts, getProduct, getCategories } from "../api/productApi.js"; +import { + loadCartFromStorage, + addToCart as addToCartUtil, + updateCartItemQuantity, + removeFromCart as removeFromCartUtil, + clearCart as clearCartUtil, + getCartTotal, + getCartItemCount, +} from "../utils/cart.js"; + +const observer = createObserver(); // 옵저버 인스턴스 생성 + +export const store = { + state: { + list: { + products: [], + categories: [], + category1: "", + category2: "", + loading: false, + filters: [], + pagination: [], + error: null, + limit: 20, + sort: "price_asc", + search: "", + }, + detail: { + product: null, + loading: false, + relatedProducts: [], + error: null, + }, + cart: { + items: [], + total: 0, + count: 0, + }, + }, + subscribe: observer.subscribe, + unsubscribe: observer.unsubscribe, + notify: observer.notify, + + setState(key, value) { + // key 형태: 'list.products', 'detail.loading' 등으로 받아오기 + const [topKey, nestedKey] = key.split("."); + + if (nestedKey) { + // 중첩된 경로: list.products + this.state[topKey] = { ...this.state[topKey], [nestedKey]: value }; + } else { + // 최상위 경로 (거의 사용 X) + this.state[key] = value; + } + + // 해당 경로 구독자에게만 알림 + observer.notify(key); + }, + + async fetchProducts() { + try { + this.setState("list.loading", true); + this.setState("list.error", null); + const response = await getProducts(this.state.list); + + Object.keys(response).forEach((key) => { + this.setState(`list.${key}`, response[key]); + }); + this.setState("list.loading", false); + } catch (error) { + this.setState("list.error", error); + this.setState("list.loading", false); + } + }, + + async fetchProductDetail(productId) { + try { + this.setState("detail.loading", true); + this.setState("detail.error", null); + const response = await getProduct(productId); + this.setState("detail.product", response); + this.setState("detail.loading", false); + } catch (error) { + this.setState("detail.error", error); + this.setState("detail.loading", false); + } + }, + + async fetchCategories() { + try { + this.setState("list.loading", true); + this.setState("list.categories", true); + this.setState("list.error", null); + const response = await getCategories(); + this.setState("list.categories", response); + this.setState("list.loading", false); + } catch (error) { + this.setState("list.error", error); + this.setState("list.loading", false); + } + }, + + // 장바구니 초기화 (localStorage에서 로드) + initCart() { + const cartItems = loadCartFromStorage(); + this.setState("cart.items", cartItems); + this.setState("cart.total", getCartTotal(cartItems)); + this.setState("cart.count", getCartItemCount(cartItems)); + console.log("🛒 장바구니 초기화 완료:", cartItems); + }, + + // 장바구니에 상품 추가 + addToCart(product, quantity) { + const updatedCart = addToCartUtil(product, quantity); + this.setState("cart.items", updatedCart); + this.setState("cart.total", getCartTotal(updatedCart)); + this.setState("cart.count", getCartItemCount(updatedCart)); + console.log("🛒 장바구니 상태 업데이트:", this.state.cart); + }, + + // 장바구니 상품 수량 변경 + updateCartQuantity(productId, quantity) { + const updatedCart = updateCartItemQuantity(productId, quantity); + this.setState("cart.items", updatedCart); + this.setState("cart.total", getCartTotal(updatedCart)); + this.setState("cart.count", getCartItemCount(updatedCart)); + }, + + // 장바구니에서 상품 제거 + removeFromCart(productId) { + const updatedCart = removeFromCartUtil(productId); + this.setState("cart.items", updatedCart); + this.setState("cart.total", getCartTotal(updatedCart)); + this.setState("cart.count", getCartItemCount(updatedCart)); + }, + + // 장바구니 비우기 + clearCart() { + const updatedCart = clearCartUtil(); + this.setState("cart.items", updatedCart); + this.setState("cart.total", 0); + this.setState("cart.count", 0); + }, +}; diff --git a/src/main.js b/src/main.js index 983c051f..7eab766a 100644 --- a/src/main.js +++ b/src/main.js @@ -1,19 +1,40 @@ -import { worker } from "./mocks/browser.js"; +import { router } from "./core/router.js"; +import { render } from "./core/render.js"; +import { store } from "./core/store.js"; +import { HomePage, DetailPage, NotFoundPage } from "./pages/index.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({ + serviceWorker: { + url: `${import.meta.env.BASE_URL}mockServiceWorker.js`, // base 경로 추가 + }, + onUnhandledRequest: "bypass", + }), + ); -// 앱 초기화 -async function initApp() { - // MSW 워커 시작 - await enableMocking(); -} +const main = () => { + store.initCart(); + + router.setup({ + "/": { + page: HomePage, + }, + "/product/:id": { + page: DetailPage, + }, + "*": { + page: NotFoundPage, + }, + }); -// 앱 시작 -initApp().catch(console.error); + // 라우터 변경 시에만 렌더링 + router.subscribe(render); +}; + +// 애플리케이션 시작 +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/pages/DetailPage.js b/src/pages/DetailPage.js new file mode 100644 index 00000000..06e5109e --- /dev/null +++ b/src/pages/DetailPage.js @@ -0,0 +1,159 @@ +import { ProductDetail } from "../components/index.js"; +import { openCartModal } from "../utils/CartModal.js"; +import { showToast } from "../utils/Toast.js"; +import { PageLayout } from "./PageLayout"; +import { store } from "../core/store.js"; +import { router } from "../core/router.js"; + +let renderFn = null; +let eventHandlers = []; + +export const DetailPage = { + productId: null, + + // 페이지 초기화 + init(render, props) { + console.log("🟢 DetailPage init 호출", props); + renderFn = render; + this.productId = props.productId; + + // detail state만 구독 + store.subscribe(renderFn, "detail.product"); + store.subscribe(renderFn, "detail.loading"); + store.subscribe(renderFn, "detail.error"); + store.subscribe(renderFn, "detail.relatedProducts"); + + store.subscribe(renderFn, "list.products"); + store.subscribe(renderFn, "cart.items"); + + this.setupEventListeners(); + this.loadProductAndRelated(); + }, + + // 상품 상세, 관련 상품 로드 + async loadProductAndRelated() { + await store.fetchProductDetail(this.productId); + + // 상품 상세 -> 관련 상품 가져오기 + const product = store.state.detail.product; + if (product && product.category2) { + await store.fetchProducts({ + category2: product.category2, + limit: 20, + }); + } + }, + + // 이벤트 위임 함수 + setupEventListeners() { + // 장바구니 아이콘 클릭 이벤트 + const cartIconHandler = (e) => { + if (e.target.closest("#cart-icon-btn")) { + console.log("장바구니 모달 열기"); + openCartModal(); + } + }; + document.addEventListener("click", cartIconHandler); + eventHandlers.push({ type: "click", handler: cartIconHandler }); + + // 장바구니 담기 버튼 클릭 이벤트 + const addToCartHandler = (e) => { + if (e.target.closest("#add-to-cart-btn")) { + const quantity = parseInt(document.getElementById("quantity-input").value); + const product = store.state.detail.product; + + if (product) { + // 장바구니에 추가 + store.addToCart(product, quantity); + showToast.success(`장바구니에 추가되었습니다`); + } + } + }; + document.addEventListener("click", addToCartHandler); + eventHandlers.push({ type: "click", handler: addToCartHandler }); + + // 수량 증가 & 감소 클릭 이벤트 + const quantityChangeHandler = (e) => { + if (e.target.closest("#quantity-decrease") || e.target.closest("#quantity-increase")) { + const quantityInput = document.getElementById("quantity-input"); + + if (!quantityInput) return; + + let quantity = parseInt(quantityInput.value) || 1; + + if (e.target.closest("#quantity-decrease")) { + quantity--; + } else if (e.target.closest("#quantity-increase")) { + quantity++; + } + quantityInput.value = Math.max(1, Math.min(quantity, 107)); + } + }; + document.addEventListener("click", quantityChangeHandler); + eventHandlers.push({ type: "click", handler: quantityChangeHandler }); + + // 관련 상품 카드 클릭 이벤트 + const relatedProductClickHandler = (e) => { + if (e.target.closest(".related-product-card")) { + const productId = e.target.closest(".related-product-card").dataset.productId; + router.navigate(`/product/${productId}`); + } + }; + document.addEventListener("click", relatedProductClickHandler); + eventHandlers.push({ type: "click", handler: relatedProductClickHandler }); + + // 카테고리 브레드크럼 이벤트 + const breadcrumbClickHandler = (e) => { + const breadcrumbLink = e.target.closest(".breadcrumb-link"); + if (breadcrumbLink) { + const category1 = breadcrumbLink.dataset.category1; + const category2 = breadcrumbLink.dataset.category2; + + if (category1 && !category2) { + router.navigate(`/?category1=${encodeURIComponent(category1)}`); + } else if (category1 && category2) { + router.navigate(`/?category1=${encodeURIComponent(category1)}&category2=${encodeURIComponent(category2)}`); + } + } + }; + document.addEventListener("click", breadcrumbClickHandler); + eventHandlers.push({ type: "click", handler: breadcrumbClickHandler }); + }, + + // 페이지 정리 + destroy() { + console.log("🔴 DetailPage destroy 호출"); + if (renderFn) { + store.unsubscribe(renderFn, "detail.product"); + store.unsubscribe(renderFn, "detail.loading"); + store.unsubscribe(renderFn, "detail.error"); + store.unsubscribe(renderFn, "detail.relatedProducts"); + store.unsubscribe(renderFn, "list.products"); + store.unsubscribe(renderFn, "cart.items"); + renderFn = null; + + // 이벤트 핸들러 해제 + eventHandlers.forEach(({ type, handler }) => { + document.removeEventListener(type, handler); + }); + eventHandlers = []; + } + }, + + // 렌더링 + render() { + console.log("🎨 DetailPage render 호출"); + const { loading, product } = store.state.detail; + const { products } = store.state.list; + return PageLayout({ + children: ` + ${ProductDetail({ loading, product, relatedProducts: products })} + `, + }); + }, + + // 렌더링 후 실행 + mounted() { + console.log("✨ DetailPage mounted 호출"); + }, +}; diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js new file mode 100644 index 00000000..4048b071 --- /dev/null +++ b/src/pages/HomePage.js @@ -0,0 +1,245 @@ +import { PageLayout } from "./PageLayout"; +import { ProductList, SearchForm } from "../components/index.js"; +import { showToast } from "../utils/Toast.js"; +import { openCartModal } from "../utils/CartModal.js"; +import { store } from "../core/store.js"; +import { router } from "../core/router.js"; + +let renderFn = null; +let eventHandlers = []; + +export const HomePage = { + // URL에서 필터 상태 초기화 + initFromURL() { + const category1 = router.getQueryParam("category1", ""); + const category2 = router.getQueryParam("category2", ""); + const search = router.getQueryParam("search", ""); + const limit = parseInt(router.getQueryParam("limit", "20")); + const sort = router.getQueryParam("sort", "price_asc"); + + if (category1) store.setState("list.category1", category1); + if (category2) store.setState("list.category2", category2); + if (search) store.setState("list.search", search); + if (limit !== 20) store.setState("list.limit", limit); + if (sort !== "price_asc") store.setState("list.sort", sort); + }, + + // Store state를 URL에 동기화 + syncToURL() { + const { category1, category2, search, limit, sort } = store.state.list; + + router.updateQueryParams({ + category1: category1 || null, + category2: category2 || null, + search: search || null, + limit: limit !== 20 ? limit : null, + sort: sort !== "price_asc" ? sort : null, + }); + }, + + // 페이지 초기화 + init(render) { + console.log("🟢 HomePage init 호출"); + renderFn = render; + + // 이 페이지에서 필요한 state만 구독 + store.subscribe(renderFn, "list.products"); + store.subscribe(renderFn, "list.loading"); + store.subscribe(renderFn, "list.error"); + store.subscribe(renderFn, "list.categories"); + store.subscribe(renderFn, "list.filters"); + store.subscribe(renderFn, "list.pagination"); + store.subscribe(renderFn, "list.category1"); + store.subscribe(renderFn, "list.category2"); + store.subscribe(renderFn, "list.limit"); + store.subscribe(renderFn, "list.sort"); + store.subscribe(renderFn, "list.search"); + store.subscribe(renderFn, "cart.items"); + + // URL에서 필터 상태 복원 + this.initFromURL(); + this.setupEventListeners(); + + // 초기 데이터 가져오기 + store.fetchProducts(); + store.fetchCategories(); + }, + + // 이벤트 위임 함수 + setupEventListeners() { + // 장바구니 아이콘 클릭 이벤트 + const cartIconHandler = (e) => { + if (e.target.closest("#cart-icon-btn")) { + console.log("장바구니 모달 열기"); + openCartModal(); + } + }; + document.addEventListener("click", cartIconHandler); + eventHandlers.push({ type: "click", handler: cartIconHandler }); + + // 장바구니 담기 버튼 클릭 이벤트 + const addToCartHandler = (e) => { + if (e.target.closest(".add-to-cart-btn")) { + e.stopPropagation(); // 상품 카드 클릭 이벤트 전파 방지 + const productId = e.target.closest(".add-to-cart-btn").dataset.productId; + const product = store.state.list.products.find((p) => p.productId === productId); + + if (product) { + // store를 통해 장바구니에 추가 (수량 1개) + store.addToCart(product, 1); + console.log("장바구니에 상품 추가:", productId); + showToast.success("장바구니에 추가되었습니다"); + } else { + console.error("상품을 찾을 수 없습니다:", productId); + showToast.error("상품 추가에 실패했습니다"); + } + } + }; + document.addEventListener("click", addToCartHandler); + eventHandlers.push({ type: "click", handler: addToCartHandler }); + + // 상품 카드 클릭 이벤트 (상품 이미지나 정보 클릭 시) + const clickHandler = (e) => { + // 장바구니 버튼이 아닌 경우에만 상세 페이지로 이동 + if (e.target.closest(".product-card") && !e.target.closest(".add-to-cart-btn")) { + const productId = e.target.closest(".product-card").dataset.productId; + router.navigate(`/product/${productId}`); + } + }; + document.addEventListener("click", clickHandler); + eventHandlers.push({ type: "click", handler: clickHandler }); + + // 전체 버튼 클릭 이벤트 - 카테고리 초기화 + const resetHandler = (e) => { + if (e.target.closest('[data-breadcrumb="reset"]')) { + store.setState("list.category1", ""); + store.setState("list.category2", ""); + store.fetchProducts(); + this.syncToURL(); + } + }; + document.addEventListener("click", resetHandler); + eventHandlers.push({ type: "click", handler: resetHandler }); + + // 카테고리 브레드 크럼블 이벤트 + const breadcrumbClickHandler = (e) => { + if (e.target.closest('[data-breadcrumb="category1"]')) { + const category = e.target.closest('[data-breadcrumb="category1"]').dataset.category1; + store.setState("list.category1", category); + store.setState("list.category2", ""); + store.fetchProducts(); + this.syncToURL(); + } + }; + document.addEventListener("click", breadcrumbClickHandler); + eventHandlers.push({ type: "click", handler: breadcrumbClickHandler }); + + // 카테고리 클릭 이벤트 (1depth) + const categoryClickHandler = (e) => { + if (e.target.closest(".category1-filter-btn")) { + const category = e.target.closest(".category1-filter-btn").dataset.category1; + store.setState("list.category1", category); + store.setState("list.category2", ""); // 초기화 + store.fetchProducts(); + this.syncToURL(); + } + }; + document.addEventListener("click", categoryClickHandler); + eventHandlers.push({ type: "click", handler: categoryClickHandler }); + + // 카테고리 클릭 이벤트 (2depth) + const category2ClickHandler = (e) => { + if (e.target.closest(".category2-filter-btn")) { + const category = e.target.closest(".category2-filter-btn").dataset.category2; + store.setState("list.category2", category); + store.fetchProducts(); + this.syncToURL(); + } + }; + document.addEventListener("click", category2ClickHandler); + eventHandlers.push({ type: "click", handler: category2ClickHandler }); + + // 개수 선택 이벤트 + const limitChangeHandler = (e) => { + if (e.target.id === "limit-select") { + const limit = parseInt(e.target.value, 10); + store.setState("list.limit", limit); + store.fetchProducts(); + this.syncToURL(); + } + }; + document.addEventListener("change", limitChangeHandler); + eventHandlers.push({ type: "change", handler: limitChangeHandler }); + + // 정렬 선택 이벤트 + const sortChangeHandler = (e) => { + if (e.target.id === "sort-select") { + const sort = e.target.value; + store.setState("list.sort", sort); + store.fetchProducts(); + this.syncToURL(); + } + }; + document.addEventListener("change", sortChangeHandler); + eventHandlers.push({ type: "change", handler: sortChangeHandler }); + + // 검색 이벤트 (엔터 키) + const searchKeydownHandler = (e) => { + if (e.target.id === "search-input" && e.key === "Enter") { + const keyword = e.target.value.trim(); + store.setState("list.search", keyword); + store.fetchProducts(); + this.syncToURL(); + } + }; + document.addEventListener("keydown", searchKeydownHandler); + eventHandlers.push({ type: "keydown", handler: searchKeydownHandler }); + }, + + // 페이지 정리 (다른 페이지로 이동 시 실행) + destroy() { + console.log("🔴 HomePage destroy 호출"); + + // store 구독 해제 + if (renderFn) { + store.unsubscribe(renderFn, "list.products"); + store.unsubscribe(renderFn, "list.loading"); + store.unsubscribe(renderFn, "list.error"); + store.unsubscribe(renderFn, "list.categories"); + store.unsubscribe(renderFn, "list.filters"); + store.unsubscribe(renderFn, "list.pagination"); + store.unsubscribe(renderFn, "list.category1"); + store.unsubscribe(renderFn, "list.category2"); + store.unsubscribe(renderFn, "list.limit"); + store.unsubscribe(renderFn, "list.sort"); + store.unsubscribe(renderFn, "list.search"); + store.unsubscribe(renderFn, "cart.items"); + renderFn = null; + + // 이벤트 핸들러 해제 + eventHandlers.forEach(({ type, handler }) => { + document.removeEventListener(type, handler); + }); + eventHandlers = []; + } + }, + + // 렌더링 (state 변경 시마다 실행) + render() { + console.log("🎨 HomePage render 호출"); + const { loading, products, filters, pagination, categories, category1, category2, limit, sort, search } = + store.state.list; + return PageLayout({ + children: ` + ${SearchForm({ loading, filters, pagination, categories, category1, category2, limit, sort, search })} + ${ProductList({ loading, products })} + `, + }); + }, + + // 렌더링 후 실행 (이벤트 리스너 등록) + mounted() { + console.log("✨ HomePage mounted 호출"); + // 여기는 뭐해야 되니 + }, +}; diff --git a/src/pages/NotFoundPage.js b/src/pages/NotFoundPage.js new file mode 100644 index 00000000..343cc5a2 --- /dev/null +++ b/src/pages/NotFoundPage.js @@ -0,0 +1,36 @@ +export const NotFoundPage = { + render() { + return /* html */ ` +
+ + + + + + + + + + + + + 404 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+ `; + }, +}; diff --git a/src/pages/PageLayout.js b/src/pages/PageLayout.js new file mode 100644 index 00000000..1e738530 --- /dev/null +++ b/src/pages/PageLayout.js @@ -0,0 +1,14 @@ +import { Footer } from "../components/Footer.js"; +import { Header } from "../components/Header.js"; + +export const PageLayout = ({ children }) => { + return /* html */ ` +
+ ${Header()} +
+ ${children} +
+ ${Footer()} +
+ `; +}; diff --git a/src/pages/index.js b/src/pages/index.js new file mode 100644 index 00000000..0057de18 --- /dev/null +++ b/src/pages/index.js @@ -0,0 +1,4 @@ +export * from "./DetailPage"; +export * from "./HomePage"; +export * from "./NotFoundPage"; +export * from "./PageLayout"; 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/src/utils/CartModal.js b/src/utils/CartModal.js new file mode 100644 index 00000000..dadaa517 --- /dev/null +++ b/src/utils/CartModal.js @@ -0,0 +1,355 @@ +import { store } from "../core/store.js"; +import { showToast } from "./Toast.js"; + +const EmptyCart = /* html */ ` + +
+
+
+ + + +
+

장바구니가 비어있습니다

+

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

+
+
+`; + +const CartItems = ({ productId, image, title, lprice, quantity }) => { + return /* html */ ` + +
+ + + +
+ ${title} +
+ +
+

+ ${title} +

+

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

+ +
+ + + +
+
+ +
+

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

+ +
+
+ `; +}; + +const CartFooter = ({ total }) => { + return /* html */ ` + +
+ + +
+ 총 금액 + ${Number(total).toLocaleString()}원 +
+ +
+ +
+ + +
+
+
+ `; +}; + +export const CartModal = ({ items, total }) => { + return /* html */ ` +
+
+ +
+

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

+ + +
+ + +
+ ${ + items.length > 0 + ? /* html */ ` +
+ +
+
+
+ ${items.map(CartItems).join("")} +
+
+ ${CartFooter({ total })} + ` + : EmptyCart + } +
+
+
+ `; +}; + +// 모달 관리 +let modalContainer = null; +let modalOverlay = null; +let escKeyHandler = null; + +const createModalContainer = () => { + // 오버레이 생성 + modalOverlay = document.createElement("div"); + modalOverlay.id = "cart-modal-overlay"; + modalOverlay.className = "fixed inset-0 bg-black bg-opacity-50 z-50 modal-overlay"; + modalOverlay.style.cssText = "display: none;"; + + // 모달 컨테이너 생성 + modalContainer = document.createElement("div"); + modalContainer.id = "cart-modal-container"; + modalContainer.className = "fixed inset-0 z-50"; + modalContainer.style.cssText = "display: none;"; + + document.body.appendChild(modalOverlay); + document.body.appendChild(modalContainer); +}; + +// 모달 열기 +export const openCartModal = () => { + if (!modalContainer) { + createModalContainer(); + } + const { items, total } = store.state.cart; + + // 모달 렌더링 + modalContainer.innerHTML = CartModal({ items, total }); + modalContainer.style.display = "block"; + modalOverlay.style.display = "block"; + document.body.style.overflow = "hidden"; + + setupModalEvents(); + + // 배경 클릭 시 모달 닫기 + modalContainer.addEventListener("click", (e) => { + const modalContent = e.target.closest(".relative"); + if (!modalContent) { + closeCartModal(); + } + }); + + // ESC 키로 모달 닫기 + escKeyHandler = (e) => { + if (e.key === "Escape") { + closeCartModal(); + } + }; + document.addEventListener("keydown", escKeyHandler); +}; + +// 선택된 체크박스 개수 업데이트 +const updateSelectedCount = () => { + const checkboxes = document.querySelectorAll(".cart-item-checkbox"); + const selectedCount = Array.from(checkboxes).filter((cb) => cb.checked).length; + + const removeBtn = document.getElementById("cart-modal-remove-selected-btn"); + const removeText = document.getElementById("remove-selected-text"); + + if (removeBtn && removeText) { + removeText.textContent = `선택한 상품 삭제 (${selectedCount}개)`; + removeBtn.style.display = selectedCount > 0 ? "block" : "none"; + } +}; + +// 모달 내부 이벤트 리스너 설정 +const setupModalEvents = () => { + // 닫기 버튼 + const closeBtn = document.getElementById("cart-modal-close-btn"); + if (closeBtn) { + closeBtn.addEventListener("click", closeCartModal); + } + + // 전체선택 체크박스 + const selectAllCheckbox = document.getElementById("cart-modal-select-all-checkbox"); + if (selectAllCheckbox) { + selectAllCheckbox.addEventListener("change", (e) => { + const isChecked = e.target.checked; + document.querySelectorAll(".cart-item-checkbox").forEach((checkbox) => { + checkbox.checked = isChecked; + }); + updateSelectedCount(); + }); + } + + // 개별 체크박스 + document.querySelectorAll(".cart-item-checkbox").forEach((checkbox) => { + checkbox.addEventListener("change", () => { + // 전체선택 체크박스 상태 업데이트 + const allCheckboxes = document.querySelectorAll(".cart-item-checkbox"); + const allChecked = Array.from(allCheckboxes).every((cb) => cb.checked); + const selectAllCheckbox = document.getElementById("cart-modal-select-all-checkbox"); + if (selectAllCheckbox) { + selectAllCheckbox.checked = allChecked; + } + updateSelectedCount(); + }); + }); + + // 선택한 상품 삭제 버튼 + const removeSelectedBtn = document.getElementById("cart-modal-remove-selected-btn"); + if (removeSelectedBtn) { + removeSelectedBtn.addEventListener("click", () => { + const selectedCheckboxes = document.querySelectorAll(".cart-item-checkbox:checked"); + const selectedProductIds = Array.from(selectedCheckboxes).map((cb) => cb.dataset.productId); + + if (selectedProductIds.length > 0) { + selectedProductIds.forEach((productId) => { + store.removeFromCart(productId); + }); + showToast.info(`선택된 상품들이 삭제되었습니다`); + openCartModal(); + } + }); + } + + // 수량 증가 버튼들 + document.querySelectorAll(".quantity-increase-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + const productId = e.currentTarget.dataset.productId; + const currentItem = store.state.cart.items.find((item) => item.productId === productId); + + if (currentItem) { + store.updateCartQuantity(productId, currentItem.quantity + 1); + openCartModal(); + } + }); + }); + + // 수량 감소 버튼들 + document.querySelectorAll(".quantity-decrease-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + const productId = e.currentTarget.dataset.productId; + const currentItem = store.state.cart.items.find((item) => item.productId === productId); + + if (currentItem) { + if (currentItem.quantity > 1) { + store.updateCartQuantity(productId, currentItem.quantity - 1); + } + openCartModal(); + } + }); + }); + + // 삭제 버튼들 + document.querySelectorAll(".cart-item-remove-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + const productId = e.currentTarget.dataset.productId; + + store.removeFromCart(productId); + openCartModal(); + }); + }); + + // 전체 비우기 버튼 + const clearCartBtn = document.getElementById("cart-modal-clear-cart-btn"); + if (clearCartBtn) { + clearCartBtn.addEventListener("click", () => { + store.clearCart(); + showToast.info("장바구니가 비워졌습니다"); + openCartModal(); + }); + } + + // 구매하기 버튼 + const checkoutBtn = document.getElementById("cart-modal-checkout-btn"); + if (checkoutBtn) { + checkoutBtn.addEventListener("click", () => { + showToast.info("구매 기능은 추후 구현 예정입니다"); + }); + } + + // 상품 이미지/제목 클릭 시 상세 페이지로 이동 + document.querySelectorAll(".cart-item-image, .cart-item-title").forEach((element) => { + element.addEventListener("click", (e) => { + const productId = e.currentTarget.dataset.productId; + closeCartModal(); + import("../core/router.js").then(({ router }) => { + router.navigate(`/product/${productId}`); + }); + }); + }); +}; + +// 모달 닫기 +export const closeCartModal = () => { + if (modalContainer) { + modalContainer.style.display = "none"; + modalContainer.innerHTML = ""; + } + if (modalOverlay) { + modalOverlay.style.display = "none"; + } + + // ESC 키 이벤트 리스너 제거 + if (escKeyHandler) { + document.removeEventListener("keydown", escKeyHandler); + escKeyHandler = null; + } + + // body 스크롤 복원 + document.body.style.overflow = ""; +}; diff --git a/src/utils/Toast.js b/src/utils/Toast.js new file mode 100644 index 00000000..e814f64e --- /dev/null +++ b/src/utils/Toast.js @@ -0,0 +1,100 @@ +// 토스트 컴포넌트 +const ToastComponent = ({ message, type = "success" }) => { + const configs = { + success: { + bgColor: "bg-green-600", + icon: /* html */ ` + + + + `, + }, + info: { + bgColor: "bg-blue-600", + icon: /* html */ ` + + + + `, + }, + error: { + bgColor: "bg-red-600", + icon: /* html */ ` + + + + `, + }, + }; + + const config = configs[type] || configs.success; + + return /* html */ ` +
+
+ ${config.icon} +
+

${message}

+ +
+ `; +}; + +// 토스트 컨테이너 관리 +let toastContainer = null; + +const initToastContainer = () => { + if (!toastContainer) { + toastContainer = document.createElement("div"); + toastContainer.id = "toast-container"; + toastContainer.className = "fixed bottom-20 left-1/2 transform -translate-x-1/2 z-50 flex flex-col gap-2"; + toastContainer.style.cssText = "pointer-events: none;"; + document.body.appendChild(toastContainer); + } + return toastContainer; +}; + +// 토스트 제거 함수 +const removeToast = (toastElement) => { + if (toastElement && toastElement.parentNode) { + toastElement.style.opacity = "0"; + toastElement.style.transform = "translateY(-20px)"; + toastElement.style.transition = "all 0.3s ease-out"; + + setTimeout(() => { + if (toastElement.parentNode) { + toastElement.remove(); + } + }, 300); + } +}; + +// 토스트 표시 함수 +const createToast = (message, type = "success") => { + const container = initToastContainer(); + + // 토스트 생성 + const toastWrapper = document.createElement("div"); + toastWrapper.style.cssText = "pointer-events: auto;"; + toastWrapper.innerHTML = ToastComponent({ message, type }); + + container.appendChild(toastWrapper); + + // 닫기 버튼 이벤트 + const closeBtn = toastWrapper.querySelector(".toast-close-btn"); + if (closeBtn) { + closeBtn.addEventListener("click", () => removeToast(toastWrapper)); + } + + setTimeout(() => removeToast(toastWrapper), 3000); +}; + +export const showToast = { + success: (message) => createToast(message, "success"), + info: (message) => createToast(message, "info"), + error: (message) => createToast(message, "error"), +}; diff --git a/src/utils/cart.js b/src/utils/cart.js new file mode 100644 index 00000000..8d18aad9 --- /dev/null +++ b/src/utils/cart.js @@ -0,0 +1,95 @@ +// localStorage 키 +const CART_STORAGE_KEY = "shopping_cart"; + +// localStorage에서 장바구니 데이터 불러오기 +export const loadCartFromStorage = () => { + try { + const cartData = localStorage.getItem(CART_STORAGE_KEY); + return cartData ? JSON.parse(cartData) : []; + } catch (error) { + console.error("장바구니 데이터 로드 실패:", error); + return []; + } +}; + +// localStorage에 장바구니 데이터 저장하기 +export const saveCartToStorage = (cartItems) => { + try { + localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cartItems)); + console.log("✅ 장바구니 저장 완료:", cartItems); + } catch (error) { + console.error("장바구니 데이터 저장 실패:", error); + } +}; + +// 장바구니에 상품 추가 +export const addToCart = (product, quantity = 1) => { + const cart = loadCartFromStorage(); + + // 이미 장바구니에 있는 상품인지 확인 + const existingItemIndex = cart.findIndex((item) => item.productId === product.productId); + + if (existingItemIndex > -1) { + // 이미 있으면 수량만 증가 + cart[existingItemIndex].quantity += quantity; + console.log(`📦 기존 상품 수량 증가: ${product.title} (${cart[existingItemIndex].quantity}개)`); + } else { + // 없으면 새로 추가 + cart.push({ + ...product, + quantity, + }); + console.log(`✨ 새 상품 추가: ${product.title} (${quantity}개)`); + } + + saveCartToStorage(cart); + return cart; +}; + +// 장바구니 상품 수량 변경 +export const updateCartItemQuantity = (productId, quantity) => { + const cart = loadCartFromStorage(); + const itemIndex = cart.findIndex((item) => item.productId === productId); + + if (itemIndex > -1) { + if (quantity <= 0) { + // 수량이 0 이하면 삭제 + cart.splice(itemIndex, 1); + console.log(`🗑️ 상품 제거: ${productId}`); + } else { + cart[itemIndex].quantity = quantity; + console.log(`🔄 수량 변경: ${productId} -> ${quantity}개`); + } + } + + saveCartToStorage(cart); + return cart; +}; + +// 장바구니에서 상품 제거 +export const removeFromCart = (productId) => { + const cart = loadCartFromStorage(); + const filteredCart = cart.filter((item) => item.productId !== productId); + saveCartToStorage(filteredCart); + console.log(`🗑️ 상품 제거: ${productId}`); + return filteredCart; +}; + +// 장바구니 비우기 +export const clearCart = () => { + saveCartToStorage([]); + console.log("🗑️ 장바구니 전체 비우기"); + return []; +}; + +// 장바구니 총 금액 계산 +export const getCartTotal = (cart) => { + return cart.reduce((total, item) => { + return total + item.lprice * item.quantity; + }, 0); +}; + +// 장바구니 총 상품 개수 +export const getCartItemCount = (cart) => { + return cart.reduce((count, item) => count + item.quantity, 0); +}; diff --git a/vite.config.js b/vite.config.js index ced41c4c..66095814 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,10 +1,19 @@ import { defineConfig } from "vitest/config"; -export default defineConfig({ +export default defineConfig(({ command }) => ({ test: { globals: true, environment: "jsdom", setupFiles: "./src/setupTests.js", exclude: ["**/e2e/**", "**/*.e2e.spec.js", "**/node_modules/**"], + poolOptions: { + threads: { + singleThread: true, + }, + }, }, -}); + base: command === "build" ? "/front_7th_chapter2-1/" : "/", // 빌드할 때만 base 경로 적용 + build: { + outDir: "dist", + }, +}));