diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f36c3c4b..9964d8a2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,92 +4,241 @@ +https://daehyunk1m.github.io/front_7th_chapter2-1/ ### 기본과제 -#### 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] 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- [ ] 검색 버튼 클릭으로 검색이 수행된다 +- [ ] Enter 키로 검색이 수행된다 +- [ ] 검색어와 일치하는 상품들만 목록에 표시된다 + +**카테고리 선택** + +- [ ] 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- [ ] 선택된 카테고리에 해당하는 상품들만 표시된다 +- [ ] 전체 상품 보기로 돌아갈 수 있다 +- [ ] 2단계 카테고리 구조를 지원한다 (1depth, 2depth) + +**카테고리 네비게이션** + +- [ ] 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- [ ] 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- [ ] "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 + +**현재 상품 수 표시** + +- [x] 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- [ ] 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 + +#### 장바구니 + +**장바구니 모달** + +- [ ] 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- [ ] X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- [ ] ESC 키로 모달을 닫을 수 있다 +- [ ] 모달에서 장바구니의 모든 기능을 사용할 수 있다 + +**장바구니 수량 조절** + +- [ ] 각 장바구니 상품의 수량을 증가할 수 있다 +- [ ] 각 장바구니 상품의 수량을 감소할 수 있다 +- [ ] 수량 변경 시 총 금액이 실시간으로 업데이트된다 + +**장바구니 삭제** + +- [ ] 각 상품에 삭제 버튼이 배치되어 있다 +- [ ] 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 + +**장바구니 선택 삭제** + +- [ ] 각 상품에 선택을 위한 체크박스가 제공된다 +- [ ] 선택 삭제 버튼이 있다 +- [ ] 체크된 상품들만 일괄 삭제된다 + +**장바구니 전체 선택** + +- [ ] 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- [ ] 전체 선택 시 모든 상품의 체크박스가 선택된다 +- [ ] 전체 해제 시 모든 상품의 체크박스가 해제된다 + +**장바구니 비우기** + +- [ ] 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 + +#### 상품 상세 + +**상품 클릭시 상세 페이지 이동** + +- [x] 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- [x] URL이 `/product/{productId}` 형태로 변경된다 +- [x] 상품의 자세한 정보가 전용 페이지에서 표시된다 + +**상품 상세 페이지 기능** + +- [x] 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- [x] 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 + +**상품 상세 - 장바구니 담기** + +- [x] 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- [x] 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- [x] 수량 증가/감소 버튼이 제공된다 + +**관련 상품 기능** + +- [ ] 상품 상세 페이지에서 관련 상품들이 표시된다 +- [ ] 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- [ ] 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- [ ] 현재 보고 있는 상품은 관련 상품에서 제외된다 + +**상품 상세 페이지 내 네비게이션** + +- [x] 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- [ ] 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- [x] SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 + +#### 사용자 피드백 시스템 + +**토스트 메시지** + +- [x] 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- [x] 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- [x] 토스트는 3초 후 자동으로 사라진다 +- [x] 토스트에 닫기 버튼이 제공된다 +- [x] 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) ### 심화과제 -#### 1) 해시 라우터 구현 -- [ ] location.hash를 이용하여 SPA 라우터 구현 - - [ ] '/#/' (홈 페이지) - - [ ] '/#/login' (로그인 페이지) - - [ ] '/#/profile' (프로필 페이지) - -#### 2) 라우트 가드 구현 -- [ ] 로그인 상태에 따른 접근 제어 -- [ ] 비로그인 사용자의 특정 페이지 접근 시 로그인 페이지로 리다이렉션 +#### SPA 네비게이션 및 URL 관리 + +**페이지 이동** + +- [x] 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. + +**상품 목록 - URL 쿼리 반영** + +- [ ] 검색어가 URL 쿼리 파라미터에 저장된다 +- [ ] 카테고리 선택이 URL 쿼리 파라미터에 저장된다 +- [ ] 상품 옵션이 URL 쿼리 파라미터에 저장된다 +- [ ] 정렬 조건이 URL 쿼리 파라미터에 저장된다 +- [ ] 조건 변경 시 URL이 자동으로 업데이트된다 +- [ ] URL을 통해 현재 검색/필터 상태를 공유할 수 있다 + +**상품 목록 - 새로고침 시 상태 유지** + +- [ ] 새로고침 후 URL 쿼리에서 검색어가 복원된다 +- [ ] 새로고침 후 URL 쿼리에서 카테고리가 복원된다 +- [ ] 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 +- [ ] 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 +- [ ] 복원된 조건에 맞는 상품 데이터가 다시 로드된다 -#### 3) 이벤트 위임 +**장바구니 - 새로고침 시 데이터 유지** -- [ ] 이벤트 위임 방식으로 이벤트를 관리하고 있다. +- [ ] 장바구니 내용이 브라우저에 저장된다 +- [ ] 새로고침 후에도 이전 장바구니 내용이 유지된다 +- [ ] 장바구니의 선택 상태도 함께 유지된다 + +**상품 상세 - URL에 ID 반영** + +- [x] 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) +- [ ] URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 + +**상품 상세 - 새로고침시 유지** + +- [ ] 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 + +**404 페이지** + +- [x] 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 +- [x] 홈으로 돌아가기 버튼이 제공된다 + +#### AI로 한 번 더 구현하기 + +- [ ] 기존에 구현한 기능을 AI로 다시 구현한다. +- [ ] 이 과정에서 직접 가공하는 것은 최대한 지양한다. ## 과제 셀프회고 ++11-10일 +발제문서와 스터디문서 딥다이브...! +JS 관련 문서와 +SPA 관련 문서는 내용이 많아서 과제 진행하면서 중간중간 계속 참고하기로.. + +컴포넌트를 나누고, +깃헙페이지를 액션 워크플로우에 물려서 배포하는방식을 적용했다. + +페이지는 흰 화면이 나오지만, 타이틀은 '상품 쇼핑몰'로 제대로 나오는 것 같으니 +우선 흰 화면만 뜨는건 과제좀 진행하고 해결하는걸로.. + ++11-11일 + ### 기술적 성장 + -### 코드 품질 +### 자랑하고 싶은 코드 + + + +### 개선이 필요하다고 생각하는 코드 + ### 학습 효과 분석 + ### 과제 피드백 + +### AI 활용 경험 공유하기 + + + ## 리뷰 받고 싶은 내용 + +궁금한 것. + +제가 구현했을 때 생각보다 SPA의 구동 로직이 라우터를 통해 진행되는 것이 많은데요 +SPA를 구성할 때 라우팅도 무시못할 중요한 축 중 하나라고 생각됩니다. + +그런데 왜 리액트는 자체적인 라우팅이 없을까? +라우팅을 사용할 때 react-router-dom 같은 다른 라이브러리에 의존하게 될까?? diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 041962e7..5189dd5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,17 +11,6 @@ on: jobs: basic: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - name: test basic - run: | - npm install - npm run test:basic - advacned: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -33,13 +22,13 @@ jobs: version: latest - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - - name: advanced-test - run: | + - run: | pnpm install - pnpm run test:advanced - e2e: + pnpm exec playwright install + pnpm run test:e2e:basic + advanced: timeout-minutes: 60 runs-on: ubuntu-latest steps: @@ -52,10 +41,10 @@ jobs: version: latest - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - name: Install dependencies run: | pnpm install - npx playwright install --with-deps - pnpm run test:e2e + pnpm exec playwright install + pnpm run test:e2e:advanced diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml new file mode 100644 index 00000000..24245db3 --- /dev/null +++ b/.github/workflows/gh-pages.yml @@ -0,0 +1,32 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write # v4부터 필요 + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + with: + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + - name: Install dependencies + run: | + pnpm install + pnpm run build + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist diff --git a/.gitignore b/.gitignore index e76379da..7cff355f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ dist-ssr *.sw? /test-results/ /playwright-report/ +coverage +.coverage diff --git a/.prettierrc b/.prettierrc index c3fef9a3..1d2699e4 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { "tabWidth": 2, "semi": true, - "singleQuote": false + "singleQuote": false, + "printWidth": 120 } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..475508c4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,156 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a front-end e-commerce shopping mall project ("항해플러스 프론트엔드 쇼핑몰") built with vanilla JavaScript as a Single Page Application (SPA). The project implements a product listing page with filtering, searching, sorting, shopping cart functionality, and product detail pages - all without using frameworks like React, Vue, or Angular. + +## Development Commands + +### Development Server +```bash +pnpm run dev # Start development server on port 5173 +pnpm run dev:hash # Start dev server with hash router (./index.hash.html) +``` + +### Build & Preview +```bash +pnpm run build # Build for production +pnpm run preview # Preview production build locally +``` + +### Testing +```bash +# E2E Tests (Playwright) +pnpm run test:e2e # Run all e2e tests +pnpm run test:e2e:basic # Run basic tests only +pnpm run test:e2e:advanced # Run advanced tests only +pnpm run test:e2e:ui # Run tests with Playwright UI +pnpm run test:e2e:report # Show test report +pnpm run test:generate # Generate test code (codegen on localhost:5173) +``` + +Note: Vitest is configured but unit tests are not currently used (only e2e tests). + +### Code Quality +```bash +pnpm run lint:fix # Fix ESLint errors +pnpm run prettier:write # Format code with Prettier +``` + +Note: Husky and lint-staged are configured to run these checks pre-commit. + +### Deployment +```bash +pnpm run deploy # Deploy to GitHub Pages (gh-pages -d dist) +``` + +## Architecture & Code Structure + +### SPA Implementation +This project is built as a vanilla JavaScript SPA. The main entry point is `src/main.js`, which contains embedded HTML template strings for different UI states. The templates are rendered by setting `document.body.innerHTML`. + +**Key Template States (in main.js):** +- `상품목록_레이아웃_로딩` - Product list loading state +- `상품목록_레이아웃_로딩완료` - Product list loaded state +- `상품목록_레이아웃_카테고리_1Depth` - Category 1st depth selected +- `상품목록_레이아웃_카테고리_2Depth` - Category 2nd depth selected +- `장바구니_비어있음` - Empty cart modal +- `장바구니_선택없음` - Cart with no items selected +- `장바구니_선택있음` - Cart with items selected +- `상세페이지_로딩` - Product detail loading state +- `상세페이지_로딩완료` - Product detail loaded state +- `토스트` - Toast notifications +- `_404_` - 404 error page + +### API Layer +- **`src/api/productApi.js`** - Contains API calls for fetching product data +- All API requests go through `/api/products` endpoints + +### Mock Service Worker (MSW) +The project uses MSW for API mocking during development: +- **`src/mocks/browser.js`** - Browser worker setup +- **`src/mocks/handlers.js`** - API endpoint handlers with 200ms delay +- **`src/mocks/items.json`** - Mock product data + +**API Endpoints (mocked):** +- `GET /api/products` - List products with filtering, sorting, pagination +- `GET /api/products/:id` - Get product detail by ID +- `GET /api/categories` - Get category hierarchy (1depth & 2depth) + +**Query Parameters:** +- `page` or `current` - Page number (default: 1) +- `limit` - Items per page (default: 20) +- `search` - Search term +- `category1` - 1st level category filter +- `category2` - 2nd level category filter +- `sort` - Sorting: `price_asc`, `price_desc`, `name_asc`, `name_desc` + +### E2E Test Structure +Tests are located in `e2e/` directory: +- **`e2e/e2e.basic.spec.js`** - Basic functionality tests (상품목록, 장바구니, 상품 상세, 사용자 피드백) +- **`e2e/e2e.advanced.spec.js`** - Advanced tests (SPA navigation, URL management, state persistence) +- **`e2e/E2EHelpers.js`** - Test helper functions + +The Playwright config (`playwright.config.js`) automatically starts the dev server on port 5173 before running tests. + +## Important Project Requirements + +### Assignment Checklist +The project is evaluated against a comprehensive checklist in `.github/pull_request_template.md` covering: + +**Basic Requirements (기본과제):** +1. Product list with loading states, error handling, infinite scroll +2. Shopping cart modal with full CRUD operations, selection, quantity adjustment +3. Product detail page with related products +4. Search and filtering (2-depth category hierarchy) +5. Sorting and pagination +6. Toast notifications + +**Advanced Requirements (심화과제):** +1. Full SPA navigation without page refresh (including back/forward) +2. URL state management (all filters, search, sort in query params) +3. State persistence on refresh (cart in localStorage, URL params restored) +4. 404 page handling +5. AI-assisted reimplementation challenge + +### Technology Stack +- **Build Tool:** Vite (using `rolldown-vite` variant) +- **Styling:** TailwindCSS (loaded via CDN in index.html) +- **Testing:** Playwright for E2E +- **Mocking:** MSW (Mock Service Worker) +- **Language:** Pure JavaScript (no TypeScript, no frameworks) +- **Code Quality:** ESLint + Prettier with Husky pre-commit hooks + +### Key Architectural Patterns +1. **Template-based rendering** - UI states are defined as template literal strings in main.js +2. **Event delegation** - Event handlers should be attached to handle dynamically rendered elements +3. **Manual routing** - SPA routing needs to be implemented without a router library +4. **LocalStorage integration** - Cart state must persist across page refreshes +5. **URL as state** - Search/filter/sort state syncs with URL query parameters + +## Development Tips + +### When Working with Templates +The current architecture uses large template strings in `main.js`. When modifying UI: +1. Locate the appropriate template constant (e.g., `상품목록_레이아웃_로딩완료`) +2. Modify the HTML within that template string +3. Ensure data attributes (like `data-product-id`) are maintained for event handling + +### When Adding New Features +1. Consider which template states need updates +2. Add necessary API mocking in `src/mocks/handlers.js` if new endpoints needed +3. Update E2E tests to cover new functionality +4. Update the PR template checklist if adding new requirements + +### Common Patterns to Follow +- Use `data-*` attributes for element identification in event handlers +- Implement debouncing for search inputs +- Show loading skeletons during data fetches +- Display toast notifications for user actions +- Handle error states gracefully with retry options + +## GitHub Actions +- **`.github/workflows/gh-pages.yml`** - Deploys to GitHub Pages on push +- Uses `peaceiris/actions-gh-pages@v3` to publish the `dist/` directory diff --git a/README.md b/README.md deleted file mode 100644 index 6b476381..00000000 --- a/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# 상세 기능 요구사항 명세서 - -## 1. 상품 목록 - -### 1.1 상품 목록 로딩 -- **초기 로딩**: 페이지 접속 시 로딩 상태 표시 -- **로딩 완료**: 데이터 로드 후 상품 목록 렌더링 -- **로딩 실패**: 에러 발생 시 재시도 옵션 제공 - -### 1.2 상품 목록 조회 -- **상품 정보 표시**: 각 상품의 기본 정보 (이미지, 상품명, 가격 등) 카드 형태로 표시 -- **목록 레이아웃**: 그리드 형태로 상품들을 정렬하여 표시 - -### 1.3 한 페이지에 보여질 상품 수 선택 (10, 20, 50, 100) -- **선택 옵션**: 드롭다운 또는 라디오 버튼으로 10, 20, 50, 100개 중 선택 -- **기본값**: 특정 개수를 기본값으로 설정 -- **적용**: 선택 변경 시 즉시 목록에 반영 - -### 1.4 무한 스크롤을 이용한 페이지네이션 -- **스크롤 감지**: 페이지 하단 근처 도달 시 다음 페이지 데이터 자동 로드 -- **연속 로딩**: 스크롤에 따라 계속해서 새로운 상품들을 목록에 추가 -- **로딩 표시**: 새 데이터 로드 중일 때 로딩 인디케이터 표시 - -### 1.5 상품을 장바구니에 담을 수 있음 -- **장바구니 버튼**: 각 상품에 장바구니 추가 버튼 배치 -- **추가 동작**: 버튼 클릭 시 해당 상품이 장바구니에 추가됨 -- **피드백**: 추가 완료 시 사용자에게 알림 - -### 1.6 상품 검색 -- **검색 입력창**: 상품명 기반 검색을 위한 텍스트 입력 필드 -- **검색 실행**: 입력 후 검색 버튼 클릭 또는 Enter 키로 검색 수행 -- **검색 결과**: 검색어와 일치하는 상품들만 목록에 표시 - -### 1.7 카테고리 선택 -- **카테고리 목록**: 사용 가능한 카테고리들을 선택할 수 있는 UI 제공 -- **필터링**: 선택된 카테고리에 해당하는 상품들만 표시 -- **카테고리 해제**: 전체 상품 보기로 돌아갈 수 있는 옵션 - -### 1.8 현재 상품 수 -- **상품 개수 표시**: 현재 조건에서 조회된 총 상품 수를 화면에 표시 -- **실시간 업데이트**: 검색이나 필터 적용 시 상품 수도 함께 업데이트 - -## 2. 장바구니 - -### 2.1 장바구니 수량 조절 -- **수량 변경**: 각 장바구니 상품의 수량을 증가/감소할 수 있는 버튼 또는 입력 필드 -- **즉시 반영**: 수량 변경 시 총 금액 등 관련 정보가 실시간으로 업데이트 - -### 2.2 장바구니 삭제 -- **개별 삭제**: 각 상품에 대해 장바구니에서 제거하는 기능 -- **삭제 버튼**: 각 상품 항목에 삭제 버튼 배치 - -### 2.3 장바구니 선택 삭제 -- **체크박스**: 각 상품에 선택을 위한 체크박스 제공 -- **선택 삭제 버튼**: 체크된 상품들을 일괄 삭제하는 버튼 -- **선택된 항목만 삭제**: 체크된 상품들만 장바구니에서 제거 - -### 2.4 장바구니 전체 선택 -- **전체 선택 체크박스**: 모든 상품을 한 번에 선택/해제할 수 있는 마스터 체크박스 -- **일괄 선택**: 전체 선택 시 모든 상품의 체크박스가 선택됨 -- **일괄 해제**: 전체 해제 시 모든 상품의 체크박스가 해제됨 - -### 2.5 장바구니 비우기 -- **전체 삭제**: 장바구니에 있는 모든 상품을 한 번에 삭제하는 기능 -- **확인 절차**: 전체 삭제 전 사용자 확인 과정 - -## 3. 상품 상세 - -### 3.1 상품 클릭시 모달로 조회 가능 -- **모달 오픈**: 상품 목록에서 상품을 클릭하면 상세 정보를 보여주는 모달 창 열림 -- **상세 정보**: 모달 내에서 상품의 자세한 정보 표시 - -### 3.2 모달 -- **모달 창**: 상품 상세 정보를 담은 오버레이 형태의 팝업 창 -- **상세 내용**: 상품 이미지, 설명, 가격 등의 상세 정보 표시 - -## 4. 편의성 - -### 4.1 상품 목록 - URL 쿼리 반영 -- **검색 조건 저장**: 검색어, 카테고리 선택, 상품 옵션을 URL 쿼리 파라미터에 저장 -- **URL 업데이트**: 사용자가 조건을 변경할 때마다 URL이 자동으로 업데이트됨 -- **공유 가능**: URL을 통해 현재 검색/필터 상태를 다른 사람과 공유 가능 - -### 4.2 상품 목록 - 새로고침 시 상태 유지 -- **상태 복원**: 새로고침 후 URL 쿼리를 읽어서 이전의 검색어, 카테고리, 옵션 설정을 복원 -- **데이터 복원**: 복원된 조건에 맞는 상품 데이터를 다시 로드하여 표시 - -### 4.3 장바구니 - 새로고침 시 데이터 유지 -- **데이터 저장**: 장바구니 내용을 브라우저에 저장 (로컬스토리지 등) -- **데이터 복원**: 새로고침 후에도 이전 장바구니 내용을 그대로 유지 - -### 4.4 상품 상세 - URL 쿼리에 ID 반영 -- **상품 ID 저장**: 상품 상세 모달 열릴 때 해당 상품의 ID를 URL 쿼리에 추가 -- **직접 접근**: URL로 직접 접근 시 해당 상품의 상세 모달이 자동으로 열림 - -### 4.5 상품 상세 - 새로고침시 유지 -- **모달 상태 유지**: 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 모달을 다시 열어줌 - -### 4.6 상품 상세 - ESC로 닫기 -- **키보드 제어**: ESC 키를 누르면 열린 상품 상세 모달이 닫힘 - -### 4.7 상품 상세 - 배경 클릭시 닫기 -- **마우스 제어**: 모달 외부 배경 영역을 클릭하면 모달이 닫힘 - -### 4.8 상품 상세 - 방향키로 다음 상품 선택 -- **키보드 네비게이션**: 좌우 방향키를 사용해서 이전/다음 상품의 상세 모달로 이동 -- **순차 이동**: 현재 상품 목록 순서에 따라 이전/다음 상품으로 전환 - -### 4.9 상품 상세 - 다음 페이지 상품 접근 시 페이지네이션 실행 -- **자동 페이지 로드**: 현재 페이지의 마지막 상품에서 다음 상품으로 이동 시, 다음 페이지를 자동으로 로드 -- **연속 탐색**: 페이지 경계를 넘나들며 연속적으로 상품 상세를 탐색 가능 - -### 4.10 상품 상세 - 장바구니 담기 -- **모달 내 장바구니**: 상품 상세 모달 안에서도 해당 상품을 장바구니에 추가할 수 있는 기능 -- **수량 선택**: 모달 내에서 수량을 선택하여 장바구니에 추가 diff --git a/cart-modal.html b/cart-modal.html new file mode 100644 index 00000000..66455103 --- /dev/null +++ b/cart-modal.html @@ -0,0 +1,320 @@ + + + + + + + 상품 쇼핑몰 + + + + + + +
+
+
+
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+
+
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
+ 총 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..e80014c0 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,14 @@ "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", + "deploy": "gh-pages -d dist" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -28,13 +27,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 +44,11 @@ "msw": "^2.10.2", "prettier": "^3.4.2", "vite": "npm:rolldown-vite@latest", - "vitest": "^2.1.8" + "vitest": "latest" }, "msw": { "workerDirectory": [ "public" ] } -} \ No newline at end of file +} diff --git a/playwright.config.js b/playwright.config.js index 53255d73..dd40de25 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -18,7 +18,7 @@ export default defineConfig({ }, ], webServer: { - command: "npm run dev", + command: "pnpm run dev", port: 5173, reuseExistingServer: !process.env.CI, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7aa93df..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/public/404.html b/public/404.html new file mode 100644 index 00000000..0e4f3308 --- /dev/null +++ b/public/404.html @@ -0,0 +1,36 @@ + + + + + + + + + diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index d2b72964..b1f186b6 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -100,10 +100,7 @@ addEventListener("fetch", function (event) { // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === "only-if-cached" && - event.request.mode !== "same-origin" - ) { + if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") { return; } @@ -219,9 +216,7 @@ async function getResponse(event, client, requestId) { const acceptHeader = headers.get("accept"); if (acceptHeader) { const values = acceptHeader.split(",").map((value) => value.trim()); - const filteredValues = values.filter( - (value) => value !== "msw/passthrough", - ); + const filteredValues = values.filter((value) => value !== "msw/passthrough"); if (filteredValues.length > 0) { headers.set("accept", filteredValues.join(", ")); @@ -291,10 +286,7 @@ function sendToClient(client, message, transferrables = []) { resolve(event.data); }; - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]); + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); }); } diff --git a/requirement.md b/requirement.md new file mode 100644 index 00000000..4e68bf8c --- /dev/null +++ b/requirement.md @@ -0,0 +1,190 @@ +# 요구사항 명세서 + +## 상품목록 + +### 상품 목록 로딩 + +- 페이지 접속 시 로딩 상태가 표시된다 +- 데이터 로드 완료 후 상품 목록이 렌더링된다 +- 로딩 실패 시 에러 상태가 표시된다 +- 에러 발생 시 재시도 버튼이 제공된다 + +### 상품 목록 조회 + +- 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 + +### 한 페이지에 보여질 상품 수 선택 + +- 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. +- 선택 변경 시 즉시 목록에 반영된다 + +### 상품 정렬 기능 + +- 상품을 가격순/인기순으로 오름차순/내림차순 정렬을 할 수 있다. +- 드롭다운을 통해 정렬 기준을 선택할 수 있다 +- 정렬 변경 시 즉시 목록에 반영된다 + +### 무한 스크롤 페이지네이션 + +- 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 +- 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 +- 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 +- 홈 페이지에서만 무한 스크롤이 활성화된다 + +### 상품을 장바구니에 담기 + +- 각 상품에 장바구니 추가 버튼이 있다 +- 버튼 클릭 시 해당 상품이 장바구니에 추가된다 +- 추가 완료 시 사용자에게 알림이 표시된다 + +### 상품 검색 + +- 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- 검색 버튼 클릭으로 검색이 수행된다 +- Enter 키로 검색이 수행된다 +- 검색어와 일치하는 상품들만 목록에 표시된다 + +### 카테고리 선택 + +- 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- 선택된 카테고리에 해당하는 상품들만 표시된다 +- 전체 상품 보기로 돌아갈 수 있다 +- 2단계 카테고리 구조를 지원한다 (1depth, 2depth) + +### 카테고리 네비게이션 + +- 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 + +### 현재 상품 수 표시 + +- 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 + +## 장바구니 + +### 장바구니 모달 + +- 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- ESC 키로 모달을 닫을 수 있다 +- 모달에서 장바구니의 모든 기능을 사용할 수 있다 + +### 장바구니 수량 조절 + +- 각 장바구니 상품의 수량을 증가할 수 있다 +- 각 장바구니 상품의 수량을 감소할 수 있다 +- 수량 변경 시 총 금액이 실시간으로 업데이트된다 + +### 장바구니 삭제 + +- 각 상품에 삭제 버튼이 배치되어 있다 +- 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 + +### 장바구니 선택 삭제 + +- 각 상품에 선택을 위한 체크박스가 제공된다 +- 선택 삭제 버튼이 있다 +- 체크된 상품들만 일괄 삭제된다 + +### 장바구니 전체 선택 + +- 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- 전체 선택 시 모든 상품의 체크박스가 선택된다 +- 전체 해제 시 모든 상품의 체크박스가 해제된다 + +### 장바구니 비우기 + +- 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 + +## 상품 상세 + +### 상품 클릭시 상세 페이지 이동 + +- 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- URL이 `/product/{productId}` 형태로 변경된다 +- 상품의 자세한 정보가 전용 페이지에서 표시된다 + +### 상품 상세 페이지 기능 + +- 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 + +### 상품 상세 - 장바구니 담기 + +- 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- 수량 증가/감소 버튼이 제공된다 + +### 관련 상품 기능 + +- 상품 상세 페이지에서 관련 상품들이 표시된다 +- 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- 현재 보고 있는 상품은 관련 상품에서 제외된다 + +### 상품 상세 페이지 내 네비게이션 + +- 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 + +## 사용자 피드백 시스템 + +### 토스트 메시지 + +- 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- 토스트는 3초 후 자동으로 사라진다 +- 토스트에 닫기 버튼이 제공된다 +- 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) + +### 에러 처리 + +- 네트워크 오류 등 에러 발생 시 사용자에게 적절한 메시지가 표시된다 +- 에러 상황에서 재시도할 수 있는 버튼이 제공된다 +- 에러 상태가 UI에 적절히 반영된다 + +## SPA 네비게이션 및 URL 관리 + +### 페이지 이동 + +- 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. + +### 상품 목록 - URL 쿼리 반영 + +- 검색어가 URL 쿼리 파라미터에 저장된다 +- 카테고리 선택이 URL 쿼리 파라미터에 저장된다 +- 상품 옵션이 URL 쿼리 파라미터에 저장된다 +- 정렬 조건이 URL 쿼리 파라미터에 저장된다 +- 조건 변경 시 URL이 자동으로 업데이트된다 +- URL을 통해 현재 검색/필터 상태를 공유할 수 있다 + +### 상품 목록 - 새로고침 시 상태 유지 + +- 새로고침 후 URL 쿼리에서 검색어가 복원된다 +- 새로고침 후 URL 쿼리에서 카테고리가 복원된다 +- 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 +- 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 +- 복원된 조건에 맞는 상품 데이터가 다시 로드된다 + +### 장바구니 - 새로고침 시 데이터 유지 + +- 장바구니 내용이 브라우저에 저장된다 +- 새로고침 후에도 이전 장바구니 내용이 유지된다 +- 장바구니의 선택 상태도 함께 유지된다 + +### 상품 상세 - URL에 ID 반영 + +- 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) +- URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 + +### 상품 상세 - 새로고침시 유지 + +- 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 + +### 404 페이지 + +- 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 +- 홈으로 돌아가기 버튼이 제공된다 diff --git a/src/App.js b/src/App.js new file mode 100644 index 00000000..00f3fe31 --- /dev/null +++ b/src/App.js @@ -0,0 +1,25 @@ +import { router } from "./router/Router.js"; +import { store } from "./store/store.js"; + +import { Header } from "./components/Header.js"; +import { Modal } from "./components/Modal.js"; +import { Toast } from "./components/Toast.js"; +import { Footer } from "./components/Footer.js"; + +export default function App() { + // 현재 라우트 + const { path, render } = router.getCurrentRoute(); + const { cart, toast } = store.getState(); + + return /*html*/ ` +
+ ${Header({ path })} +
+ ${cart.isOpen ? Modal() : ""} + ${render()} +
+ ${toast.isOpen ? Toast() : ""} + ${Footer()} +
+ `; +} diff --git a/src/api/productApi.js b/src/api/productApi.js new file mode 100644 index 00000000..bbdea046 --- /dev/null +++ b/src/api/productApi.js @@ -0,0 +1,30 @@ +// 상품 목록 조회 +export async function getProducts(params = {}) { + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.current ?? params.page ?? 1; + + const searchParams = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + ...(search && { search }), + ...(category1 && { category1 }), + ...(category2 && { category2 }), + sort, + }); + + const response = await fetch(`/api/products?${searchParams}`); + + return await response.json(); +} + +// 상품 상세 조회 +export async function getProduct(productId) { + const response = await fetch(`/api/products/${productId}`); + return await response.json(); +} + +// 카테고리 목록 조회 +export async function getCategories() { + const response = await fetch("/api/categories"); + return await response.json(); +} diff --git a/src/components/Filter.js b/src/components/Filter.js new file mode 100644 index 00000000..bde76b2e --- /dev/null +++ b/src/components/Filter.js @@ -0,0 +1,118 @@ +import { store } from "../store/store.js"; +import { useEffect } from "../hooks/useEffect.js"; +import { getCategories } from "../api/productApi.js"; + +const fetchCategoriesEffect = () => { + const { categories } = store.getState(); + if (categories.size === 0) { + store.setState({ isLoading: true }); + getCategories().then((data) => { + const newCategories = new Map(); + + Object.entries(data).forEach(([key, v]) => { + const value = Object.keys(v); + newCategories.set(key, value); + }); + + store.setState({ categories: newCategories, isLoading: false }); + }); + } +}; + +export const Filter = () => { + const { isLoading, products } = store.getState(); + const category1List = new Set(products.map((product) => product.category1)); + + // 첫 진입 시 카테고리 목록 갱신 + useEffect(fetchCategoriesEffect, []); + + return /*html*/ ` +
+ +
+
+ + ${Filter.Breadcrumb()} +
+ +
+ ${isLoading ? /*html*/ `
카테고리 로딩 중...
` : [...category1List, "디지털/가전"].map((category1) => Filter.Category1(category1)).join("")} +
+ +
+ +
+ +
+ + ${Filter.Limit()} +
+ +
+ + ${Filter.Sort()} +
+
+
+ `; +}; + +Filter.Breadcrumb = () => { + return /*html*/ ` + + `; +}; + +Filter.Category1 = (category1) => { + return /*html*/ ` + + `; +}; + +Filter.Limit = () => { + const pagination = store.getState("pagination"); + const limitList = [10, 20, 50, 100]; + + return /*html*/ ` + + `; +}; + +Filter.Sort = () => { + const { sort } = store.getState("filters"); + const sortList = { + price_asc: "가격 낮은순", + price_desc: "가격 높은순", + name_asc: "이름순", + name_desc: "이름 역순", + }; + + return /*html*/ ` + + `; +}; diff --git a/src/components/Footer.js b/src/components/Footer.js new file mode 100644 index 00000000..14d2c15a --- /dev/null +++ b/src/components/Footer.js @@ -0,0 +1,59 @@ +import { useEffect } from "../hooks/useEffect.js"; +import { store } from "../store/store.js"; +import { router } from "../router/Router.js"; + +export const Footer = () => { + useEffect(() => { + const targetElement = document.querySelector("footer"); + + if (!targetElement) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const { path } = router.getCurrentRoute(); + if (path !== "/") return; + + const { isLoading, pagination } = store.getState(); + + // 로딩 중이거나 다음 페이지가 없으면 스킵 + if (isLoading || !pagination.hasNext) { + return; + } + + // 페이지만 증가시키면 Home.js의 useEffect가 자동으로 데이터를 fetch함 + store.setState({ + pagination: { + ...pagination, + page: pagination.page + 1, + }, + }); + } + }); + }, + { + root: null, + rootMargin: "100px", + threshold: 0.1, + }, + ); + + observer.observe(targetElement); + + // cleanup 함수 반환 + return () => { + observer.disconnect(); + }; + }, []); + + return /*html*/ ` + + `; +}; diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 00000000..65cb626f --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,59 @@ +import { store } from "../store/store.js"; + +export const Header = ({ path }) => { + const isDetailPage = path.includes("/product/"); + + return /*html*/ ` +
+
+
+ ${Title({ isDetail: isDetailPage })} +
+ + ${CartBtn()} +
+
+
+
+ `; +}; + +export const Title = ({ isDetail = false }) => { + return isDetail + ? /*html*/ ` +
+ +

상품 상세

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

+ 쇼핑몰 +

+ `; +}; + +export const CartBtn = () => { + const { list } = store.getState("cart"); + return /*html*/ ` + + `; +}; diff --git a/src/components/Loading.js b/src/components/Loading.js new file mode 100644 index 00000000..293584cc --- /dev/null +++ b/src/components/Loading.js @@ -0,0 +1,25 @@ +import { store } from "../store/store.js"; + +export const Loading = () => { + const { hasNext } = store.getState("pagination"); + const className = hasNext ? "text-center py-4" : "text-center py-4 text-sm text-gray-500"; + + return /*html*/ ` +
+ ${hasNext ? Loading.HasNext() : "모든 상품을 확인했습니다"} +
+ `; +}; + +Loading.HasNext = () => { + return /*html*/ ` +
+ + + + + 상품을 불러오는 중... +
+ `; +}; diff --git a/src/components/Modal.js b/src/components/Modal.js new file mode 100644 index 00000000..9fc7e026 --- /dev/null +++ b/src/components/Modal.js @@ -0,0 +1,183 @@ +export const Modal = () => { + return /*html*/ ` +
+ +
+ + ${Modal.Contents()} +
+ `; +}; + +Modal.Contents = () => { + return /*html*/ ` +
+
+ +
+

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

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

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

+

+ 220원 +

+ +
+ + + +
+
+ +
+

+ 440원 +

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

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

+

+ 230원 +

+ +
+ + + +
+
+ +
+

+ 230원 +

+ +
+
+
+
+
+ +
+ +
+ 선택한 상품 (1개) + 440원 +
+ +
+ 총 금액 + 670원 +
+ +
+ +
+ + +
+
+
+
+
+ `; +}; + +Modal.CartItem = () => { + return /*html*/ ` +
+ + +
+ `; +}; diff --git a/src/components/ProductItem.js b/src/components/ProductItem.js new file mode 100644 index 00000000..fde1fdc7 --- /dev/null +++ b/src/components/ProductItem.js @@ -0,0 +1,37 @@ +import { store } from "../store/store.js"; +export const ProductItem = () => { + const { products } = store.getState(); + + return products + .map((product) => { + return /*html*/ ` +
+ +
+ ${product.title} +
+ +
+
+

+ ${product.title} +

+

+

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

+
+ + +
+
+ `; + }) + .join(""); +}; diff --git a/src/components/ProductList.js b/src/components/ProductList.js new file mode 100644 index 00000000..e0a05afb --- /dev/null +++ b/src/components/ProductList.js @@ -0,0 +1,44 @@ +import { Loading } from "./Loading.js"; +import { store } from "../store/store.js"; +import { ProductSkeleton } from "./ProductSkeleton.js"; +import { ProductItem } from "./ProductItem.js"; + +export const ProductList = () => { + const { + products, + isLoading, + pagination: { hasNext, total }, + } = store.getState(); + + return /*html*/ ` +
+
+ + ${products.length > 0 ? ProductList.TotalCount({ total }) : ""} + + ${ProductList.Container({ + children: /*html*/ ` + ${isLoading && products.length === 0 ? ProductSkeleton({ length: 4 }) : ProductItem()} + `, + })} + ${isLoading || !hasNext ? Loading() : ""} +
+
+ `; +}; + +ProductList.Container = ({ children }) => { + return /*html*/ ` +
+ ${children} +
+ `; +}; + +ProductList.TotalCount = ({ total }) => { + return /*html*/ ` +
+ 총 ${total}개의 상품 +
+ `; +}; diff --git a/src/components/ProductSkeleton.js b/src/components/ProductSkeleton.js new file mode 100644 index 00000000..2c397fa8 --- /dev/null +++ b/src/components/ProductSkeleton.js @@ -0,0 +1,14 @@ +export const ProductSkeleton = ({ length = 1 }) => { + return /*html*/ ` + +
+
+
+
+
+
+
+
+
+ `.repeat(length); +}; diff --git a/src/components/Search.js b/src/components/Search.js new file mode 100644 index 00000000..72faa995 --- /dev/null +++ b/src/components/Search.js @@ -0,0 +1,31 @@ +import { store } from "../store/store.js"; +import { Filter } from "./Filter.js"; +export const Search = () => { + return /*html*/ ` +
+ +
+ ${Search.InputBox()} +
+ + ${Filter()} +
+ `; +}; + +Search.InputBox = () => { + const { search } = store.getState("filters"); + + return /*html*/ ` +
+ +
+ + + +
+
+ `; +}; diff --git a/src/components/Toast.js b/src/components/Toast.js new file mode 100644 index 00000000..f0f3a2f0 --- /dev/null +++ b/src/components/Toast.js @@ -0,0 +1,87 @@ +import { store } from "../store/store.js"; +import { useEffect } from "../hooks/useEffect.js"; + +/** + * @param {'success' | 'info' | 'error'} type - 토스트 타입 (success, info, error) + */ +export const Toast = () => { + const { isOpen, type } = store.getState("toast"); + + const stateOfToast = { + success: Toast.Success, + info: Toast.Info, + error: Toast.Error, + }; + + useEffect(() => { + if (type) { + const timeout = setTimeout(() => { + store.setState({ toast: { isOpen: false } }); + }, 3000); + + return () => clearTimeout(timeout); + } + }, [isOpen]); + + return /*html*/ ` +
+
+ ${stateOfToast[type]()} +
+
+ `; +}; + +Toast.Success = () => { + return /*html*/ ` +
+
+ + + +
+

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

+ +
+ `; +}; + +Toast.Info = () => { + return /*html*/ ` +
+
+ + + +
+

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

+ +
+ `; +}; + +Toast.Error = () => { + return /*html*/ ` +
+
+ + + +
+

오류가 발생했습니다.

+ +
+ `; +}; diff --git a/src/eventHandler.js b/src/eventHandler.js new file mode 100644 index 00000000..32566b68 --- /dev/null +++ b/src/eventHandler.js @@ -0,0 +1,210 @@ +import { router } from "./router/Router.js"; +import { store } from "./store/store.js"; + +import { getProducts, getProduct } from "./api/productApi.js"; + +/** + * 전역 이벤트 핸들러 (한 번만 등록) + */ +export const initGlobalEventHandlers = () => { + // document에 단 한 번만 이벤트 리스너 등록 + document.addEventListener("click", handleGlobalClick); + document.addEventListener("change", handleGlobalChange); + document.addEventListener("keydown", handleGlobalKeyDown); +}; + +const handleRetryFetch = () => { + store.setState({ isLoading: true, isError: false }); + getProducts() + .then((data) => { + store.setState({ ...data, isLoading: false }); + }) + .catch(() => { + store.setState({ isError: true, isLoading: false, toast: { isOpen: true, type: "error" } }); + }); +}; + +const handleGetProductFetch = (productId) => { + store.setState({ isLoading: true, isError: false }); + getProduct(productId) + .then((selectedProduct) => { + store.setState({ selectedProduct, isLoading: false }); + }) + .catch(() => { + store.setState({ isError: true, isLoading: false, toast: { isOpen: true, type: "error" } }); + }); +}; + +const handleGlobalClick = (e) => { + const target = e.target; + + if (target.matches("#app-title")) { + console.log("first"); + store.setState({ + filters: { search: "", category1: "", category2: "", sort: "price_asc" }, + pagination: { page: 1, limit: 20 }, + }); + router.navigate("/"); + return; + } + + // 뒤로가기 버튼 + if (target.closest("#back-btn")) { + window.history.back(); + } + + // 다시 시도 버튼 + if (target.closest("#retry-fetch-btn")) { + handleRetryFetch(); + return; + } + + // 장바구니 열기 버튼 + if (target.closest("#cart-open-btn")) { + const cart = store.getState("cart"); + if (cart.isOpen) return; + + store.setState({ cart: { ...cart, isOpen: true } }); + return; + } + + // 장바구니 닫기 버튼 + if (target.closest("#cart-modal-close-btn") || target.closest(".cart-modal-overlay")) { + const cart = store.getState("cart"); + if (!cart.isOpen) return; + + store.setState({ cart: { ...cart, isOpen: false } }); + return; + } + + // 장바구니 담기 버튼 + if (target.closest(".add-to-cart-btn") || target.closest("#add-to-cart-btn")) { + e.stopPropagation(); + addCartItem(target.dataset.productId); + store.setState({ toast: { isOpen: true, type: "success" } }); + } + + // 상품 카드 클릭 + if (target.closest(".product-card")) { + if (target.closest(".add-to-cart-btn")) return; + const productCard = target.closest("[data-product-id]"); + const productId = productCard.dataset.productId; + + handleGetProductFetch(productId); + router.navigate(`product/${productId}`); + return; + } + + // 상품 목록으로 돌아가기 + if (target.closest(".go-to-product-list")) { + router.navigate(import.meta.env.BASE_URL === "/" ? "/" : ""); + return; + } + + // 토스트 닫기 + if (target.closest("#toast-close-btn")) { + store.setState({ toast: { isOpen: false } }); + return; + } + + // 수량 증가 + if (target.matches("#quantity-increase")) { + const cart = store.getState("cart"); + store.setState({ cart: { ...cart, quantity: cart.quantity + 1 } }); + } + + // 수량 감소 + if (target.matches("#quantity-decrease")) { + const cart = store.getState("cart"); + if (cart.quantity <= 1) return; + + store.setState({ cart: { ...cart, quantity: cart.quantity - 1 } }); + } +}; + +const addCartItem = (targetId) => { + const { products, cart } = store.getState(); + if (cart.list.get(targetId)) return; + + const { image, lprice, title } = products.find(({ productId }) => productId === targetId); + const newList = cart.list.set(targetId, { + productId: targetId, + image, + lprice, + title, + quantity: (cart.list.get(targetId)?.quantity || 0) + cart.quantity, + selected: false, + }); + + store.setState({ + cart: { ...cart, quantity: 1, list: newList }, + toast: { isOpen: true, type: "success" }, + }); +}; + +const handleGlobalChange = (e) => { + const target = e.target; + + // 페이지당 상품 수 변경 + if (target.matches("#limit-select")) { + const pagination = store.getState("pagination"); + const limit = +target.value; + + if (pagination.limit === limit) return; + + store.setState({ pagination: { ...pagination, limit } }); + return; + } + + // 정렬 변경 + if (target.matches("#sort-select")) { + const filters = store.getState("filters"); + + if (filters.sort === target.value) return; + + store.setState({ filters: { ...filters, sort: target.value } }); + return; + } + + // 검색창 입력 + if (target.matches("#search-input")) { + handleSearch(target.value); + } +}; + +const handleSearch = (value) => { + const filters = store.getState("filters"); + if (filters.search === value) return; + store.setState({ filters: { ...filters, search: value } }); +}; + +// input 이벤트는 별도로 관리 +export const initInputHandlers = () => { + document.addEventListener("input", handleGlobalInput); +}; + +const handleGlobalKeyDown = (e) => { + const target = e.target; + + // 검색창 엔터 입력 + if (target.matches("#search-input")) { + if (e.key === "Enter") { + handleSearch(target.value); + } + } +}; + +const handleGlobalInput = (e) => { + const target = e.target; + + // 수량 입력 + if (target.matches("#quantity-input")) { + const productId = target.dataset.productId; + const quantity = +target.value; + const cart = store.getState("cart"); + const cartItem = cart.list.get(productId); + if (!cartItem) return; + const newList = cart.list.set(productId, { ...cartItem, quantity }); + store.setState({ cart: { ...cart, list: newList } }); + } +}; diff --git a/src/hooks/useEffect.js b/src/hooks/useEffect.js new file mode 100644 index 00000000..24e97a8e --- /dev/null +++ b/src/hooks/useEffect.js @@ -0,0 +1,46 @@ +let effectQueue = []; +let isExecuting = false; +let prevDependencies = new WeakMap(); // callback 함수를 키로 사용 +let cleanupFunctions = new WeakMap(); // cleanup 함수 저장 + +export const useEffect = (callback, dependencies) => { + effectQueue.push({ callback, dependencies }); +}; + +export const flushEffects = () => { + if (isExecuting) return; + + isExecuting = true; + Promise.resolve().then(() => { + const effects = [...effectQueue]; + effectQueue = []; + + effects.forEach(({ callback, dependencies }) => { + const prevDeps = prevDependencies.get(callback); + + // 의존성 비교: 이전 값이 없거나, 하나라도 변경되었으면 실행 + const shouldRun = + !prevDeps || + !dependencies || // 의존성 배열이 없으면 항상 실행 + dependencies.length !== prevDeps.length || + dependencies.some((dep, index) => dep !== prevDeps[index]); + + if (shouldRun) { + // 이전 cleanup 함수 실행 + const cleanup = cleanupFunctions.get(callback); + if (cleanup) { + cleanup(); + } + + // effect 실행 및 cleanup 저장 + const newCleanup = callback(); + if (typeof newCleanup === "function") { + cleanupFunctions.set(callback, newCleanup); + } + + prevDependencies.set(callback, dependencies); + } + }); + isExecuting = false; + }); +}; diff --git a/src/main.js b/src/main.js index 983c051f..00ed102f 100644 --- a/src/main.js +++ b/src/main.js @@ -1,19 +1,622 @@ -import { worker } from "./mocks/browser.js"; - -// 개발 환경에서만 MSW 워커 시작 -async function enableMocking() { - if (import.meta.env.DEV) { - return worker.start({ - onUnhandledRequest: "bypass", // 처리되지 않은 요청은 그대로 통과 - }); +import App from "./App.js"; +import { router } from "./router/Router.js"; +import { store } from "./store/store.js"; +import { render } from "./render.js"; +import { initGlobalEventHandlers, initInputHandlers } from "./eventHandler.js"; + +// import { getProduct, getCategories } from "./api/productApi.js"; +const enableMocking = () => + import("./mocks/browser.js").then(({ worker }) => + worker.start({ + onUnhandledRequest: "bypass", + serviceWorker: { + url: import.meta.env.BASE_URL + "mockServiceWorker.js", + }, + }), + ); + +function main() { + // store 변경 시 렌더링 처리 + store.subscribe(() => { + render(App); + }); + + // 라우터 초기화 + router.init(() => { + render(App); + }); + + // 초기 렌더링 실행 + render(App); + + // 전역 이벤트 핸들러 등록 + initGlobalEventHandlers(); + initInputHandlers(); + + // vv 탬플릿들, 작업 종료 후 삭제 예정 vv + + 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원 +
+ +
+ +
+ + +
+
+
+
+
+ `; + + if (window.location.pathname.includes("pub")) { + document.body.innerHTML = ` +
상품목록_레이아웃_카테고리_1Depth
+ ${상품목록_레이아웃_카테고리_1Depth} +
+
상품목록_레이아웃_카테고리_2Depth
+ ${상품목록_레이아웃_카테고리_2Depth} +
+
토스트
+ ${토스트} +
+
장바구니_비어있음
+ ${장바구니_비어있음} +
+
장바구니_선택없음
+ ${장바구니_선택없음} +
+
장바구니_선택있음
+ ${장바구니_선택있음} +
+ + `; } } -// 앱 초기화 -async function initApp() { - // MSW 워커 시작 - await enableMocking(); +// 애플리케이션 시작 +if (import.meta.env.MODE !== "test") { + enableMocking().then(main); +} else { + main(); } - -// 앱 시작 -initApp().catch(console.error); 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/Home.js b/src/pages/Home.js new file mode 100644 index 00000000..389f5bc2 --- /dev/null +++ b/src/pages/Home.js @@ -0,0 +1,143 @@ +import { getProducts } from "../api/productApi.js"; +import { store } from "../store/store.js"; +import { useEffect } from "../hooks/useEffect.js"; +import { Search } from "../components/Search.js"; +import { ProductList } from "../components/ProductList.js"; +import { syncStateToURL, getStateFromURL } from "../utils/urlSync.js"; + +let lastFetchParams = null; +let isInitialized = false; // 초기화 플래그 +let lastPage = 1; // 마지막으로 로드한 페이지 +let isFetching = false; // 현재 fetch 중인지 확인 + +const fetchProductsWithParams = (filters, pagination, isInfiniteScroll = false) => { + const params = { + current: pagination.page, + limit: pagination.limit, + ...(filters.search && { search: filters.search }), + ...(filters.category1 && { category1: filters.category1 }), + ...(filters.category2 && { category2: filters.category2 }), + sort: filters.sort, + }; + + // 정렬된 키로 일관된 문자열 생성 + const paramsString = `${params.current}|${params.limit}|${params.search || ""}|${params.category1 || ""}|${params.category2 || ""}|${params.sort}`; + + // 같은 파라미터로 이미 fetch했으면 스킵 (무한 스크롤은 제외) + if (lastFetchParams === paramsString && !isInfiniteScroll) { + return; + } + + // 이미 fetch 중이면 스킵 + if (isFetching) { + return; + } + + lastFetchParams = paramsString; + isFetching = true; + store.setState({ isLoading: true }); + + getProducts(params) + .then((data) => { + // 무한 스크롤인 경우 기존 products에 추가 + if (isInfiniteScroll && pagination.page > lastPage) { + const currentProducts = store.getState("products"); + store.setState({ + products: [...currentProducts, ...data.products], + pagination: data.pagination, + filters: data.filters, + isLoading: false, + }); + lastPage = pagination.page; + } else { + // 일반 페이지 로드인 경우 교체 + store.setState({ ...data, isLoading: false }); + lastPage = pagination.page; + } + isFetching = false; + }) + .catch(() => { + store.setState({ + isLoading: false, + isError: true, + toast: { isOpen: true, type: "error" }, + }); + isFetching = false; + }); +}; + +export const Home = () => { + const { isError, filters, pagination } = store.getState(); + + // 초기 로드 시 URL에서 상태 복원 + useEffect(() => { + if (isInitialized) return; + isInitialized = true; + + const urlState = getStateFromURL(); + + // 변수 초기화 + lastFetchParams = null; + lastPage = urlState.pagination.page || 1; // URL에서 가져온 페이지로 설정 + isFetching = false; // fetch 상태도 리셋 + + // URL에 쿼리 파라미터가 있으면 store에 반영 + if ( + urlState.filters.search || + urlState.filters.category1 || + urlState.filters.category2 || + urlState.filters.sort !== "price_asc" || + urlState.pagination.page > 1 || + urlState.pagination.limit !== 20 + ) { + store.setState({ + filters: urlState.filters, + pagination: { ...store.getState("pagination"), ...urlState.pagination }, + }); + // setState 후 렌더링이 발생하면 두 번째 useEffect가 실행되므로 여기서는 fetch 안 함 + return; + } + + // URL에 파라미터가 없으면 초기 데이터 fetch + fetchProductsWithParams(filters, pagination); + }, []); + + // filters나 pagination이 변경될 때마다 URL 동기화 및 데이터 fetch + useEffect(() => { + if (!isInitialized) return; // 초기화 전에는 실행 안 함 + + // 페이지가 증가했는지 확인 (무한 스크롤) + const isInfiniteScroll = pagination.page > lastPage && pagination.page > 1; + + // 필터나 정렬, limit이 변경되면 page는 1로 되돌아가므로 리셋 + // 단, 이미 page=1이고 lastPage=1이면 리셋 안 함 (중복 방지) + if (pagination.page === 1 && lastPage > 1) { + lastPage = 1; // 1페이지로 리셋 + lastFetchParams = null; // 캐시도 리셋 + } + + // URL에 현재 상태 반영 (렌더링 트리거 없이) + // 무한 스크롤일 때는 URL 업데이트 안 함 + if (!isInfiniteScroll) { + syncStateToURL(filters, pagination); + } + + // 데이터 fetch + fetchProductsWithParams(filters, pagination, isInfiniteScroll); + }, [filters.search, filters.category1, filters.category2, filters.sort, pagination.page, pagination.limit]); + + return /*html*/ ` + + ${Search()} + + ${ + isError + ? /*html*/ ` +
+
오류가 발생했습니다.
+ +
` + : ProductList() + } + `; +}; diff --git a/src/pages/NotFound.js b/src/pages/NotFound.js new file mode 100644 index 00000000..48079766 --- /dev/null +++ b/src/pages/NotFound.js @@ -0,0 +1,34 @@ +export const NotFound = () => { + return /*html*/ ` +
+ + + + + + + + + + + + + 404 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+ `; +}; diff --git a/src/pages/ProductDetail.js b/src/pages/ProductDetail.js new file mode 100644 index 00000000..278a5a92 --- /dev/null +++ b/src/pages/ProductDetail.js @@ -0,0 +1,180 @@ +import { store } from "../store/store.js"; + +export const ProductDetail = () => { + const isLoading = store.getState("isLoading"); + + return /*html*/ ` + ${isLoading ? ProductDetail.Loading() : ProductDetail.Container()} + `; +}; + +ProductDetail.Loading = () => { + return /*html*/ ` +
+
+
+

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

+
+
+ `; +}; + +ProductDetail.Container = () => { + return /*html*/ ` + + ${ProductDetail.Breadcrumb()} + + ${ProductDetail.Item()} + + ${ProductDetail.Cart()} + +
+ +
+ + ${ProductDetail.RelatedProducts()} + `; +}; + +ProductDetail.Breadcrumb = () => { + const { category1, category2 } = store.getState("selectedProduct"); + + return /*html*/ ` + + `; +}; + +ProductDetail.Item = () => { + const { title, image, lprice, brand, description, rating, reviewCount, stock } = store.getState("selectedProduct"); + + return /*html*/ ` +
+ +
+
+ ${title} +
+ +
+

${brand}

+

${title}

+ +
+
+ + + + + + + + + + + + + + + +
+ ${rating} (${reviewCount}개 리뷰) +
+ +
+ ${Number(lprice).toLocaleString("ko-KR")}원 +
+ +
+ 재고 ${stock}개 +
+ +
${description}
+
+
+ `; +}; + +ProductDetail.Cart = () => { + const { productId } = store.getState("selectedProduct"); + const { quantity } = store.getState("cart"); + return /*html*/ ` +
+
+ 수량 +
+ + + +
+
+ + +
+
+ `; +}; + +ProductDetail.RelatedProducts = () => { + // const arr = [...Array(10)]; + const arr = []; + return /*html*/ ` +
+
+

관련 상품

+

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

+
+
+
+ ${arr.length > 0 ? arr.map((detail) => ProductDetail.RelatedItem(detail)).join("") : "관련 상품이 없습니다."} +
+
+
+ `; +}; + +ProductDetail.RelatedItem = (detail) => { + const { productId, title, image, lprice } = detail; + + return /*html*/ ` + + `; +}; diff --git a/src/render.js b/src/render.js new file mode 100644 index 00000000..64928ab4 --- /dev/null +++ b/src/render.js @@ -0,0 +1,15 @@ +import { flushEffects } from "./hooks/useEffect.js"; + +/** + * @param {string} component + */ +export function render(component) { + if (!component) { + throw new Error("Component is required"); + } + const $root = document.querySelector("#root"); + $root.innerHTML = component(); + + // 렌더링 후 이펙트 + flushEffects(); +} diff --git a/src/router/Router.js b/src/router/Router.js new file mode 100644 index 00000000..00bc826e --- /dev/null +++ b/src/router/Router.js @@ -0,0 +1,193 @@ +import { routes } from "./Routes.js"; + +/** + * @typedef {Object} Route + * @property {string} path + * @property {string} name + * @property {() => string} render + */ + +/** + * @param {Route[]} routes + */ +export class RouterInstance { + constructor(routes = []) { + this.routes = routes; + this.basePath = import.meta.env.BASE_URL; + this.currentRoute = null; + this.params = {}; + this.queryParams = {}; + } + + // 라우트 메소드 + + init(renderCallback) { + this.renderCallback = renderCallback; + + // GitHub Pages 404.html 리다이렉트 처리 + // 404.html에서 /?/path/to/page 형식으로 리다이렉트됨 + const search = window.location.search; + if (search && search.startsWith("?/")) { + const redirect = search.slice(2).split("&")[0].replace(/~and~/g, "&"); + const queryString = search.slice(2).split("&").slice(1).join("&").replace(/~and~/g, "&"); + + const newUrl = + window.location.pathname + + (redirect ? "/" + redirect : "") + + (queryString ? "?" + queryString : "") + + window.location.hash; + + window.history.replaceState(null, "", newUrl); + } + + window.addEventListener("popstate", () => { + this.#handleRoute(); + }); + + document.addEventListener("click", (e) => { + const link = e.target.closest("a[data-link]"); + + // a tag 막음 + if (link) { + e.preventDefault(); + // href에서 pathname만 추출 (full URL이 아닌 경우 그대로 사용) + const path = link.getAttribute("href") || "/"; + this.navigate(path); + } + }); + } + + /** + * @param {string} path + * @param {Object} option + * @param {boolean} option.replace + */ + navigate(path, option = {}) { + const { replace = false } = option; + const fullPath = this.basePath === "/" ? path : this.basePath + path; + + if (replace) { + window.history.replaceState(null, "", fullPath); + } else { + window.history.pushState(null, "", fullPath); + } + + this.#handleRoute(); + } + + // 라우트 헬퍼 + + // 라우트 변경 처리 + #handleRoute() { + const route = this.getCurrentRoute(); + + if (!route) { + throw new Error("Not Found Route"); + } + + this.currentRoute = route; + this.renderCallback?.(); + } + + /** + * @param {string} path + * @returns {Route | undefined} + */ + #matchRoute(path) { + for (const route of this.routes) { + if (route.path === path) { + this.params = {}; + return route; + } + + if (route.path.includes(":")) { + const routeRegex = this.#pathToRegex(route.path); + const match = path.match(routeRegex); + if (match) { + this.params = this.#extractParams(route.path, match); + return route; + } + } + } + } + + /** + * @param {string} path + * @returns {RegExp} + */ + #pathToRegex(path) { + const pattern = path + .replace(/\//g, "\\/") // 슬래시 이스케이프 + .replace(/:\w+/g, "([^\\/]+)"); // 파라미터 추출 + + return new RegExp(`^${pattern}$`); + } + + /** + * @param {string} path + * @param {RegExpMatchArray} match + * @returns {Record} + */ + #extractParams(path, match) { + const params = {}; + const paramNames = path.match(/:\w+/g) || []; + + paramNames.forEach((paramName, index) => { + const key = paramName.slice(1); + params[key] = match[index + 1]; + }); + + return params; + } + + // 게터 + + /** + * @returns {Route} + */ + getCurrentRoute() { + const path = this.getPath(); + const match = this.#matchRoute(path); + const notFoundRoute = this.routes.find((route) => route.path === "*"); + + return match ?? notFoundRoute; + } + + /** + * @returns {string} + */ + getPath() { + let pathName = window.location.pathname; + + // basePath 핸들링 + if (pathName.startsWith(this.basePath)) { + pathName = pathName.slice(this.basePath.length) || "/"; + } + + return pathName.startsWith("/") ? pathName : "/" + pathName; + } + + /** + * @returns {Record} + */ + getParams() { + return this.params; + } + + /** + * @returns {Record} + */ + getQueryParams() { + const searchParams = new URLSearchParams(window.location.search); + const queryParams = {}; + + for (const [key, value] of searchParams) { + queryParams[key] = value; + } + + return queryParams; + } +} + +// 라우터 싱글톤 패턴 +export const router = new RouterInstance(routes); diff --git a/src/router/routes.js b/src/router/routes.js new file mode 100644 index 00000000..089c3c6d --- /dev/null +++ b/src/router/routes.js @@ -0,0 +1,9 @@ +import { Home } from "../pages/Home.js"; +import { NotFound } from "../pages/NotFound.js"; +import { ProductDetail } from "../pages/ProductDetail.js"; + +export const routes = [ + { path: "/", name: "home", render: Home }, + { path: "/product/:id", name: "product", render: ProductDetail }, + { path: "*", name: "notFound", render: NotFound }, +]; diff --git a/src/setupTests.js b/src/setupTests.js index d0de870d..d72b8905 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1 +1,16 @@ import "@testing-library/jest-dom"; +import { configure } from "@testing-library/dom"; +import { afterAll, beforeAll } from "vitest"; +import { server } from "./__tests__/mockServerHandler.js"; + +configure({ + asyncUtilTimeout: 5000, +}); + +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); +}); + +afterAll(() => { + server.close(); +}); diff --git a/src/store/store.js b/src/store/store.js new file mode 100644 index 00000000..c0783cf1 --- /dev/null +++ b/src/store/store.js @@ -0,0 +1,92 @@ +const initState = { + categories: new Map(), + products: [], + selectedProduct: { + title: "", + link: "", + image: "", + lprice: "", + hprice: "", + mallName: "", + productId: "", + productType: "", + brand: "", + maker: "", + category1: "", + category2: "", + category3: "", + category4: "", + description: "", + rating: 0, + reviewCount: 0, + stock: 0, + images: [], + }, + filters: { + search: "", + category1: "", + category2: "", + sort: "price_asc", + }, + pagination: { + page: 1, + limit: 20, + total: 0, + totalPages: 0, + hasNext: false, + hasPrev: false, + }, + isLoading: false, + cart: { + isOpen: false, + selectedAll: false, + quantity: 1, + list: new Map(), + }, + toast: { + isOpen: false, + type: "success", + }, + isError: false, +}; + +export const createStore = () => { + let state = { ...initState }; + const listeners = new Set(); + + const getState = (key) => (key ? state[key] : state); + const setState = (newState) => { + state = { ...state, ...newState }; + console.log(state); + + listeners.forEach((listener) => listener(state)); + }; + + const subscribe = (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + return { getState, setState, subscribe }; +}; + +// 스토어 싱글톤 패턴 +export const store = createStore(); + +// export const useStore = () => { +// const store = createStore(); +// return store; +// }; + +// export const persistStore = () => { +// const persistState = () => { +// localStorage.setItem("store", JSON.stringify(state)); +// }; +// const loadState = () => { +// const storedState = localStorage.getItem("store"); +// return storedState ? JSON.parse(storedState) : initState; +// }; +// return { persistState, loadState }; +// }; diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 00000000..3824a8de --- /dev/null +++ b/src/styles.css @@ -0,0 +1,157 @@ +/* 추가 CSS 스타일 */ + +/* 상품 상세 페이지용 스타일 */ +.product-detail-container { + min-height: calc(100vh - 80px); +} + +.breadcrumb-link { + transition: color 0.2s ease; +} + +.breadcrumb-link:hover { + color: #3b82f6; +} + +.related-product-card { + transition: all 0.2s ease; +} + +.related-product-card:hover { + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.product-detail-image { + max-height: 400px; + object-fit: contain; +} + +/* 상품 카드 호버 효과 개선 */ +.product-card { + transition: all 0.2s ease; +} + +.product-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +/* 토스트 애니메이션 */ +@keyframes slide-up { + from { + transform: translateY(100px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.animate-slide-up { + animation: slide-up 0.3s ease-out; +} + +/* 모달 애니메이션 */ +.modal-overlay { + animation: fade-in 0.2s ease-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* 스켈레톤 로딩 */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* 버튼 비활성화 스타일 */ +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 로딩 스피너 */ +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +/* 반응형 그리드 개선 */ +@media (min-width: 640px) { + .responsive-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 768px) { + .responsive-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 1024px) { + .responsive-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* 스크롤바 스타일링 */ +.overflow-y-auto::-webkit-scrollbar { + width: 6px; +} + +.overflow-y-auto::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* 모바일 터치 최적화 */ +@media (max-width: 640px) { + .product-card { + transition: transform 0.1s ease-out; + } + + .product-card:active { + transform: scale(0.98); + } + + button:active { + transform: scale(0.95); + } +} diff --git a/src/utils/urlSync.js b/src/utils/urlSync.js new file mode 100644 index 00000000..3256d6d8 --- /dev/null +++ b/src/utils/urlSync.js @@ -0,0 +1,59 @@ +import { router } from "../router/Router.js"; + +/** + * URL에서 필터와 페이지네이션 상태를 읽어옴 + */ +export const getStateFromURL = () => { + const queryParams = router.getQueryParams(); + + return { + filters: { + search: queryParams.search || "", + category1: queryParams.category1 || "", + category2: queryParams.category2 || "", + sort: queryParams.sort || "price_asc", + }, + pagination: { + page: parseInt(queryParams.current || "1", 10), // URL에서는 current 사용 + limit: parseInt(queryParams.limit || "20", 10), + }, + }; +}; + +/** + * 현재 필터와 페이지네이션 상태를 URL에 반영 + * @param {Object} filters + * @param {Object} pagination + */ +export const syncStateToURL = (filters, pagination) => { + const params = new URLSearchParams(); + + if (filters.search) params.set("search", filters.search); + if (filters.category1) params.set("category1", filters.category1); + if (filters.category2) params.set("category2", filters.category2); + if (filters.sort && filters.sort !== "price_asc") params.set("sort", filters.sort); + + if (pagination.page > 1) params.set("current", pagination.page); + if (pagination.limit !== 20) params.set("limit", pagination.limit); + + const queryString = params.toString(); + const basePath = router.basePath === "/" ? "" : router.basePath; + const currentPath = router.getPath(); + const newURL = basePath + currentPath + (queryString ? "?" + queryString : ""); + + const currentURL = window.location.pathname + window.location.search; + if (currentURL !== newURL) { + window.history.replaceState(null, "", newURL); + } +}; + +export const isStateEqual = (state1, state2) => { + return ( + state1.filters.search === state2.filters.search && + state1.filters.category1 === state2.filters.category1 && + state1.filters.category2 === state2.filters.category2 && + state1.filters.sort === state2.filters.sort && + state1.pagination.page === state2.pagination.page && + state1.pagination.limit === state2.pagination.limit + ); +}; diff --git a/vite.config.js b/vite.config.js index ced41c4c..7ff9f49b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,10 +1,16 @@ import { defineConfig } from "vitest/config"; -export default defineConfig({ +export default defineConfig(({ mode, command }) => ({ + base: mode === "production" || command === "build" ? "/front_7th_chapter2-1/" : "/", test: { globals: true, environment: "jsdom", setupFiles: "./src/setupTests.js", exclude: ["**/e2e/**", "**/*.e2e.spec.js", "**/node_modules/**"], + poolOptions: { + threads: { + singleThread: true, + }, + }, }, -}); +}));