diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f36c3c4b..c869e8ad 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 페이지 표시 +#### 상품목록 + +**상품 목록 로딩** + +- [x] 페이지 접속 시 로딩 상태가 표시된다 +- [x] 데이터 로드 완료 후 상품 목록이 렌더링된다 +- [x] 로딩 실패 시 에러 상태가 표시된다 +- [x] 에러 발생 시 재시도 버튼이 제공된다 + +**상품 목록 조회** + +- [x] 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 + +**한 페이지에 보여질 상품 수 선택** + +- [x] 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. +- [x] 선택 변경 시 즉시 목록에 반영된다 + +**상품 정렬 기능** + +- [x] 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다. +- [x] 드롭다운을 통해 정렬 기준을 선택할 수 있다 +- [x] 정렬 변경 시 즉시 목록에 반영된다 + +**무한 스크롤 페이지네이션** + +- [x] 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 +- [x] 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 +- [x] 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 +- [x] 홈 페이지에서만 무한 스크롤이 활성화된다 + +**상품을 장바구니에 담기** + +- [x] 각 상품에 장바구니 추가 버튼이 있다 +- [x] 버튼 클릭 시 해당 상품이 장바구니에 추가된다 +- [x] 추가 완료 시 사용자에게 알림이 표시된다 + +**상품 검색** + +- [x] 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- [x] 검색 버튼 클릭으로 검색이 수행된다 +- [x] Enter 키로 검색이 수행된다 +- [x] 검색어와 일치하는 상품들만 목록에 표시된다 + +**카테고리 선택** + +- [x] 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- [x] 선택된 카테고리에 해당하는 상품들만 표시된다 +- [x] 전체 상품 보기로 돌아갈 수 있다 +- [x] 2단계 카테고리 구조를 지원한다 (1depth, 2depth) + +**카테고리 네비게이션** + +- [x] 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- [x] 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- [x] "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 + +**현재 상품 수 표시** + +- [x] 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- [x] 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 + +#### 장바구니 + +**장바구니 모달** + +- [x] 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- [x] X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- [x] ESC 키로 모달을 닫을 수 있다 +- [x] 모달에서 장바구니의 모든 기능을 사용할 수 있다 + +**장바구니 수량 조절** + +- [x] 각 장바구니 상품의 수량을 증가할 수 있다 +- [x] 각 장바구니 상품의 수량을 감소할 수 있다 +- [x] 수량 변경 시 총 금액이 실시간으로 업데이트된다 + +**장바구니 삭제** + +- [x] 각 상품에 삭제 버튼이 배치되어 있다 +- [x] 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 + +**장바구니 선택 삭제** + +- [x] 각 상품에 선택을 위한 체크박스가 제공된다 +- [x] 선택 삭제 버튼이 있다 +- [x] 체크된 상품들만 일괄 삭제된다 + +**장바구니 전체 선택** + +- [x] 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- [x] 전체 선택 시 모든 상품의 체크박스가 선택된다 +- [x] 전체 해제 시 모든 상품의 체크박스가 해제된다 + +**장바구니 비우기** + +- [x] 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 + +#### 상품 상세 + +**상품 클릭시 상세 페이지 이동** + +- [x] 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- [x] URL이 `/product/{productId}` 형태로 변경된다 +- [x] 상품의 자세한 정보가 전용 페이지에서 표시된다 + +**상품 상세 페이지 기능** + +- [x] 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- [x] 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 + +**상품 상세 - 장바구니 담기** + +- [x] 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- [x] 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- [x] 수량 증가/감소 버튼이 제공된다 + +**관련 상품 기능** + +- [x] 상품 상세 페이지에서 관련 상품들이 표시된다 +- [x] 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- [x] 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- [x] 현재 보고 있는 상품은 관련 상품에서 제외된다 + +**상품 상세 페이지 내 네비게이션** + +- [x] 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- [x] 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- [x] SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 + +#### 사용자 피드백 시스템 + +**토스트 메시지** + +- [x] 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- [x] 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- [x] 토스트는 3초 후 자동으로 사라진다 +- [x] 토스트에 닫기 버튼이 제공된다 +- [x] 토스트 타입별로 다른 스타일이 적용된다 (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..d51eb5a4 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "front-6th-chapter1-1", + "name": "front-chapter1-1", "private": true, "version": "0.0.0", "type": "module", @@ -10,15 +10,15 @@ "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", - "prepare": "husky" + "prepare": "husky", + "predeploy": "pnpm run build", + "deploy": "gh-pages -d dist" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -28,13 +28,16 @@ }, "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", "eslint-plugin-prettier": "^5.2.1", + "gh-pages": "^6.3.0", "globals": "^15.13.0", "husky": "^9.1.7", "jsdom": "^25.0.1", @@ -42,11 +45,12 @@ "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 + }, + "homepage": "https://toeam.github.io/front-7th-chapter2-1" +} 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..60f8a7c5 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 @@ -32,6 +38,9 @@ importers: eslint-plugin-prettier: specifier: ^5.2.1 version: 5.2.3(eslint-config-prettier@9.1.0(eslint@9.23.0))(eslint@9.23.0)(prettier@3.5.3) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 globals: specifier: ^15.13.0 version: 15.15.0 @@ -54,14 +63,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 +82,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 +534,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'} @@ -510,6 +562,18 @@ packages: '@napi-rs/wasm-runtime@0.2.11': resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -526,12 +590,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 +784,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 +805,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 +831,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/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@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 +851,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: @@ -824,10 +913,20 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.3: + resolution: {integrity: sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -837,6 +936,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'} @@ -907,6 +1009,9 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -938,6 +1043,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==} @@ -960,6 +1074,10 @@ packages: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -970,12 +1088,21 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + 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 +1119,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==} @@ -1017,6 +1144,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1093,8 +1224,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: @@ -1103,12 +1234,19 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fdir@6.4.3: resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} peerDependencies: @@ -1132,10 +1270,26 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1147,10 +1301,18 @@ 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'} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1184,10 +1346,23 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + gh-pages@6.3.0: + resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==} + engines: {node: '>=10'} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-parent@6.0.2: 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'} @@ -1196,10 +1371,17 @@ packages: resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.11.0: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -1227,6 +1409,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 +1486,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 @@ -1326,6 +1533,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1410,6 +1620,10 @@ packages: resolution: {integrity: sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==} engines: {node: '>=18.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1427,6 +1641,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 +1654,17 @@ 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@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + 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'} @@ -1444,6 +1672,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1471,6 +1703,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'} @@ -1522,14 +1762,29 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + 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 +1804,23 @@ 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==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + 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 +1841,17 @@ packages: engines: {node: '>=0.10'} hasBin: true - playwright-core@1.51.1: - resolution: {integrity: sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + 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 @@ -1616,6 +1886,9 @@ packages: querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -1641,6 +1914,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -1699,6 +1976,9 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1706,6 +1986,15 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + 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'} @@ -1725,6 +2014,10 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + slice-ansi@5.0.0: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} @@ -1744,8 +2037,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 +2051,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 +2079,13 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1793,6 +2097,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 +2115,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: @@ -1846,6 +2158,10 @@ packages: resolution: {integrity: sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==} engines: {node: '>=18'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1865,15 +2181,19 @@ packages: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 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 +2227,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 +2297,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 +2353,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 +2372,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 +2663,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 @@ -2334,6 +2704,18 @@ snapshots: '@tybys/wasm-util': 0.9.0 optional: true + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -2347,11 +2729,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 +2869,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 +2885,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 +2925,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 +2954,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 +2962,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 @@ -2591,8 +3013,18 @@ snapshots: aria-query@5.3.2: {} + array-union@2.1.0: {} + 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 + + async@3.2.6: {} + asynckit@0.4.0: {} balanced-match@1.0.2: {} @@ -2602,6 +3034,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 +3056,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: @@ -2668,6 +3104,8 @@ snapshots: commander@13.1.0: {} + commondir@1.0.1: {} + concat-map@0.0.1: {} cookie@0.7.2: {} @@ -2694,6 +3132,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: {} @@ -2706,6 +3148,10 @@ snapshots: detect-libc@2.0.4: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -2716,10 +3162,16 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + + email-addresses@5.0.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 +3180,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: @@ -2798,6 +3250,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} eslint-config-prettier@9.1.0(eslint@9.23.0): @@ -2898,16 +3352,28 @@ 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: {} fast-diff@1.3.0: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + fdir@6.4.3(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -2922,10 +3388,29 @@ snapshots: dependencies: flat-cache: 4.0.1 + filename-reserved-regex@2.0.0: {} + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2938,6 +3423,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 @@ -2945,6 +3435,12 @@ snapshots: es-set-tostringtag: 2.1.0 mime-types: 2.1.35 + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fsevents@2.3.2: optional: true @@ -2977,16 +3473,50 @@ snapshots: get-stream@8.0.1: {} + gh-pages@6.3.0: + dependencies: + async: 3.2.6 + commander: 13.1.0 + email-addresses: 5.0.0 + filenamify: 4.3.0 + find-cache-dir: 3.3.2 + fs-extra: 11.3.2 + globby: 11.1.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-parent@6.0.2: 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: {} + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphql@16.11.0: {} has-flag@4.0.0: {} @@ -3007,6 +3537,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 +3596,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 @@ -3104,6 +3665,12 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3184,6 +3751,10 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3202,6 +3773,8 @@ snapshots: loupe@3.1.3: {} + loupe@3.1.4: {} + lru-cache@10.4.3: {} lz-string@1.5.0: {} @@ -3210,10 +3783,26 @@ 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@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + math-intrinsics@1.1.0: {} merge-stream@2.0.0: {} + merge2@1.4.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -3235,6 +3824,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: {} @@ -3293,14 +3888,26 @@ snapshots: outvariant@1.4.3: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3315,10 +3922,19 @@ 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: {} + path-type@4.0.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.0: {} picocolors@1.1.1: {} @@ -3329,11 +3945,15 @@ snapshots: pidtree@0.6.0: {} - playwright-core@1.51.1: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + 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 @@ -3365,6 +3985,8 @@ snapshots: querystringify@2.2.0: {} + queue-microtask@1.2.3: {} + react-is@17.0.2: {} redent@3.0.0: @@ -3385,6 +4007,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + reusify@1.1.0: {} + rfdc@1.4.1: {} rolldown-vite@6.3.21(esbuild@0.25.1)(yaml@2.7.0): @@ -3450,12 +4074,20 @@ snapshots: rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safer-buffer@2.1.2: {} saxes@6.0.0: dependencies: xmlchars: 2.2.0 + semver@6.3.1: {} + + semver@7.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3472,6 +4104,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + slash@3.0.0: {} + slice-ansi@5.0.0: dependencies: ansi-styles: 6.2.1 @@ -3488,7 +4122,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.8.1: {} + std-env@3.9.0: {} strict-event-emitter@0.5.1: {} @@ -3500,6 +4134,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 +4162,14 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3533,6 +4181,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 +4201,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: {} @@ -3580,6 +4236,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + tslib@2.8.1: {} type-check@0.4.0: @@ -3592,6 +4252,8 @@ snapshots: universalify@0.2.0: {} + universalify@2.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -3601,12 +4263,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 +4290,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 +4369,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + wrap-ansi@9.0.0: dependencies: ansi-styles: 6.2.1 diff --git a/requirement.md b/requirement.md new file mode 100644 index 00000000..d450ef17 --- /dev/null +++ b/requirement.md @@ -0,0 +1,190 @@ +# 요구사항 명세서 + +## 상품목록 + +### 상품 목록 로딩 + +- 페이지 접속 시 로딩 상태가 표시된다 +- 데이터 로드 완료 후 상품 목록이 렌더링된다 +- 로딩 실패 시 에러 상태가 표시된다 +- 에러 발생 시 재시도 버튼이 제공된다 + +### 상품 목록 조회 + +- 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 + +### 한 페이지에 보여질 상품 수 선택 + +- 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. +- 선택 변경 시 즉시 목록에 반영된다 + +### 상품 정렬 기능 + +- 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다. +- 드롭다운을 통해 정렬 기준을 선택할 수 있다 +- 정렬 변경 시 즉시 목록에 반영된다 + +### 무한 스크롤 페이지네이션 + +- 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 +- 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 +- 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 +- 홈 페이지에서만 무한 스크롤이 활성화된다 + +### 상품을 장바구니에 담기 + +- 각 상품에 장바구니 추가 버튼이 있다 +- 버튼 클릭 시 해당 상품이 장바구니에 추가된다 +- 추가 완료 시 사용자에게 알림이 표시된다 + +### 상품 검색 + +- 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- 검색 버튼 클릭으로 검색이 수행된다 +- Enter 키로 검색이 수행된다 +- 검색어와 일치하는 상품들만 목록에 표시된다 + +### 카테고리 선택 + +- 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- 선택된 카테고리에 해당하는 상품들만 표시된다 +- 전체 상품 보기로 돌아갈 수 있다 +- 2단계 카테고리 구조를 지원한다 (1depth, 2depth) + +### 카테고리 네비게이션 + +- 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 + +### 현재 상품 수 표시 + +- 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 + +## 장바구니 + +### 장바구니 모달 + +- 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- ESC 키로 모달을 닫을 수 있다 +- 모달에서 장바구니의 모든 기능을 사용할 수 있다 + +### 장바구니 수량 조절 + +- 각 장바구니 상품의 수량을 증가할 수 있다 +- 각 장바구니 상품의 수량을 감소할 수 있다 +- 수량 변경 시 총 금액이 실시간으로 업데이트된다 + +### 장바구니 삭제 + +- 각 상품에 삭제 버튼이 배치되어 있다 +- 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 + +### 장바구니 선택 삭제 + +- 각 상품에 선택을 위한 체크박스가 제공된다 +- 선택 삭제 버튼이 있다 +- 체크된 상품들만 일괄 삭제된다 + +### 장바구니 전체 선택 + +- 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- 전체 선택 시 모든 상품의 체크박스가 선택된다 +- 전체 해제 시 모든 상품의 체크박스가 해제된다 + +### 장바구니 비우기 + +- 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 + +## 상품 상세 + +### 상품 클릭시 상세 페이지 이동 + +- 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- URL이 `/product/{productId}` 형태로 변경된다 +- 상품의 자세한 정보가 전용 페이지에서 표시된다 + +### 상품 상세 페이지 기능 + +- 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 + +### 상품 상세 - 장바구니 담기 + +- 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- 수량 증가/감소 버튼이 제공된다 + +### 관련 상품 기능 + +- 상품 상세 페이지에서 관련 상품들이 표시된다 +- 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- 현재 보고 있는 상품은 관련 상품에서 제외된다 + +### 상품 상세 페이지 내 네비게이션 + +- 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 + +## 사용자 피드백 시스템 + +### 토스트 메시지 + +- 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- 토스트는 3초 후 자동으로 사라진다 +- 토스트에 닫기 버튼이 제공된다 +- 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) + +### 에러 처리 + +- 네트워크 오류 등 에러 발생 시 사용자에게 적절한 메시지가 표시된다 +- 에러 상황에서 재시도할 수 있는 버튼이 제공된다 +- 에러 상태가 UI에 적절히 반영된다 + +## SPA 네비게이션 및 URL 관리 + +### 페이지 이동 + +- 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. + +### 상품 목록 - URL 쿼리 반영 + +- 검색어가 URL 쿼리 파라미터에 저장된다 +- 카테고리 선택이 URL 쿼리 파라미터에 저장된다 +- 상품 옵션이 URL 쿼리 파라미터에 저장된다 +- 정렬 조건이 URL 쿼리 파라미터에 저장된다 +- 조건 변경 시 URL이 자동으로 업데이트된다 +- URL을 통해 현재 검색/필터 상태를 공유할 수 있다 + +### 상품 목록 - 새로고침 시 상태 유지 + +- 새로고침 후 URL 쿼리에서 검색어가 복원된다 +- 새로고침 후 URL 쿼리에서 카테고리가 복원된다 +- 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 +- 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 +- 복원된 조건에 맞는 상품 데이터가 다시 로드된다 + +### 장바구니 - 새로고침 시 데이터 유지 + +- 장바구니 내용이 브라우저에 저장된다 +- 새로고침 후에도 이전 장바구니 내용이 유지된다 +- 장바구니의 선택 상태도 함께 유지된다 + +### 상품 상세 - URL에 ID 반영 + +- 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) +- URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 + +### 상품 상세 - 새로고침시 유지 + +- 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 + +### 404 페이지 + +- 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 +- 홈으로 돌아가기 버튼이 제공된다 diff --git a/src/api/productApi.js b/src/api/productApi.js new file mode 100644 index 00000000..be612402 --- /dev/null +++ b/src/api/productApi.js @@ -0,0 +1,29 @@ +// 상품 목록 조회 +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/CartModal.js b/src/components/CartModal.js new file mode 100644 index 00000000..4b0043a6 --- /dev/null +++ b/src/components/CartModal.js @@ -0,0 +1,154 @@ +const cartModalContent = (cartList) => { + console.log(cartList); + + if (cartList && cartList.length > 0) { + return ` +
+ +
+ +
+ +
+
+ ${cartList + .map((item) => { + return ` +
+ + + +
+ ${item.title} +
+ +
+

+ ${item.title} +

+

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

+ +
+ + + +
+
+ +
+

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

+ +
+
+ `; + }) + .join("")} +
+
+
+ +
+ + ${ + cartList.filter((item) => item.selected).length > 0 + ? ` +
+ 선택한 상품 (${cartList.filter((item) => item.selected).length}개) + ${cartList + .filter((item) => item.selected) + .reduce((total, item) => total + item.lprice * item.quantity, 0) + .toLocaleString()}원 +
+ ` + : "" + } + +
+ 총 금액 + ${cartList.reduce((total, item) => total + item.lprice * item.quantity, 0).toLocaleString()}원 +
+ +
+ +
+ + +
+
+
+ `; + } + + return ` +
+ +
+
+
+ + + +
+

장바구니가 비어있습니다

+

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

+
+
+
+`; +}; + +export const CartModal = (cartList) => { + return ` +
+
+ +
+

+ + + + 장바구니 +

+ + +
+ + + ${cartModalContent(cartList)} +
+
+ `; +}; diff --git a/src/components/CartModalHandler.js b/src/components/CartModalHandler.js new file mode 100644 index 00000000..6f8854ea --- /dev/null +++ b/src/components/CartModalHandler.js @@ -0,0 +1,240 @@ +import { CartModal } from "./CartModal"; +import { cartStore } from "../store/cartStore"; +import { Toast } from "./Toast"; +/** + * 장바구니 모달 컨트롤러 + * 모달 열기/닫기 및 이벤트 리스너 관리 + */ +const createCartModalHandler = () => { + /** + * 모달 열기 + */ + const open = () => { + const $modalContainer = document.querySelector("#cart-modal-container"); + const cartList = getCartList(); + if ($modalContainer) { + // 모달이 이미 있으면 최신 상태로 업데이트 + const $modalContent = $modalContainer.querySelector("#cart-modal-dialog"); + if ($modalContent) { + $modalContent.outerHTML = CartModal(cartList); + } + $modalContainer.classList.remove("hidden"); + document.body.style.overflow = "hidden"; + modalEventListeners(); + } else { + // 모달이 없으면 생성 + const modalHTML = ` +
+
+ ${CartModal(cartList)} +
+ `; + + const $root = document.querySelector("#root"); + if ($root) { + $root.insertAdjacentHTML("beforeend", modalHTML); + } else { + document.body.insertAdjacentHTML("beforeend", modalHTML); + } + document.body.style.overflow = "hidden"; + modalEventListeners(); + } + }; + + /** + * 모달 닫기 + */ + const close = () => { + const $modalContainer = document.querySelector("#cart-modal-container"); + if ($modalContainer) { + $modalContainer.classList.add("hidden"); + document.body.style.overflow = ""; + } + }; + + /** + * 모달 이벤트 리스너 초기화 + */ + const init = () => { + // 클릭 이벤트 리스너 + document.body.addEventListener("click", (e) => { + const $target = e.target; + if ($target.closest("#cart-icon-btn")) { + open(); + return; + } + + if ($target.closest("#cart-modal-close-btn")) { + close(); + return; + } + + // 장바구니 모달 오버레이 클릭 (모달 내부 제외) + if ($target.id === "cart-modal-overlay") { + close(); + return; + } + }); + + // ESC 키 이벤트 리스너 + document.body.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + const $modalContainer = document.querySelector("#cart-modal-container"); + if ($modalContainer && !$modalContainer.classList.contains("hidden")) { + close(); + } + } + }); + }; + + const getCartList = () => { + const cartList = cartStore.getState().items; + console.log(cartList); + return cartList; + }; + + const rerenderModal = () => { + const cartList = getCartList(); + const $modalContent = document.querySelector("#cart-modal-dialog"); + if ($modalContent) { + $modalContent.outerHTML = CartModal(cartList); + modalEventListeners(); + } + }; + + // 선택된 항목 삭제 버튼 표시/숨김 관리 + const updateRemoveSelectedButton = () => { + const $selectAllDeleteBtn = document.querySelector("#cart-modal-clear-cart-btn"); + const $existingRemoveBtn = document.querySelector("#cart-modal-remove-selected-btn"); + const selectedCount = cartStore.getSelectedCount(); + + if (selectedCount > 0) { + // 선택된 항목이 있고 버튼이 없으면 추가 + if ($selectAllDeleteBtn && !$existingRemoveBtn) { + $selectAllDeleteBtn.parentElement.insertAdjacentHTML( + "beforebegin", + ` + + `, + ); + + // 추가한 버튼에 이벤트 리스너 연결 + const $removeSelectedBtn = document.querySelector("#cart-modal-remove-selected-btn"); + if ($removeSelectedBtn) { + $removeSelectedBtn.addEventListener("click", () => { + cartStore.removeSelectedItems(); + Toast({ result: "delete" }); + rerenderModal(); + }); + } + } else if ($existingRemoveBtn) { + // 버튼이 이미 있으면 개수만 업데이트 + $existingRemoveBtn.textContent = `선택한 상품 삭제 (${selectedCount}개)`; + } + } else { + // 선택된 항목이 없으면 버튼 제거 + if ($existingRemoveBtn) { + $existingRemoveBtn.remove(); + } + } + }; + + /** + * 모달 내부 이벤트 리스너 연결 + */ + const modalEventListeners = () => { + // 모달 내용 클릭 시 이벤트 전파 차단 (오버레이 클릭으로 인식되지 않도록) + const $modalDialog = document.querySelector("#cart-modal-dialog"); + if ($modalDialog) { + $modalDialog.addEventListener("click", (e) => { + e.stopPropagation(); + }); + } + + // X 버튼 클릭 이벤트 + const $closeBtn = document.querySelector("#cart-modal-close-btn"); + if ($closeBtn) { + $closeBtn.addEventListener("click", () => { + close(); + }); + } + + // 전체선택 체크박스 + const $selectAllCheckbox = document.querySelector("#cart-modal-select-all-checkbox"); + if ($selectAllCheckbox) { + $selectAllCheckbox.addEventListener("change", () => { + cartStore.toggleSelectAll(); + // 상태 변경 후 모달 업데이트 + rerenderModal(); + // 버튼 표시/숨김 업데이트 + updateRemoveSelectedButton(); + }); + } + + // 개별 아이템 체크박스 + const $itemCheckboxes = document.querySelectorAll(".cart-item-checkbox"); + $itemCheckboxes.forEach((checkbox) => { + checkbox.addEventListener("change", () => { + const productId = checkbox.dataset.productId; + cartStore.toggleSelect(productId); + // 상태 변경 후 모달 업데이트 + rerenderModal(); + // 버튼 표시/숨김 업데이트 + updateRemoveSelectedButton(); + }); + }); + + // 수량체크 버튼 + const $increaseButtons = document.querySelectorAll(".quantity-increase-btn"); + const $decreaseButtons = document.querySelectorAll(".quantity-decrease-btn"); + + $increaseButtons.forEach((button) => { + button.addEventListener("click", (e) => { + const productId = e.currentTarget.dataset.productId; + cartStore.increaseQuantity(productId); + rerenderModal(); + }); + }); + + $decreaseButtons.forEach((button) => { + button.addEventListener("click", (e) => { + const productId = e.currentTarget.dataset.productId; + cartStore.decreaseQuantity(productId); + rerenderModal(); + }); + }); + + // 개별 삭제버튼 + const $removeButtons = document.querySelectorAll(".cart-item-remove-btn"); + $removeButtons.forEach((button) => { + button.addEventListener("click", (e) => { + const productId = e.currentTarget.dataset.productId; + cartStore.removeItem(productId); + rerenderModal(); + }); + }); + + // 전체 삭제버튼 + const $clearCartBtn = document.querySelector("#cart-modal-clear-cart-btn"); + if ($clearCartBtn) { + $clearCartBtn.addEventListener("click", () => { + cartStore.clear(); + Toast({ result: "reset" }); + rerenderModal(); + }); + } + }; + + // Public API 반환 + return { + open, + close, + init, + }; +}; + +// 싱글톤 인스턴스 생성 및 export +export const CartModalFn = createCartModalHandler(); diff --git a/src/components/Footer.js b/src/components/Footer.js new file mode 100644 index 00000000..0536fc4c --- /dev/null +++ b/src/components/Footer.js @@ -0,0 +1,7 @@ +export const Footer = () => { + return ``; +}; diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 00000000..eb536117 --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,19 @@ +import { HeaderCart } from "./HeaderCart"; + +export const Header = () => { + return ` +
+
+
+

+ 쇼핑몰 +

+
+ + ${HeaderCart()} +
+
+
+
+ `; +}; diff --git a/src/components/HeaderCart.js b/src/components/HeaderCart.js new file mode 100644 index 00000000..99cd9176 --- /dev/null +++ b/src/components/HeaderCart.js @@ -0,0 +1,22 @@ +import { cartStore } from "../store/cartStore"; + +export const HeaderCart = () => { + const itemCount = cartStore.getItemCount(); + console.log("장바구니 아이템 개수:", itemCount); + console.log("장바구니 아이템들:", cartStore.getState().items); + + return ` + + `; +}; diff --git a/src/components/ProductDetail.js b/src/components/ProductDetail.js new file mode 100644 index 00000000..9b446d38 --- /dev/null +++ b/src/components/ProductDetail.js @@ -0,0 +1,79 @@ +import { cartStore } from "../store/cartStore"; +import { Toast } from "./Toast"; +import { push } from "../main"; + +const createDetailHandler = () => { + const insertCart = (product) => { + const $insertCartBtn = document.querySelector("#add-to-cart-btn"); + if ($insertCartBtn) { + $insertCartBtn.addEventListener("click", () => { + const $quantityInput = document.querySelector("#quantity-input"); + const quantity = parseInt($quantityInput.value) || 1; + + cartStore.addItem( + { + productId: product.productId, + title: product.title, + image: product.image, + lprice: product.lprice, + }, + quantity, + ); + Toast({ result: "success" }); + }); + } + }; + + const increaseQuantity = () => { + const $increaseBtn = document.querySelector("#quantity-increase"); + const $inputValue = document.querySelector("#quantity-input"); + if ($increaseBtn) { + $increaseBtn.addEventListener("click", () => { + const currentValue = parseInt($inputValue.value) || 1; + const maxValue = parseInt($inputValue.max) || 999; + const newValue = Math.min(currentValue + 1, maxValue); + $inputValue.value = newValue; + }); + } + }; + + const decreaseQuantity = () => { + const $decreaseBtn = document.querySelector("#quantity-decrease"); + const $inputValue = document.querySelector("#quantity-input"); + if ($decreaseBtn) { + $decreaseBtn.addEventListener("click", () => { + const currentValue = parseInt($inputValue.value) || 1; + const minValue = parseInt($inputValue.min) || 1; + const newValue = Math.max(currentValue - 1, minValue); + $inputValue.value = newValue; + }); + } + }; + + const breadCrumb = () => { + document.addEventListener("click", (e) => { + if (e.target.classList.contains("breadcrumb-link")) { + // category2가 있으면 category2를 우선 처리 (category2 버튼에는 category1도 함께 있음) + if (e.target.dataset.category2) { + const category1 = e.target.dataset.category1; + const category2 = e.target.dataset.category2; + push( + `${import.meta.env.BASE_URL}?category1=${encodeURIComponent(category1)}&category2=${encodeURIComponent(category2)}`, + ); + } else if (e.target.dataset.category1) { + const category1 = e.target.dataset.category1; + push(`${import.meta.env.BASE_URL}?category1=${encodeURIComponent(category1)}`); + } + } + }); + }; + + return { + insertCart, + increaseQuantity, + decreaseQuantity, + breadCrumb, + }; +}; + +export const detailHandler = createDetailHandler(); diff --git a/src/components/ProductList.js b/src/components/ProductList.js new file mode 100644 index 00000000..1871bac7 --- /dev/null +++ b/src/components/ProductList.js @@ -0,0 +1,81 @@ +const Skeleton = ` +
+
+
+
+
+
+
+
+
+`; + +const Loading = `
+
+ + + + + 상품을 불러오는 중... +
+
`; + +const ProductItem = ({ title, lprice, image, productId, brand }) => { + return ` +
+ +
+ ${title} +
+ +
+
+

+ ${title} +

+

${brand || ""}

+

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

+
+ + +
+
+ `; +}; + +export const ProductList = ({ loading, products, pagination }) => { + console.log("pagination", pagination); + return ` +
+
+ ${ + loading + ? ` + +
+ +${Skeleton.repeat(4)} +
+ ${Loading}` + : `
+ 총 ${pagination?.total || 0}개의 상품 +
+
+ ${products?.map(ProductItem).join("") || ""} +
+ ` + } +
+
+ `; +}; diff --git a/src/components/SearchForm.js b/src/components/SearchForm.js new file mode 100644 index 00000000..99988fc7 --- /dev/null +++ b/src/components/SearchForm.js @@ -0,0 +1,188 @@ +const LoadingCategory = ` +
카테고리 로딩 중...
+`; + +const Category1 = (categories, selectedCategory1) => { + console.log(categories); + const category1 = Object.keys(categories); + + if (selectedCategory1) { + return ""; + } + return ` + ${category1 + .map( + (items) => ` + + `, + ) + .join("")} +`; +}; + +// 카테고리1로 카테고리 2 목록 추출 +const CategorySub = (categories, category1) => { + if (categories && categories[category1]) { + console.log(Object.keys(categories[category1])); + return Object.keys(categories[category1]); + } + return []; +}; + +const Category2 = (categories, selectedCategory1, selectedCategory2) => { + const category2List = CategorySub(categories, selectedCategory1); + + if (!selectedCategory1 || category2List.length === 0) { + return ""; + } + + return ` + ${category2List + .map((category2) => { + const isSelected = selectedCategory2 === category2; + const selectedClass = "bg-blue-100 border-blue-300 text-blue-800"; + const defaultClass = "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"; + return ` + + `; + }) + .join("")} + `; +}; + +const CategoryCrumbs = (selectedCategory1, selectedCategory2) => { + const categoryList = []; + + if (selectedCategory1) { + categoryList.push({ name: selectedCategory1, type: "category1" }); + } + + if (selectedCategory2) { + categoryList.push({ name: selectedCategory2, type: "category2", category1: selectedCategory1 }); + } + + if (categoryList.length === 0) { + return ""; + } + + return categoryList + .map((category, index) => { + const isLast = index === categoryList.length - 1; + if (isLast) { + return `>${category.name}`; + } + if (category.type === "category1") { + return `>`; + } else if (category.type === "category2") { + return `>`; + } + return ""; + }) + .join(""); +}; + +const ProductSort = (filters) => { + const currentSort = filters?.sort || "price_asc"; + const options = [ + { value: "price_asc", label: "가격 낮은순" }, + { value: "price_desc", label: "가격 높은순" }, + { value: "name_asc", label: "이름순" }, + { value: "name_desc", label: "이름 역순" }, + ]; + return ` + +`; +}; + +const SelectLimit = (pagination) => { + const currentLimit = pagination?.limit || 20; + const options = [10, 20, 50, 100]; + + return ` + +`; +}; + +export const SearchForm = ({ filters, pagination, loading, categories, selectedCategory1, selectedCategory2 }) => { + console.log(filters); + // category1이 선택되었으면 category2를 표시, 아니면 category1을 표시 + const categoryButtons = loading + ? LoadingCategory + : selectedCategory1 + ? Category2(categories, selectedCategory1, selectedCategory2) + : Category1(categories, selectedCategory1); + + return ` +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + + ${CategoryCrumbs(selectedCategory1, selectedCategory2)} +
+ + +
+ + ${categoryButtons} +
+
+ +
+ +
+ + ${SelectLimit(pagination)} +
+ +
+ + ${ProductSort(filters)} +
+
+
+
+ `; +}; diff --git a/src/components/Toast.js b/src/components/Toast.js new file mode 100644 index 00000000..f4c4ed75 --- /dev/null +++ b/src/components/Toast.js @@ -0,0 +1,147 @@ +// Toast HTML 템플릿 생성 함수 +const getToastHTML = (result) => { + if (result === "success") { + return ` +
+
+ + + +
+

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

+ +
+ `; + } else if (result === "delete") { + return ` +
+
+ + + +
+

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

+ +
+ `; + } else if (result === "reset") { + return ` +
+
+ + + +
+

장바구니가 비워졌습니다.

+ +
+ `; + } else if (result === "error") { + return ` +
+
+ + + +
+

오류가 발생했습니다.

+ +
+ `; + } + return ""; +}; + +// Toast 타이머 관리 +let toastTimer = null; + +// Toast를 화면에 표시하는 함수 +export const Toast = ({ result }) => { + if (!result) return; + + // 모달이 열려있는지 확인 + const $modalContainer = document.querySelector("#cart-modal-container"); + const isModalOpen = $modalContainer && !$modalContainer.classList.contains("hidden"); + + // Toast 컨테이너가 없으면 생성 + let toastContainer = document.querySelector("#toast"); + if (!toastContainer) { + toastContainer = document.createElement("div"); + toastContainer.id = "toast"; + toastContainer.className = + "fixed bottom-4 left-0 right-0 z-50 flex flex-col gap-2 items-center justify-center mx-auto"; + toastContainer.style.width = "fit-content"; + } + + // 모달이 열려있으면 모달 컨테이너 내부에, 닫혀있으면 body에 추가 + if (isModalOpen && $modalContainer) { + // 기존 컨테이너가 다른 곳에 있으면 제거 + if (toastContainer.parentElement && toastContainer.parentElement !== $modalContainer) { + toastContainer.remove(); + } + // 모달 컨테이너에 추가 (없을 때만) + if (!toastContainer.parentElement) { + $modalContainer.appendChild(toastContainer); + } + } else { + // 기존 컨테이너가 다른 곳에 있으면 제거 + if (toastContainer.parentElement && toastContainer.parentElement !== document.body) { + toastContainer.remove(); + } + // body에 추가 (없을 때만) + if (!toastContainer.parentElement) { + document.body.appendChild(toastContainer); + } + } + + // 기존 토스트와 타이머 제거 + toastContainer.innerHTML = ""; + if (toastTimer) { + clearTimeout(toastTimer); + toastTimer = null; + } + + // Toast 요소 생성 + const toastElement = document.createElement("div"); + toastElement.innerHTML = getToastHTML(result); + const toast = toastElement.firstElementChild; + + // 닫기 버튼 이벤트 + const closeBtn = toast.querySelector("#toast-close-btn"); + const closeToast = () => { + toast.remove(); + if (toastTimer) { + clearTimeout(toastTimer); + toastTimer = null; + } + }; + + if (closeBtn) { + closeBtn.addEventListener("click", closeToast); + } + + // Toast를 컨테이너에 추가 + toastContainer.appendChild(toast); + + // 3초 후 자동으로 닫기 + toastTimer = setTimeout(() => { + closeToast(); + toastTimer = null; + }, 3000); +}; diff --git a/src/components/error.js b/src/components/error.js new file mode 100644 index 00000000..519250b4 --- /dev/null +++ b/src/components/error.js @@ -0,0 +1,100 @@ +import { PageLayout } from "../pages/PageLayout"; + +export const Error = () => { + return PageLayout({ + children: ` +
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
+ +
+
+ +
+
+
+ + + + 상품을 불러오는데 실패했습니다. +
+ +
+
+
+
+
+ `, + }); +}; diff --git a/src/components/index.js b/src/components/index.js new file mode 100644 index 00000000..92e2df55 --- /dev/null +++ b/src/components/index.js @@ -0,0 +1,4 @@ +export * from "./Header.js"; +export * from "./Footer.js"; +export * from "./SearchForm.js"; +export * from "./ProductList.js"; diff --git a/src/main.js b/src/main.js index 983c051f..f9cdf559 100644 --- a/src/main.js +++ b/src/main.js @@ -1,19 +1,573 @@ -import { worker } from "./mocks/browser.js"; +import { Homepage } from "./pages/Homepage"; +import { getProducts, getProduct, getCategories } from "./api/productApi"; +import { DetailPage } from "./pages/Detailpage"; +import { Error } from "./components/error"; +import { cartStore } from "./store/cartStore"; +import { Header } from "./components/Header"; +import { Toast } from "./components/Toast"; +import { CartModalFn } from "./components/CartModalHandler"; +import { detailHandler } from "./components/ProductDetail"; +import { infiniteScrollManager } from "./utils/infiniteScroll"; -// 개발 환경에서만 MSW 워커 시작 -async function enableMocking() { - if (import.meta.env.DEV) { +const enableMocking = () => + import("./mocks/browser.js").then(({ worker }) => { return worker.start({ - onUnhandledRequest: "bypass", // 처리되지 않은 요청은 그대로 통과 + onUnhandledRequest: "bypass", + serviceWorker: { + url: `${import.meta.env.BASE_URL}mockServiceWorker.js`, + }, }); + }); + +export const push = (path) => { + // BASE_URL이 포함된 경로인 경우, BASE_URL을 제거한 절대 경로로 변환 + const basePath = import.meta.env.BASE_URL; + let finalPath = path; + if (path.startsWith(basePath)) { + // BASE_URL을 제거하고 절대 경로로 변환 (예: /front_7th_chapter2-1/product/123 -> /product/123) + finalPath = path.replace(basePath, "/").replace(/\/$/, "") || "/"; } -} + // 홈 경로는 BASE_URL 없이 "/"로 처리 + if (finalPath === basePath || finalPath === `${basePath}/` || finalPath === "/") { + finalPath = "/"; + } + // 현재 URL과 같으면 히스토리에 추가하지 않음 (중복 방지) + const currentPath = window.location.pathname; + // currentPath에서 BASE_URL 제거하여 비교 + const currentPathWithoutBase = currentPath.startsWith(basePath) + ? currentPath.replace(basePath, "/").replace(/\/$/, "") || "/" + : currentPath.replace(/\/$/, "") || "/"; + if (currentPathWithoutBase === finalPath) { + return; + } + // history.pushState에 BASE_URL 없이 절대 경로 전달 + // 히스토리 스택에 저장하기 위해 BASE_URL 없이 경로를 전달 + history.pushState(null, null, finalPath); + // render()는 비동기이므로 즉시 호출 + render(); +}; -// 앱 초기화 -async function initApp() { - // MSW 워커 시작 - await enableMocking(); -} +const render = async () => { + const $root = document.querySelector("#root"); + const basePath = import.meta.env.BASE_URL; + const pathName = window.location.pathname; + // base path를 제거한 상대 경로 계산 + const relativePath = pathName.replace(basePath, "/").replace(/\/$/, "") || "/"; + + if (relativePath === "/") { + try { + // 무한 스크롤 초기화 + infiniteScrollManager.destroy(); + infiniteScrollManager.init(); + + // $root.innerHTML = Error(); + $root.innerHTML = Homepage({ loading: true }); + + // URL 쿼리 파라미터 읽기 + const urlParams = new URLSearchParams(window.location.search); + const category1 = urlParams.get("category1"); + const category2 = urlParams.get("category2"); + const search = urlParams.get("search"); + const sort = urlParams.get("sort"); + const limit = urlParams.get("limit"); + + const params = {}; + if (category1) params.category1 = category1; + if (category2) params.category2 = category2; + if (search) params.search = search; + if (sort) params.sort = sort; + if (limit) params.limit = parseInt(limit); + + const data = await getProducts(params); + const categories = await getCategories(); + console.log(data); + $root.innerHTML = Homepage({ + loading: false, + ...data, + categories, + selectedCategory1: category1, + selectedCategory2: category2, + filters: { ...(search && { search }), ...(sort && { sort }) }, + }); + + // 무한 스크롤에 초기 데이터 설정 + infiniteScrollManager.setInitialData( + data.products || [], + data.pagination || {}, + categories, + category1, + category2, + ); + } catch { + $root.innerHTML = Error(); + } + } else { + // 상세 페이지로 이동 시 무한 스크롤 비활성화 + infiniteScrollManager.destroy(); + // relativePath에서 productId 추출 (예: /product/85067212996 -> 85067212996) + const productId = relativePath.split("/").pop(); + $root.innerHTML = DetailPage({ loading: true }); + const data = await getProduct(productId); + + // 관련 상품 가져오기 + const relatedProductsData = await getProducts({ + category1: data.category1, + category2: data.category2, + }); + + $root.innerHTML = DetailPage({ + loading: false, + product: data, + relatedProducts: relatedProductsData.products || [], + }); + // 상세 페이지 장바구니 담기 버튼 이벤트 리스너 연결 + detailHandler.insertCart(data); + detailHandler.increaseQuantity(); + detailHandler.decreaseQuantity(); + detailHandler.breadCrumb(); + } +}; + +//뒤로가기 이벤트 핸들러 +window.addEventListener("popstate", () => { + render(); +}); + +// 상품 목록 갯수 클릭 이벤트 핸들러 +document.body.addEventListener("change", async (e) => { + console.log(e.target.value); // 갯수 + const limit = e.target.value; + const $root = document.querySelector("#root"); + + if (e.target.id !== "limit-select") return; + + // 현재 URL 파라미터 읽기 + const urlParams = new URLSearchParams(window.location.search); + const category1 = urlParams.get("category1"); + const category2 = urlParams.get("category2"); + const search = urlParams.get("search"); + const sort = urlParams.get("sort"); + + // URL 업데이트 + const basePath = import.meta.env.BASE_URL; + const newSearchParams = new URLSearchParams(); + if (category1) newSearchParams.set("category1", category1); + if (category2) newSearchParams.set("category2", category2); + if (search) newSearchParams.set("search", search); + if (sort) newSearchParams.set("sort", sort); + if (limit) newSearchParams.set("limit", limit); + const newUrl = `${basePath}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`; + history.pushState(null, null, newUrl); + + // limit 값으로 상품목록 재랜더링 + // 상품목록 조회 api의 params에 limit 값 변경 + const params = { + limit: parseInt(limit), + }; + if (category1) params.category1 = category1; + if (category2) params.category2 = category2; + if (search) params.search = search; + if (sort) params.sort = sort; + + // 무한 스크롤 초기화 + infiniteScrollManager.destroy(); + infiniteScrollManager.init(); + + // 로딩상태 표시 + $root.innerHTML = Homepage({ loading: true }); + + // 카테고리 조회 + const categories = await getCategories(); + + const data = await getProducts(params); + console.log(data); + $root.innerHTML = Homepage({ + loading: false, + ...data, + categories, + selectedCategory1: category1, + selectedCategory2: category2, + filters: { ...(search && { search }), ...(sort && { sort }) }, + }); + + // 무한 스크롤에 초기 데이터 설정 + infiniteScrollManager.setInitialData(data.products || [], data.pagination || {}, categories, category1, category2); +}); + +// 정렬 이벤트 핸들러 +document.body.addEventListener("change", async (e) => { + if (e.target.id !== "sort-select") return; + const sort = e.target.value; + const $root = document.querySelector("#root"); + + // 현재 URL 파라미터 읽기 + const urlParams = new URLSearchParams(window.location.search); + const category1 = urlParams.get("category1"); + const category2 = urlParams.get("category2"); + const search = urlParams.get("search"); + const limit = urlParams.get("limit"); + + // URL 업데이트 + const basePath = import.meta.env.BASE_URL; + const newSearchParams = new URLSearchParams(); + if (category1) newSearchParams.set("category1", category1); + if (category2) newSearchParams.set("category2", category2); + if (search) newSearchParams.set("search", search); + if (sort) newSearchParams.set("sort", sort); + if (limit) newSearchParams.set("limit", limit); + const newUrl = `${basePath}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`; + history.pushState(null, null, newUrl); + + const params = { + sort: sort, + }; + if (category1) params.category1 = category1; + if (category2) params.category2 = category2; + if (search) params.search = search; + if (limit) params.limit = parseInt(limit); + + // 무한 스크롤 초기화 + infiniteScrollManager.destroy(); + infiniteScrollManager.init(); + + // 로딩상태 표시 + $root.innerHTML = Homepage({ loading: true }); + + // 카테고리 조회 + const categories = await getCategories(); + + const data = await getProducts(params); + console.log(data); + $root.innerHTML = Homepage({ + loading: false, + ...data, + categories, + selectedCategory1: category1, + selectedCategory2: category2, + filters: search ? { search } : undefined, + }); + + // 무한 스크롤에 초기 데이터 설정 + infiniteScrollManager.setInitialData(data.products || [], data.pagination || {}, categories, category1, category2); +}); + +// 무한 스크롤 이벤트 +document.body.addEventListener("scroll", async () => { + // if( window.innerHeight + window.scrollY >= document.body.offsetHeight){ + // const parmas = { + // page: 2, + // }; + // const data = await getProducts(params); + // console.log(data); + // $root.innerHTML = Homepage({ loading: false, ...data, categories }); + // } +}); + +// 검색이벤트 엔터 핸들러 +document.body.addEventListener("keydown", async (e) => { + // 마우스가 검색창을 눌럿는지 + const $target = e.target; + if ($target.id === "search-input" && e.keyCode === 13) { + const searchVal = $target.value; + const $root = document.querySelector("#root"); + + // URL에서 현재 선택된 카테고리 읽어오기 + const urlParams = new URLSearchParams(window.location.search); + const category1 = urlParams.get("category1"); + const category2 = urlParams.get("category2"); + + const params = { + search: searchVal, + }; + if (category1) params.category1 = category1; + if (category2) params.category2 = category2; + + // URL 업데이트 (카테고리 정보 유지) + const basePath = import.meta.env.BASE_URL; + const newSearchParams = new URLSearchParams(); + if (category1) newSearchParams.set("category1", category1); + if (category2) newSearchParams.set("category2", category2); + if (searchVal) newSearchParams.set("search", searchVal); + const newUrl = `${basePath}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`; + history.pushState(null, null, newUrl); + + // 무한 스크롤 초기화 + infiniteScrollManager.destroy(); + infiniteScrollManager.init(); + + $root.innerHTML = Homepage({ + loading: true, + filters: { search: searchVal }, + selectedCategory1: category1, + selectedCategory2: category2, + }); + const data = await getProducts(params); + const categories = await getCategories(); + console.log(data); + console.log(categories); + $root.innerHTML = Homepage({ + loading: false, + ...data, + categories, + filters: { search: searchVal }, + selectedCategory1: category1, + selectedCategory2: category2, + }); + + // 무한 스크롤에 초기 데이터 설정 + infiniteScrollManager.setInitialData(data.products || [], data.pagination || {}, categories, category1, category2); + } +}); -// 앱 시작 -initApp().catch(console.error); +// 검색 버튼 클릭 이벤트 핸들러 +document.body.addEventListener("click", async (e) => { + // if (e.target.id === "search-btn") { + // const $searchInput = document.querySelector("#search-input"); + // if ($searchInput) { + // const searchVal = $searchInput.value; + // const $root = document.querySelector("#root"); + + // const params = { + // search: searchVal, + // }; + + // $root.innerHTML = Homepage({ loading: true, filters: { search: searchVal } }); + // const data = await getProducts(params); + // const categories = await getCategories(); + // console.log(data); + // console.log(categories); + // $root.innerHTML = Homepage({ + // loading: false, + // ...data, + // categories, + // filters: { search: searchVal }, + // }); + // } + // } + + // 검색 아이콘 클릭 이벤트 핸들러 + if (e.target.id === "search-icon" || e.target.closest("#search-icon")) { + const $searchInput = document.querySelector("#search-input"); + if ($searchInput) { + const searchVal = $searchInput.value; + const $root = document.querySelector("#root"); + + // URL에서 현재 선택된 카테고리 읽어오기 + const urlParams = new URLSearchParams(window.location.search); + const category1 = urlParams.get("category1"); + const category2 = urlParams.get("category2"); + + const params = { + search: searchVal, + }; + if (category1) params.category1 = category1; + if (category2) params.category2 = category2; + + // URL 업데이트 (카테고리 정보 유지) + const basePath = import.meta.env.BASE_URL; + const newSearchParams = new URLSearchParams(); + if (category1) newSearchParams.set("category1", category1); + if (category2) newSearchParams.set("category2", category2); + if (searchVal) newSearchParams.set("search", searchVal); + const newUrl = `${basePath}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`; + history.pushState(null, null, newUrl); + + // 무한 스크롤 초기화 + infiniteScrollManager.destroy(); + infiniteScrollManager.init(); + + $root.innerHTML = Homepage({ + loading: true, + filters: { search: searchVal }, + selectedCategory1: category1, + selectedCategory2: category2, + }); + const data = await getProducts(params); + const categories = await getCategories(); + console.log(data); + console.log(categories); + $root.innerHTML = Homepage({ + loading: false, + ...data, + categories, + filters: { search: searchVal }, + selectedCategory1: category1, + selectedCategory2: category2, + }); + + // 무한 스크롤에 초기 데이터 설정 + infiniteScrollManager.setInitialData( + data.products || [], + data.pagination || {}, + categories, + category1, + category2, + ); + } + } +}); + +// 카테고리 클릭 이벤트 핸들러 +document.body.addEventListener("click", async (e) => { + const $target = e.target; + if ($target.classList.contains("category1-filter-btn")) { + const category1 = $target.dataset.category1; + console.log(category1); + push(`${import.meta.env.BASE_URL}?category1=${encodeURIComponent(category1)}`); + } + + if ($target.classList.contains("category2-filter-btn")) { + const category1 = $target.dataset.category1; + const category2 = $target.dataset.category2; + + console.log(category1, category2); + push( + `${import.meta.env.BASE_URL}?category1=${encodeURIComponent(category1)}&category2=${encodeURIComponent(category2)}`, + ); + } +}); + +// 카테고리 브레드크럼 클릭 이벤트 핸들러 +document.body.addEventListener("click", async (e) => { + const $target = e.target; + + if ($target.hasAttribute("data-breadcrumb")) { + console.log($target.dataset.category1); + + // 현재 URL에서 검색어 읽어오기 + const urlParams = new URLSearchParams(window.location.search); + const currentSearch = urlParams.get("search"); + + const basePath = import.meta.env.BASE_URL; + const newSearchParams = new URLSearchParams(); + + if ($target.dataset.breadcrumb === "reset") { + // 검색어만 유지 + if (currentSearch) { + newSearchParams.set("search", currentSearch); + } + push(`${basePath}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`); + } else if ($target.dataset.breadcrumb === "category1") { + const category1 = $target.dataset.category1; + newSearchParams.set("category1", category1); + if (currentSearch) { + newSearchParams.set("search", currentSearch); + } + push(`${basePath}?${newSearchParams.toString()}`); + } else if ($target.dataset.breadcrumb === "category2") { + const category1 = $target.dataset.category1; + const category2 = $target.dataset.category2; + newSearchParams.set("category1", category1); + newSearchParams.set("category2", category2); + if (currentSearch) { + newSearchParams.set("search", currentSearch); + } + push(`${basePath}?${newSearchParams.toString()}`); + } + } +}); + +// Header 업데이트 함수 +const updateHeader = () => { + const $header = document.querySelector("header"); + if ($header) { + $header.outerHTML = Header(); + } +}; + +// 장바구니 상태 변경 구독 +cartStore.subscribe(() => { + updateHeader(); +}); + +// 상품 목록으로 돌아가기 버튼 클릭 이벤트 핸들러 +document.body.addEventListener("click", (e) => { + if (e.target.closest(".go-to-product-list")) { + push(`${import.meta.env.BASE_URL}`); + render(); + } +}); + +// 상품 카드 클릭 이벤트 핸들러 (홈페이지 및 관련 상품) +document.body.addEventListener("click", (e) => { + // 장바구니 버튼 클릭은 제외 + if (e.target.closest(".add-to-cart-btn")) return; + + const $productCard = e.target.closest(".product-card") || e.target.closest(".related-product-card"); + if ($productCard) { + e.preventDefault(); + e.stopPropagation(); + const productId = $productCard.dataset.productId; + const basePath = import.meta.env.BASE_URL; + // BASE_URL을 포함한 전체 경로 생성 + const productPath = basePath.endsWith("/") ? `${basePath}product/${productId}` : `${basePath}/product/${productId}`; + push(productPath); + } +}); + +// 메인 리스트 장바구니 클릭 이벤트 핸들러 (이벤트 위임 방식, once 옵션 사용) +const handleAddToCart = async (e) => { + // .add-to-cart-btn 클래스를 가진 버튼만 처리 (버튼 내부 요소 클릭도 처리) + const $button = e.target.closest(".add-to-cart-btn"); + if (!$button) { + return; + } + + // 상세 페이지의 장바구니 버튼(id="add-to-cart-btn")은 제외 + if ($button.id === "add-to-cart-btn") { + return; + } + + e.stopPropagation(); + e.preventDefault(); + + // 중복 클릭 방지 + if ($button.disabled || $button.dataset.processing === "true") { + return; + } + $button.dataset.processing = "true"; + $button.disabled = true; + + console.log("✅ 메인 리스트 장바구니 버튼 클릭 처리 시작"); + + const productId = $button.dataset.productId; + + try { + // 상품 정보 가져오기 + const product = await getProduct(productId); + + // 장바구니에 추가 + console.log("📦 메인 리스트에서 addItem 호출"); + cartStore.addItem({ + productId: product.productId, + title: product.title, + image: product.image, + lprice: product.lprice, + }); + + // state.items만 콘솔로 찍기 + console.log("장바구니 아이템들:", cartStore.getState().items); + Toast({ result: "success" }); + } finally { + // 처리 완료 후 버튼 활성화 + $button.dataset.processing = "false"; + $button.disabled = false; + } +}; + +// 이벤트 위임 방식으로 등록 +document.body.addEventListener("click", handleAddToCart); +console.log("✅ 장바구니 이벤트 핸들러 등록 완료"); + +const main = () => { + render(); + // 장바구니 모달 컨트롤러 핸들러 초기화 + CartModalFn.init(); +}; + +// 애플리케이션 시작 +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..504bab75 --- /dev/null +++ b/src/pages/Detailpage.js @@ -0,0 +1,172 @@ +import { HeaderCart } from "../components/HeaderCart.js"; +import { Footer } from "../components/index.js"; + +const renderRelatedProducts = (product, relatedProducts) => { + if (!relatedProducts || relatedProducts.length === 0) { + return '

관련 상품이 없습니다.

'; + } + + return relatedProducts + .filter((item) => item.productId !== product.productId) + .map( + (item) => ` + + `, + ) + .join(""); +}; + +export const DetailPage = ({ loading, product, relatedProducts = [] }) => { + return ` +
+
+
+
+
+ +

상품 상세

+
+
+ ${HeaderCart()} +
+
+
+
+ ${ + loading + ? `
+
+
+

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

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

+

${product.title}

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

관련 상품

+

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

+
+
+
+ ${renderRelatedProducts(product, relatedProducts)} +
+
+
+
+ ` + } + ${Footer()} +
+ `; +}; diff --git a/src/pages/Homepage.js b/src/pages/Homepage.js new file mode 100644 index 00000000..2a21fe29 --- /dev/null +++ b/src/pages/Homepage.js @@ -0,0 +1,19 @@ +import { PageLayout } from "./PageLayout"; +import { SearchForm, ProductList } from "../components/index.js"; + +export const Homepage = ({ + filters, + products, + pagination, + loading, + categories, + selectedCategory1, + selectedCategory2, +}) => { + return PageLayout({ + children: ` + ${SearchForm({ filters, pagination, loading, categories, selectedCategory1, selectedCategory2 })} + ${ProductList({ loading, products, pagination })} + `, + }); +}; diff --git a/src/pages/PageLayout.js b/src/pages/PageLayout.js new file mode 100644 index 00000000..56682466 --- /dev/null +++ b/src/pages/PageLayout.js @@ -0,0 +1,13 @@ +import { Header, Footer } from "../components"; +export const PageLayout = ({ children }) => { + return ` +
+ ${Header()} +
+ ${children} +
+ ${Footer()} + +
+`; +}; diff --git a/src/setupTests.js b/src/setupTests.js deleted file mode 100644 index d0de870d..00000000 --- a/src/setupTests.js +++ /dev/null @@ -1 +0,0 @@ -import "@testing-library/jest-dom"; diff --git a/src/store/cartStore.js b/src/store/cartStore.js new file mode 100644 index 00000000..8bc1905b --- /dev/null +++ b/src/store/cartStore.js @@ -0,0 +1,205 @@ +// 장바구니 Store - 함수형 Observer 패턴 +const createCartStore = () => { + // 클로저로 관리되는 상태 + let state = { + items: [], // { productId, title, image, lprice, quantity, selected } + }; + + let subscribers = []; + + // localStorage에서 장바구니 데이터 로드 + const loadFromStorage = () => { + try { + const saved = localStorage.getItem("shopping_cart"); + if (saved) { + state.items = JSON.parse(saved); + notify(); + } + } catch (error) { + console.error("장바구니 데이터 로드 실패:", error); + } + }; + + // localStorage에 장바구니 데이터 저장 + const saveToStorage = () => { + try { + localStorage.setItem("shopping_cart", JSON.stringify(state.items)); + } catch (error) { + console.error("장바구니 데이터 저장 실패:", error); + } + }; + + // 상태 변경 알림 + const notify = () => { + subscribers.forEach((callback) => callback(state)); + }; + + // 구독자 등록 + const subscribe = (callback) => { + subscribers.push(callback); + // 즉시 현재 상태를 전달 + callback(state); + + // 구독 해제 함수 반환 + return () => { + subscribers = subscribers.filter((sub) => sub !== callback); + }; + }; + + // 상품 추가 + const addItem = (product, quantity = 1) => { + // productId를 문자열로 통일 + const productId = String(product.productId); + const existingItemIndex = state.items.findIndex((item) => String(item.productId) === productId); + + if (existingItemIndex !== -1) { + // 이미 있는 상품이면 수량만 증가 (불변성 유지) + state.items = state.items.map((item, index) => + index === existingItemIndex ? { ...item, quantity: item.quantity + quantity } : item, + ); + } else { + // 새 상품 추가 + state.items = [ + ...state.items, + { + productId: productId, + title: product.title, + image: product.image, + lprice: product.lprice, + quantity: quantity, + selected: false, + }, + ]; + } + + saveToStorage(); + notify(); + }; + + // 상품 수량 변경 + const updateQuantity = (productId, quantity) => { + if (quantity <= 0) { + removeItem(productId); + return; + } + + state.items = state.items.map((item) => (item.productId === productId ? { ...item, quantity } : item)); + + saveToStorage(); + notify(); + }; + + // 상품 수량 증가 + const increaseQuantity = (productId, amount) => { + amount = amount !== undefined ? amount : 1; + const target = state.items.find((item) => item.productId === productId); + if (!target) { + return; + } + + updateQuantity(productId, target.quantity + amount); + }; + + // 상품 수량 감소 (최소 1 유지) + const decreaseQuantity = (productId, amount) => { + amount = amount !== undefined ? amount : 1; + const target = state.items.find((item) => item.productId === productId); + if (!target) { + return; + } + + const nextQuantity = target.quantity - amount; + updateQuantity(productId, nextQuantity < 1 ? 1 : nextQuantity); + }; + + // 상품 삭제 + const removeItem = (productId) => { + state.items = state.items.filter((item) => item.productId !== productId); + saveToStorage(); + notify(); + }; + + // 선택된 상품 삭제 + const removeSelectedItems = () => { + state.items = state.items.filter((item) => !item.selected); + saveToStorage(); + notify(); + }; + + // 전체 비우기 + const clear = () => { + state.items = []; + saveToStorage(); + notify(); + }; + + // 상품 선택/해제 + const toggleSelect = (productId) => { + state.items = state.items.map((item) => + item.productId === productId ? { ...item, selected: !item.selected } : item, + ); + saveToStorage(); + notify(); + }; + + // 전체 선택/해제 + const toggleSelectAll = () => { + const allSelected = state.items.every((item) => item.selected); + state.items = state.items.map((item) => ({ + ...item, + selected: !allSelected, + })); + saveToStorage(); + notify(); + }; + + // 장바구니 아이템 개수 (고유 상품 종류의 개수) + const getItemCount = () => { + return state.items.length; + }; + + // 선택된 아이템 개수 + const getSelectedCount = () => { + return state.items.filter((item) => item.selected).length; + }; + + // 총 금액 계산 + const getTotalPrice = () => { + return state.items.reduce((total, item) => total + item.lprice * item.quantity, 0); + }; + + // 선택된 아이템 총 금액 + const getSelectedTotalPrice = () => { + return state.items.filter((item) => item.selected).reduce((total, item) => total + item.lprice * item.quantity, 0); + }; + + // 현재 상태 반환 + const getState = () => { + return { ...state }; + }; + + // 초기화: localStorage에서 데이터 로드 + loadFromStorage(); + + // Public API 반환 + return { + subscribe, + addItem, + updateQuantity, + removeItem, + removeSelectedItems, + clear, + toggleSelect, + toggleSelectAll, + increaseQuantity, + decreaseQuantity, + getItemCount, + getSelectedCount, + getTotalPrice, + getSelectedTotalPrice, + getState, + }; +}; + +// 싱글톤 인스턴스 생성 및 export +export const cartStore = createCartStore(); 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/template.js b/src/template.js new file mode 100644 index 00000000..72663f08 --- /dev/null +++ b/src/template.js @@ -0,0 +1,1154 @@ +const enableMocking = () => + import("./mocks/browser.js").then(({ worker }) => { + const isProduction = import.meta.env.PROD; + const basePath = isProduction ? "/front_7th_chapter2-1" : ""; + + return worker.start({ + onUnhandledRequest: "bypass", + serviceWorker: { + url: `${basePath}/mockServiceWorker.js`, + }, + }); + }); + +function main() { + const 상품목록_레이아웃_로딩 = ` +
+
+
+
+

+ 쇼핑몰 +

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

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

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

+ 쇼핑몰 +

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

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

+

+

+ 220원 +

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

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

+

이지웨이건축자재

+

+ 230원 +

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

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

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

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

+ +
+ +
+
+ + + +
+

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

+ +
+ +
+
+ + + +
+

오류가 발생했습니다.

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

+ + + + 장바구니 +

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

장바구니가 비어있습니다

+

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

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

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

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

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

+

+ 220원 +

+ +
+ + + +
+
+ +
+

+ 440원 +

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

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

+

+ 230원 +

+ +
+ + + +
+
+ +
+

+ 230원 +

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

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

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

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

+

+ 220원 +

+ +
+ + + +
+
+ +
+

+ 440원 +

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

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

+

+ 230원 +

+ +
+ + + +
+
+ +
+

+ 230원 +

+ +
+
+
+
+
+ +
+ +
+ 선택한 상품 (1개) + 440원 +
+ +
+ 총 금액 + 670원 +
+ +
+ +
+ + +
+
+
+
+
+ `; + + const 상세페이지_로딩 = ` +
+
+
+
+
+ +

상품 상세

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

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

+
+
+
+
+
+

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

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

상품 상세

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

+

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

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

관련 상품

+

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

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

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

+
+
+
+ `; + + const _404_ = ` +
+
+ + + + + + + + + + + + + 404 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+
+ `; + + document.body.innerHTML = ` + ${상품목록_레이아웃_로딩} +
+ ${상품목록_레이아웃_로딩완료} +
+ ${상품목록_레이아웃_카테고리_1Depth} +
+ ${상품목록_레이아웃_카테고리_2Depth} +
+ ${토스트} +
+ ${장바구니_비어있음} +
+ ${장바구니_선택없음} +
+ ${장바구니_선택있음} +
+ ${상세페이지_로딩} +
+ ${상세페이지_로딩완료} +
+ ${_404_} + `; +} + +// 애플리케이션 시작 +if (import.meta.env.MODE !== "test") { + enableMocking().then(main); +} else { + main(); +} diff --git a/src/utils/infiniteScroll.js b/src/utils/infiniteScroll.js new file mode 100644 index 00000000..cccc7ca6 --- /dev/null +++ b/src/utils/infiniteScroll.js @@ -0,0 +1,319 @@ +import { getProducts } from "../api/productApi"; +import { getCategories } from "../api/productApi"; + +/** + * 무한 스크롤 상태 관리 + */ +class InfiniteScrollManager { + constructor() { + this.currentPage = 1; + this.isLoading = false; + this.hasMore = true; + this.observer = null; + this.currentParams = {}; + this.allProducts = []; + this.categories = null; + this.selectedCategory1 = null; + this.selectedCategory2 = null; + } + + /** + * 무한 스크롤 초기화 + */ + init() { + this.reset(); + this.setupObserver(); + } + + /** + * 상태 초기화 + */ + reset() { + this.currentPage = 1; + this.isLoading = false; + this.hasMore = true; + this.allProducts = []; + this.currentParams = {}; + this.selectedCategory1 = null; + this.selectedCategory2 = null; + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + } + + /** + * 현재 URL 파라미터 읽기 + */ + readCurrentParams() { + const urlParams = new URLSearchParams(window.location.search); + const category1 = urlParams.get("category1"); + const category2 = urlParams.get("category2"); + const searchFromUrl = urlParams.get("search"); + const searchInput = document.querySelector("#search-input"); + const searchFromInput = searchInput ? searchInput.value : ""; + // URL의 검색어를 우선 사용하고, 없으면 검색창의 값을 사용 + const search = searchFromUrl || searchFromInput; + const limitSelect = document.querySelector("#limit-select"); + const limit = limitSelect ? parseInt(limitSelect.value) : 20; + const sortSelect = document.querySelector("#sort-select"); + const sort = sortSelect ? sortSelect.value : "price_asc"; + + return { + category1: category1 || "", + category2: category2 || "", + search: search || "", + limit, + sort, + }; + } + + /** + * Intersection Observer 설정 + */ + setupObserver() { + // 홈 페이지가 아닌 경우 무한 스크롤 비활성화 + const basePath = import.meta.env.BASE_URL; + const pathName = window.location.pathname; + const relativePath = pathName.replace(basePath, "/").replace(/\/$/, "") || "/"; + + if (relativePath !== "/") { + return; + } + + // 기존 observer가 있으면 제거 + if (this.observer) { + this.observer.disconnect(); + } + + // 감지할 요소 생성 (하단에 배치될 요소) + const $productsGrid = document.querySelector("#products-grid"); + if (!$productsGrid) { + return; + } + + // 로딩 인디케이터 요소 생성 + let $loadingIndicator = document.querySelector("#infinite-scroll-loading"); + if (!$loadingIndicator) { + $loadingIndicator = document.createElement("div"); + $loadingIndicator.id = "infinite-scroll-loading"; + $loadingIndicator.className = "infinite-scroll-trigger"; + $loadingIndicator.style.height = "100px"; + $productsGrid.parentElement.appendChild($loadingIndicator); + // 초기 빈 상태로 설정 + $loadingIndicator.innerHTML = ""; + } + + // Intersection Observer 생성 + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !this.isLoading && this.hasMore) { + // 로딩 인디케이터를 먼저 표시한 후 로드 시작 + this.updateLoadingIndicator(true); + // 다음 이벤트 루프에서 로드 시작 (DOM 업데이트 보장) + setTimeout(() => { + this.loadNextPage(); + }, 0); + } + }); + }, + { + root: null, + rootMargin: "200px", // 하단 200px 전에 미리 로드 + threshold: 0.1, + }, + ); + + this.observer.observe($loadingIndicator); + } + + /** + * 다음 페이지 데이터 로드 + */ + async loadNextPage() { + if (this.isLoading || !this.hasMore) { + return; + } + + this.isLoading = true; + // 로딩 인디케이터 표시 (이미 표시되어 있어도 안전) + this.updateLoadingIndicator(true); + + try { + // 현재 파라미터 읽기 + const params = this.readCurrentParams(); + + // 파라미터가 변경되었으면 초기화 + const paramsKey = JSON.stringify(params); + const currentParamsKey = JSON.stringify(this.currentParams); + if (paramsKey !== currentParamsKey) { + this.reset(); + this.allProducts = []; + this.currentParams = params; + this.selectedCategory1 = params.category1; + this.selectedCategory2 = params.category2; + } + + // 다음 페이지 요청 + const nextPage = this.currentPage + 1; + const response = await getProducts({ + ...this.currentParams, + page: nextPage, + }); + + // 카테고리 정보는 첫 페이지에서만 가져오기 + if (!this.categories) { + this.categories = await getCategories(); + } + + // 상품 목록에 추가 + if (response.products && response.products.length > 0) { + this.allProducts = [...this.allProducts, ...response.products]; + this.currentPage = nextPage; + + // 더 불러올 데이터가 있는지 확인 + const totalPages = Math.ceil(response.pagination.total / response.pagination.limit); + this.hasMore = nextPage < totalPages; + + // UI 업데이트 + this.updateProductList(response.pagination); + } else { + this.hasMore = false; + } + } catch (error) { + console.error("무한 스크롤 로드 실패:", error); + this.hasMore = false; + } finally { + this.isLoading = false; + this.updateLoadingIndicator(false); + } + } + + /** + * 로딩 인디케이터 업데이트 + */ + updateLoadingIndicator(show) { + let $loadingIndicator = document.querySelector("#infinite-scroll-loading"); + if (!$loadingIndicator) { + return; + } + + if (show) { + $loadingIndicator.innerHTML = ` +
+
+ + + + + 상품을 불러오는 중... +
+
+ `; + } else if (!this.hasMore) { + $loadingIndicator.innerHTML = ` +
+ 모든 상품을 불러왔습니다. +
+ `; + } else { + $loadingIndicator.innerHTML = ""; + } + } + + /** + * 상품 목록 UI 업데이트 + */ + updateProductList() { + const $productsGrid = document.querySelector("#products-grid"); + if (!$productsGrid) { + return; + } + + // 현재 그리드에 있는 상품 개수 확인 + const currentProductCount = $productsGrid.children.length; + + // 새로 추가할 상품들만 추출 + const newProducts = this.allProducts.slice(currentProductCount); + + // 새 상품 HTML 생성 + const newProductsHTML = newProducts + .map((product) => { + return ` +
+ +
+ ${product.title} +
+ +
+
+

+ ${product.title} +

+

+

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

+
+ + +
+
+ `; + }) + .join(""); + + // 새 상품들을 기존 그리드에 추가 + if (newProductsHTML) { + $productsGrid.insertAdjacentHTML("beforeend", newProductsHTML); + } + } + + /** + * 첫 페이지 데이터 설정 (홈페이지 초기 로드 시) + */ + setInitialData(products, pagination, categories, selectedCategory1, selectedCategory2) { + this.allProducts = products || []; + this.currentPage = 1; + this.categories = categories; + this.selectedCategory1 = selectedCategory1; + this.selectedCategory2 = selectedCategory2; + this.currentParams = this.readCurrentParams(); + + // 더 불러올 데이터가 있는지 확인 + const totalPages = Math.ceil(pagination.total / pagination.limit); + this.hasMore = this.currentPage < totalPages; + + // Observer 재설정 + setTimeout(() => { + this.setupObserver(); + }, 100); + } + + /** + * 무한 스크롤 정리 + */ + destroy() { + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + const $loadingIndicator = document.querySelector("#infinite-scroll-loading"); + if ($loadingIndicator) { + $loadingIndicator.remove(); + } + this.reset(); + } +} + +export const infiniteScrollManager = new InfiniteScrollManager(); diff --git a/vite.config.js b/vite.config.js index ced41c4c..69b1ec2c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,10 +1,8 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from "vite"; export default defineConfig({ - test: { - globals: true, - environment: "jsdom", - setupFiles: "./src/setupTests.js", - exclude: ["**/e2e/**", "**/*.e2e.spec.js", "**/node_modules/**"], + base: "/front_7th_chapter2-1/", + build: { + outDir: "dist", }, });