diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d6c95379..4c99537a 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -15,4 +15,4 @@ module.exports = { { allowConstantExport: true }, ], }, -} +}; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..1fc2fbb6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,38 @@ +name: Deploy Advanced to GitHub Pages + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Build Advanced project + run: pnpm run build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist/advanced + publish_branch: gh-pages diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..92f97e75 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000..fca2e1e1 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,119 @@ +# Gemini Code Assist Memos + +이 파일은 Gemini Code Assist와의 상호작용을 통해 얻은 프로젝트 관련 정보, 결정 사항, 유용한 팁 등을 기록하는 공간입니다. + +# 작업 절대 원칙 + +작업 시 다음 원칙을 반드시 준수해야 합니다. + +- **기능 불변성 유지**: 기존 애플리케이션의 기능적 동작은 마이그레이션 전후로 완벽하게 동일해야 합니다. 기능 변경은 허용되지 않습니다. +- **동작 일관성 보장**: 사용자 인터페이스(UI) 및 사용자 경험(UX)은 마이그레이션 전과 동일하게 유지되어야 합니다. +- **TypeScript 우선**: 모든 마이그레이션된 코드 및 새로 작성되는 코드는 TypeScript로 작성되어야 하며, 명확하고 정확한 타입 정의를 포함해야 합니다. `any` 타입 사용은 최소화해야 합니다. +- **React 관용적 코드**: React의 컴포넌트 기반 아키텍처, Hooks, 상태 관리 패턴을 사용하여 코드를 작성해야 합니다. 직접적인 DOM 조작은 React의 라이프사이클 및 렌더링 원칙에 따라 재구성되어야 합니다. +- **기존 Clean Code 규칙 준수**: `MANDATORY CODE WRITING RULES` 섹션에 명시된 모든 Clean Code 원칙(DRY, KISS, YAGNI, 단일 책임, 코드 조직화, 명명 규칙, 추상화)을 React 및 TypeScript 환경에 맞게 적용해야 합니다. +- **점진적 마이그레이션**: 작업을 가능한 한 작은 단위로 나누어 진행하고, 각 단위 작업 완료 후 커밋을 통해 변경 사항을 명확히 기록해야 합니다. +- **불필요한 라이브러리 추가 금지**: 마이그레이션 과정에서 새로운 외부 라이브러리 추가는 사용자 승인 없이는 금지됩니다. 기존 프로젝트의 의존성을 최대한 활용합니다. +- **React.FC 사용 금지**: `React.FC` 타입은 `children`을 암묵적으로 포함하고, 기본 Props 타입 추론을 제한하는 등 타입 안정성을 저하시킬 수 있습니다. + - 대신 `React.FunctionComponent` 대신 명시적인 Props 타입 정의를 사용해야 합니다. + +# 절대 반드시 지켜야만 하는 절대적 원칙 (안지키면 다시해야함) + +- 코드의 동작이나 구현이 바뀌면 안되고 반드시 구조 변경(리팩토링)만 해야해야만해 +- 공통으로 쓰이는 파일만 공통 폴더에 넣어두고, 비즈니스 로직이 담긴 경우, 관심사끼리 묶어 폴더로 관리해야해 +- src/basic/tests/basic.test.js, src/advanced/tests/advanced.test.js 테스트 코드가 모두 하나도 빠짐없이 통과해야해 (테스트는 npx vitest run 으로 watch 가 발생하지 않도록 해) +- 테스트 코드 검증 여부는 basic 및 advanced 폴더를 기준으로 검사해야만해. origin 파일은 의미없어. + +-> 작업 후 마지막으로 절대 원칙이 지켜졌는지 한번 더 컴토 후 올바르게 고치고 알려줘 + +# Clean Code Writing Rules + +## MANDATORY CODE WRITING RULES + +You MUST follow these rules when writing any code: + +### CORE DESIGN PRINCIPLES + +- **DRY**: NEVER repeat the same code +- **KISS**: Write code as simply as possible +- **YAGNI**: Do NOT write unnecessary code +- **Single Responsibility**: Functions MUST be under 20 lines and have ONE clear responsibility + +### CODE ORGANIZATION RULES + +Apply these 4 organization principles: + +- **Proximity**: Group related elements with blank lines +- **Commonality**: Group related functionality into functions +- **Similarity**: Use similar names and positions for similar roles +- **Continuity**: Arrange code in dependency order + +### NAMING REQUIREMENTS + +#### Naming Principles (ALL MUST BE FOLLOWED) + +1. **Predictable**: Name must allow prediction of value, type, and return value +2. **Contextual**: Add descriptive adjectives or nouns for context +3. **Clear**: Remove unnecessary words while maintaining clear meaning +4. **Concise**: Brief yet clearly convey role and purpose +5. **Consistent**: Use identical terms for identical intentions across entire codebase + +#### REQUIRED Naming Patterns + +**Action Functions - USE THESE PATTERNS:** + +``` +// Creation: create~(), add~(), push~(), insert~(), new~(), append~(), spawn~(), make~(), build~(), generate~() +// Retrieval: get~(), fetch~(), query~() +// Transformation: parse~(), split~(), transform~(), serialize~() +// Modification: update~(), mutation~() +// Deletion: delete~(), remove~() +// Communication: put~(), send~(), dispatch~(), receive~() +// Validation: validate~(), check~() +// Calculation: calc~(), compute~() +// Control: init~(), configure~(), start~(), stop~() +// Storage: save~(), store~() +// Logging: log~(), record~() +``` + +**Data Variables - USE THESE PATTERNS:** + +``` +// Quantities: count~, sum~, num~, min~, max~, total +// State: is~, has~, current~, selected~ +// Progressive/Past: ~ing, ~ed +// Information: ~name, ~title, ~desc, ~text, ~data +// Identifiers: ~ID, ~code, ~index, ~key +// Time: ~at, ~date +// Type: ~type +// Collections: ~s +// Others: item, temp, params, error +// Conversion: from(), of() +``` + +### ABSTRACTION RULES + +- **Data Abstraction**: Simplify data structure and processing methods +- **Process Abstraction**: Encapsulate complex logic into simple interfaces +- **Appropriate Level**: Do NOT over-abstract or under-abstract + +### MANDATORY CHECKLIST + +Before finalizing ANY code, you MUST verify: + +1. ✅ **Applied standard naming patterns** from above +2. ✅ **Organized code using 4 organization principles** +3. ✅ **Split complex logic into small functions** +4. ✅ **Code expresses intent without comments** (comments only when absolutely necessary) +5. ✅ **Maintained consistent formatting** + +### FORBIDDEN PRACTICES + +- ❌ Do NOT mix similar terms (`display` vs `show`) +- ❌ Do NOT write functions longer than 20 lines +- ❌ Do NOT repeat code patterns +- ❌ Do NOT use unclear or ambiguous names +- ❌ Do NOT violate naming consistency across codebase + +## COMPLIANCE REQUIREMENT + +ALL code output MUST comply with these rules. No exceptions. diff --git a/README.md b/README.md index 3198c545..4a19edbc 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,15 @@ 이번 과제는 단일책임원칙을 위반한 거대한 컴포넌트를 리팩토링 하는 것입니다. React의 컴포넌트는 단일 책임 원칙(Single Responsibility Principle, SRP)을 따르는 것이 좋습니다. 즉, 각 컴포넌트는 하나의 책임만을 가져야 합니다. 하지만 실제로는 여러 가지 기능을 가진 거대한 컴포넌트를 작성하는 경우가 많습니다. [목표] + ## 1. 취지 + - React의 추구미(!)를 이해해보아요! - 단일 책임 원칙(SRP)을 위반한 거대한 컴포넌트가 얼마나 안 좋은지 경험해보아요! - 단일 책임이라는 개념을 이해하기 상태, 순수함수, 컴포넌트, 훅 등 다양한 계층을 이해해합니다. - 엔티티와 UI를 구분하고 데이터, 상태, 비즈니스 로직 등의 특징이 다르다는 것을 이해해보세요. - 이를 통해 적절한 Custom Hook과 유틸리티 함수를 분리하고, 컴포넌트 계층 구조를 정리하는 능력을 갖춥니다! - ## 2. 목표 모든 소프트웨어에는 적절한 책임과 계층이 존재합니다. 하나의 계층(Component)만으로 소프트웨어를 구성하게 되면 나중에는 정리정돈이 되지 않은 코드를 만나게 됩니다. 예전에는 이러한 BestPractice에 대해서 혼돈의 시대였지만 FE가 진화를 거듭하는 과정에서 적절한 계측에 대한 합의가 이루어지고 있는 상태입니다. @@ -22,7 +23,7 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. - 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup - 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() - 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 -- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) +- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) 이번 과제의 목표는 이러한 계층을 이해하고 분리하여 정리정돈을 하는 기준이나 방법등을 습득하는데 있습니다. @@ -35,34 +36,34 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. #### 1) 장바구니 페이지 요구사항 - 상품 목록 - - 상품명, 가격, 재고 수량 등을 표시 - - 각 상품의 할인 정보 표시 - - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 + - 상품명, 가격, 재고 수량 등을 표시 + - 각 상품의 할인 정보 표시 + - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 - 장바구니 - - 장바구니 내 상품 수량 조절 가능 - - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 - - 적용된 할인율 표시 (예: "10% 할인 적용") - - 장바구니 내 모든 상품의 총액을 계산해야 + - 장바구니 내 상품 수량 조절 가능 + - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 + - 적용된 할인율 표시 (예: "10% 할인 적용") + - 장바구니 내 모든 상품의 총액을 계산해야 - 쿠폰 할인 - - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 + - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 - 주문요약 - - 할인 전 총 금액 - - 총 할인 금액 - - 최종 결제 금액 + - 할인 전 총 금액 + - 총 할인 금액 + - 최종 결제 금액 #### 2) 관리자 페이지 요구사항 - 상품 관리 - - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 - - 새로운 상품 추가 가능 - - 상품 제거 가능 + - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 + - 새로운 상품 추가 가능 + - 상품 제거 가능 - 할인 관리 - - 상품별 할인 정보 추가/수정/삭제 가능 - - 할인 조건 설정 (구매 수량에 따른 할인율) + - 상품별 할인 정보 추가/수정/삭제 가능 + - 할인 조건 설정 (구매 수량에 따른 할인율) - 쿠폰 관리 - - 전체 상품에 적용 가능한 쿠폰 생성 - - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) - - 할인 유형은 금액 또는 비율로 설정 가능 + - 전체 상품에 적용 가능한 쿠폰 생성 + - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) + - 할인 유형은 금액 또는 비율로 설정 가능 ### (2) 코드 개선 요구사항 @@ -88,9 +89,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. ### (3) 테스트 코드 통과하기 - - ## 심화과제: Props drilling + - 이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다. - 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요. - Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다. @@ -102,9 +102,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - basic에서 열심히 컴포넌트를 분리해주었겠죠? - 아마 그 과정에서 container - presenter 패턴으로 만들어졌기에 props drilling이 상당히 불편했을거에요. - 그래서 심화과제에서는 props drilling을 제거하는 작업을 할거에요. - - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) - - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. - + - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) + - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. ### (1) 요구사항 @@ -112,15 +111,11 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - Context나 Jotai를 사용하여 상태를 관리합니다. - 테스트 코드를 통과합니다. - ### (2) 힌트 - UI 컴포넌트와 엔티티 컴포넌트는 각각 props를 다르게 받는게 좋습니다. - - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, + - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, - 엔티티 컴포넌트는 가급적 엔티티를 중심으로 전달받는 것이 좋습니다. - 특히 콜백의 경우, - UI 컴포넌트는 이벤트 핸들러를 props로 받아서 처리하도록 해서 재사용성을 높이지만, - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. - - - diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 00000000..c326c55f --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,55 @@ +const { defineConfig, globalIgnores } = require('eslint/config'); + +const globals = require('globals'); + +const { fixupConfigRules } = require('@eslint/compat'); + +const tsParser = require('@typescript-eslint/parser'); +const reactRefresh = require('eslint-plugin-react-refresh'); +const js = require('@eslint/js'); + +const { FlatCompat } = require('@eslint/eslintrc'); + +const prettier = require('eslint-plugin-prettier'); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +module.exports = defineConfig([ + { + languageOptions: { + globals: { + ...globals.browser, + }, + + parser: tsParser, + }, + + extends: fixupConfigRules( + compat.extends( + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + 'plugin:prettier/recommended' + ) + ), + + plugins: { + 'react-refresh': reactRefresh, + }, + + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { + allowConstantExport: true, + }, + ], + 'prettier/prettier': 'error', + }, + }, + globalIgnores(['**/dist', '**/.eslintrc.cjs']), +]); diff --git a/index.advanced.html b/index.advanced.html index 97a2b3e1..30c87546 100644 --- a/index.advanced.html +++ b/index.advanced.html @@ -1,13 +1,13 @@ - - - - 장바구니로 학습하는 디자인패턴 - - - -
- - - \ No newline at end of file + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/index.basic.html b/index.basic.html index 67da41be..72a7adad 100644 --- a/index.basic.html +++ b/index.basic.html @@ -1,13 +1,13 @@ - - - - 장바구니로 학습하는 디자인패턴 - - - -
- - - \ No newline at end of file + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/index.html b/index.html new file mode 100644 index 00000000..30c87546 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/index.origin.html b/index.origin.html index 1c71e279..ff2fba77 100644 --- a/index.origin.html +++ b/index.origin.html @@ -1,13 +1,13 @@ - - - - 장바구니로 학습하는 디자인패턴 - - - -
- - - \ No newline at end of file + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/package.json b/package.json index 79034acb..f5f95539 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "start:origin": "vite serve --open ./index.origin.html", "start:basic": "vite serve --open ./index.basic.html", - "start:advanced": "vite serve --open ./index.advanced.html", + "start:advanced": "vite serve --open ./index.html", "test": "vitest", "test:origin": "vitest src/origin", "test:basic": "vitest src/basic", @@ -16,10 +16,12 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "jotai": "^2.13.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, "devDependencies": { + "@eslint/compat": "^1.3.1", "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -30,9 +32,12 @@ "@vitejs/plugin-react-swc": "^3.11.0", "@vitest/ui": "^3.2.4", "eslint": "^9.32.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.3", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "jsdom": "^26.1.0", + "prettier": "^3.6.2", "typescript": "^5.9.2", "vite": "^7.0.6", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85..516f7a57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,11 @@ settings: excludeLinksFromLockfile: false importers: - .: dependencies: + jotai: + specifier: ^2.13.0 + version: 2.13.0(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -15,6 +17,9 @@ importers: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) devDependencies: + '@eslint/compat': + specifier: ^1.3.1 + version: 1.3.1(eslint@9.32.0) '@testing-library/jest-dom': specifier: ^6.6.4 version: 6.6.4 @@ -45,6 +50,12 @@ importers: eslint: specifier: ^9.32.0 version: 9.32.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.32.0) + eslint-plugin-prettier: + specifier: ^5.5.3 + version: 5.5.3(eslint-config-prettier@10.1.8(eslint@9.32.0))(eslint@9.32.0)(prettier@3.6.2) eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@9.32.0) @@ -54,6 +65,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + prettier: + specifier: ^3.6.2 + version: 3.6.2 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -65,465 +79,753 @@ importers: version: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) packages: - '@adobe/css-tools@4.4.0': - resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} + resolution: + { + integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==, + } '@asamuzakjp/css-color@3.1.2': - resolution: {integrity: sha512-nwgc7jPn3LpZ4JWsoHtuwBsad1qSSLDDX634DdG0PBJofIuIEtSWk4KkRmuXyu178tjuHAbwiMNNzwqIyLYxZw==} + resolution: + { + integrity: sha512-nwgc7jPn3LpZ4JWsoHtuwBsad1qSSLDDX634DdG0PBJofIuIEtSWk4KkRmuXyu178tjuHAbwiMNNzwqIyLYxZw==, + } '@babel/code-frame@7.25.7': - resolution: {integrity: sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==} - engines: {node: '>=6.9.0'} + resolution: + { + integrity: sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==, + } + engines: { node: '>=6.9.0' } '@babel/helper-validator-identifier@7.25.7': - resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==} - engines: {node: '>=6.9.0'} + resolution: + { + integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==, + } + engines: { node: '>=6.9.0' } '@babel/highlight@7.25.7': - resolution: {integrity: sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==} - engines: {node: '>=6.9.0'} + resolution: + { + integrity: sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==, + } + engines: { node: '>=6.9.0' } '@babel/runtime@7.25.7': - resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==} - engines: {node: '>=6.9.0'} + resolution: + { + integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==, + } + engines: { node: '>=6.9.0' } '@csstools/color-helpers@5.0.2': - resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==, + } + engines: { node: '>=18' } '@csstools/css-calc@2.1.2': - resolution: {integrity: sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw==, + } + engines: { node: '>=18' } peerDependencies: '@csstools/css-parser-algorithms': ^3.0.4 '@csstools/css-tokenizer': ^3.0.3 '@csstools/css-color-parser@3.0.8': - resolution: {integrity: sha512-pdwotQjCCnRPuNi06jFuP68cykU1f3ZWExLe/8MQ1LOs8Xq+fTkYgd+2V8mWUWMrOn9iS2HftPVaMZDaXzGbhQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-pdwotQjCCnRPuNi06jFuP68cykU1f3ZWExLe/8MQ1LOs8Xq+fTkYgd+2V8mWUWMrOn9iS2HftPVaMZDaXzGbhQ==, + } + engines: { node: '>=18' } peerDependencies: '@csstools/css-parser-algorithms': ^3.0.4 '@csstools/css-tokenizer': ^3.0.3 '@csstools/css-parser-algorithms@3.0.4': - resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==, + } + engines: { node: '>=18' } peerDependencies: '@csstools/css-tokenizer': ^3.0.3 '@csstools/css-tokenizer@3.0.3': - resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==, + } + engines: { node: '>=18' } '@esbuild/aix-ppc64@0.25.8': - resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==, + } + engines: { node: '>=18' } cpu: [ppc64] os: [aix] '@esbuild/android-arm64@0.25.8': - resolution: {integrity: sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==, + } + engines: { node: '>=18' } cpu: [arm64] os: [android] '@esbuild/android-arm@0.25.8': - resolution: {integrity: sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==, + } + engines: { node: '>=18' } cpu: [arm] os: [android] '@esbuild/android-x64@0.25.8': - resolution: {integrity: sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==, + } + engines: { node: '>=18' } cpu: [x64] os: [android] '@esbuild/darwin-arm64@0.25.8': - resolution: {integrity: sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==, + } + engines: { node: '>=18' } cpu: [arm64] os: [darwin] '@esbuild/darwin-x64@0.25.8': - resolution: {integrity: sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==, + } + engines: { node: '>=18' } cpu: [x64] os: [darwin] '@esbuild/freebsd-arm64@0.25.8': - resolution: {integrity: sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==, + } + engines: { node: '>=18' } cpu: [arm64] os: [freebsd] '@esbuild/freebsd-x64@0.25.8': - resolution: {integrity: sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==, + } + engines: { node: '>=18' } cpu: [x64] os: [freebsd] '@esbuild/linux-arm64@0.25.8': - resolution: {integrity: sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==, + } + engines: { node: '>=18' } cpu: [arm64] os: [linux] '@esbuild/linux-arm@0.25.8': - resolution: {integrity: sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==, + } + engines: { node: '>=18' } cpu: [arm] os: [linux] '@esbuild/linux-ia32@0.25.8': - resolution: {integrity: sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==, + } + engines: { node: '>=18' } cpu: [ia32] os: [linux] '@esbuild/linux-loong64@0.25.8': - resolution: {integrity: sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==, + } + engines: { node: '>=18' } cpu: [loong64] os: [linux] '@esbuild/linux-mips64el@0.25.8': - resolution: {integrity: sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==, + } + engines: { node: '>=18' } cpu: [mips64el] os: [linux] '@esbuild/linux-ppc64@0.25.8': - resolution: {integrity: sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==, + } + engines: { node: '>=18' } cpu: [ppc64] os: [linux] '@esbuild/linux-riscv64@0.25.8': - resolution: {integrity: sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==, + } + engines: { node: '>=18' } cpu: [riscv64] os: [linux] '@esbuild/linux-s390x@0.25.8': - resolution: {integrity: sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==, + } + engines: { node: '>=18' } cpu: [s390x] os: [linux] '@esbuild/linux-x64@0.25.8': - resolution: {integrity: sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==, + } + engines: { node: '>=18' } cpu: [x64] os: [linux] '@esbuild/netbsd-arm64@0.25.8': - resolution: {integrity: sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==, + } + engines: { node: '>=18' } cpu: [arm64] os: [netbsd] '@esbuild/netbsd-x64@0.25.8': - resolution: {integrity: sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==, + } + engines: { node: '>=18' } cpu: [x64] os: [netbsd] '@esbuild/openbsd-arm64@0.25.8': - resolution: {integrity: sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==, + } + engines: { node: '>=18' } cpu: [arm64] os: [openbsd] '@esbuild/openbsd-x64@0.25.8': - resolution: {integrity: sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==, + } + engines: { node: '>=18' } cpu: [x64] os: [openbsd] '@esbuild/openharmony-arm64@0.25.8': - resolution: {integrity: sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==, + } + engines: { node: '>=18' } cpu: [arm64] os: [openharmony] '@esbuild/sunos-x64@0.25.8': - resolution: {integrity: sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==, + } + engines: { node: '>=18' } cpu: [x64] os: [sunos] '@esbuild/win32-arm64@0.25.8': - resolution: {integrity: sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==, + } + engines: { node: '>=18' } cpu: [arm64] os: [win32] '@esbuild/win32-ia32@0.25.8': - resolution: {integrity: sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==, + } + engines: { node: '>=18' } cpu: [ia32] os: [win32] '@esbuild/win32-x64@0.25.8': - resolution: {integrity: sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==, + } + engines: { node: '>=18' } cpu: [x64] os: [win32] '@eslint-community/eslint-utils@4.4.0': - resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { + integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 '@eslint-community/eslint-utils@4.7.0': - resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { + integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 '@eslint-community/regexpp@4.11.1': - resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + resolution: + { + integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==, + } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } '@eslint-community/regexpp@4.12.1': - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + resolution: + { + integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==, + } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } + + '@eslint/compat@1.3.1': + resolution: + { + integrity: sha512-k8MHony59I5EPic6EQTCNOuPoVBnoYXkP+20xvwFjN7t0qI3ImyvyBgg+hIVPwC8JaxVjjUZld+cLfBLFDLucg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.40 || 9 + peerDependenciesMeta: + eslint: + optional: true '@eslint/config-array@0.21.0': - resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@eslint/config-helpers@0.3.0': - resolution: {integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@eslint/core@0.15.1': - resolution: {integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@eslint/js@9.32.0': - resolution: {integrity: sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@eslint/object-schema@2.1.6': - resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@eslint/plugin-kit@0.3.4': - resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} + resolution: + { + integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==, + } + engines: { node: '>=18.18.0' } '@humanfs/node@0.16.6': - resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} - engines: {node: '>=18.18.0'} + resolution: + { + integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==, + } + engines: { node: '>=18.18.0' } '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} + resolution: + { + integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==, + } + engines: { node: '>=12.22' } '@humanwhocodes/retry@0.3.1': - resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} - engines: {node: '>=18.18'} + resolution: + { + integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==, + } + engines: { node: '>=18.18' } '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} + resolution: + { + integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==, + } + engines: { node: '>=18.18' } '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + resolution: + { + integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==, + } '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} + resolution: + { + integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, + } + engines: { node: '>= 8' } '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} + 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'} + resolution: + { + integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, + } + engines: { node: '>= 8' } + + '@pkgr/core@0.2.9': + resolution: + { + integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==, + } + engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } '@polka/url@1.0.0-next.28': - resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + resolution: + { + integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==, + } '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + resolution: + { + integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==, + } '@rollup/rollup-android-arm-eabi@4.46.2': - resolution: {integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==} + resolution: + { + integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==, + } cpu: [arm] os: [android] '@rollup/rollup-android-arm64@4.46.2': - resolution: {integrity: sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==} + resolution: + { + integrity: sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==, + } cpu: [arm64] os: [android] '@rollup/rollup-darwin-arm64@4.46.2': - resolution: {integrity: sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==} + resolution: + { + integrity: sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==, + } cpu: [arm64] os: [darwin] '@rollup/rollup-darwin-x64@4.46.2': - resolution: {integrity: sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==} + resolution: + { + integrity: sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==, + } cpu: [x64] os: [darwin] '@rollup/rollup-freebsd-arm64@4.46.2': - resolution: {integrity: sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==} + resolution: + { + integrity: sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==, + } cpu: [arm64] os: [freebsd] '@rollup/rollup-freebsd-x64@4.46.2': - resolution: {integrity: sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==} + resolution: + { + integrity: sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==, + } cpu: [x64] os: [freebsd] '@rollup/rollup-linux-arm-gnueabihf@4.46.2': - resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==} + resolution: + { + integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==, + } cpu: [arm] os: [linux] '@rollup/rollup-linux-arm-musleabihf@4.46.2': - resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==} + resolution: + { + integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==, + } cpu: [arm] os: [linux] '@rollup/rollup-linux-arm64-gnu@4.46.2': - resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==} + resolution: + { + integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==, + } cpu: [arm64] os: [linux] '@rollup/rollup-linux-arm64-musl@4.46.2': - resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==} + resolution: + { + integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==, + } cpu: [arm64] os: [linux] '@rollup/rollup-linux-loongarch64-gnu@4.46.2': - resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==} + resolution: + { + integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==, + } cpu: [loong64] os: [linux] '@rollup/rollup-linux-ppc64-gnu@4.46.2': - resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==} + resolution: + { + integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==, + } cpu: [ppc64] os: [linux] '@rollup/rollup-linux-riscv64-gnu@4.46.2': - resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==} + resolution: + { + integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==, + } cpu: [riscv64] os: [linux] '@rollup/rollup-linux-riscv64-musl@4.46.2': - resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==} + resolution: + { + integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==, + } cpu: [riscv64] os: [linux] '@rollup/rollup-linux-s390x-gnu@4.46.2': - resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==} + resolution: + { + integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==, + } cpu: [s390x] os: [linux] '@rollup/rollup-linux-x64-gnu@4.46.2': - resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==} + resolution: + { + integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==, + } cpu: [x64] os: [linux] '@rollup/rollup-linux-x64-musl@4.46.2': - resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==} + resolution: + { + integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==, + } cpu: [x64] os: [linux] '@rollup/rollup-win32-arm64-msvc@4.46.2': - resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==} + resolution: + { + integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==, + } cpu: [arm64] os: [win32] '@rollup/rollup-win32-ia32-msvc@4.46.2': - resolution: {integrity: sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==} + resolution: + { + integrity: sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==, + } cpu: [ia32] os: [win32] '@rollup/rollup-win32-x64-msvc@4.46.2': - resolution: {integrity: sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==} + resolution: + { + integrity: sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==, + } cpu: [x64] os: [win32] '@swc/core-darwin-arm64@1.13.3': - resolution: {integrity: sha512-ux0Ws4pSpBTqbDS9GlVP354MekB1DwYlbxXU3VhnDr4GBcCOimpocx62x7cFJkSpEBF8bmX8+/TTCGKh4PbyXw==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-ux0Ws4pSpBTqbDS9GlVP354MekB1DwYlbxXU3VhnDr4GBcCOimpocx62x7cFJkSpEBF8bmX8+/TTCGKh4PbyXw==, + } + engines: { node: '>=10' } cpu: [arm64] os: [darwin] '@swc/core-darwin-x64@1.13.3': - resolution: {integrity: sha512-p0X6yhxmNUOMZrbeZ3ZNsPige8lSlSe1llllXvpCLkKKxN/k5vZt1sULoq6Nj4eQ7KeHQVm81/+AwKZyf/e0TA==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-p0X6yhxmNUOMZrbeZ3ZNsPige8lSlSe1llllXvpCLkKKxN/k5vZt1sULoq6Nj4eQ7KeHQVm81/+AwKZyf/e0TA==, + } + engines: { node: '>=10' } cpu: [x64] os: [darwin] '@swc/core-linux-arm-gnueabihf@1.13.3': - resolution: {integrity: sha512-OmDoiexL2fVWvQTCtoh0xHMyEkZweQAlh4dRyvl8ugqIPEVARSYtaj55TBMUJIP44mSUOJ5tytjzhn2KFxFcBA==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-OmDoiexL2fVWvQTCtoh0xHMyEkZweQAlh4dRyvl8ugqIPEVARSYtaj55TBMUJIP44mSUOJ5tytjzhn2KFxFcBA==, + } + engines: { node: '>=10' } cpu: [arm] os: [linux] '@swc/core-linux-arm64-gnu@1.13.3': - resolution: {integrity: sha512-STfKku3QfnuUj6k3g9ld4vwhtgCGYIFQmsGPPgT9MK/dI3Lwnpe5Gs5t1inoUIoGNP8sIOLlBB4HV4MmBjQuhw==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-STfKku3QfnuUj6k3g9ld4vwhtgCGYIFQmsGPPgT9MK/dI3Lwnpe5Gs5t1inoUIoGNP8sIOLlBB4HV4MmBjQuhw==, + } + engines: { node: '>=10' } cpu: [arm64] os: [linux] '@swc/core-linux-arm64-musl@1.13.3': - resolution: {integrity: sha512-bc+CXYlFc1t8pv9yZJGus372ldzOVscBl7encUBlU1m/Sig0+NDJLz6cXXRcFyl6ABNOApWeR4Yl7iUWx6C8og==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-bc+CXYlFc1t8pv9yZJGus372ldzOVscBl7encUBlU1m/Sig0+NDJLz6cXXRcFyl6ABNOApWeR4Yl7iUWx6C8og==, + } + engines: { node: '>=10' } cpu: [arm64] os: [linux] '@swc/core-linux-x64-gnu@1.13.3': - resolution: {integrity: sha512-dFXoa0TEhohrKcxn/54YKs1iwNeW6tUkHJgXW33H381SvjKFUV53WR231jh1sWVJETjA3vsAwxKwR23s7UCmUA==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-dFXoa0TEhohrKcxn/54YKs1iwNeW6tUkHJgXW33H381SvjKFUV53WR231jh1sWVJETjA3vsAwxKwR23s7UCmUA==, + } + engines: { node: '>=10' } cpu: [x64] os: [linux] '@swc/core-linux-x64-musl@1.13.3': - resolution: {integrity: sha512-ieyjisLB+ldexiE/yD8uomaZuZIbTc8tjquYln9Quh5ykOBY7LpJJYBWvWtm1g3pHv6AXlBI8Jay7Fffb6aLfA==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-ieyjisLB+ldexiE/yD8uomaZuZIbTc8tjquYln9Quh5ykOBY7LpJJYBWvWtm1g3pHv6AXlBI8Jay7Fffb6aLfA==, + } + engines: { node: '>=10' } cpu: [x64] os: [linux] '@swc/core-win32-arm64-msvc@1.13.3': - resolution: {integrity: sha512-elTQpnaX5vESSbhCEgcwXjpMsnUbqqHfEpB7ewpkAsLzKEXZaK67ihSRYAuAx6ewRQTo7DS5iTT6X5aQD3MzMw==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-elTQpnaX5vESSbhCEgcwXjpMsnUbqqHfEpB7ewpkAsLzKEXZaK67ihSRYAuAx6ewRQTo7DS5iTT6X5aQD3MzMw==, + } + engines: { node: '>=10' } cpu: [arm64] os: [win32] '@swc/core-win32-ia32-msvc@1.13.3': - resolution: {integrity: sha512-nvehQVEOdI1BleJpuUgPLrclJ0TzbEMc+MarXDmmiRFwEUGqj+pnfkTSb7RZyS1puU74IXdK/YhTirHurtbI9w==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-nvehQVEOdI1BleJpuUgPLrclJ0TzbEMc+MarXDmmiRFwEUGqj+pnfkTSb7RZyS1puU74IXdK/YhTirHurtbI9w==, + } + engines: { node: '>=10' } cpu: [ia32] os: [win32] '@swc/core-win32-x64-msvc@1.13.3': - resolution: {integrity: sha512-A+JSKGkRbPLVV2Kwx8TaDAV0yXIXm/gc8m98hSkVDGlPBBmydgzNdWy3X7HTUBM7IDk7YlWE7w2+RUGjdgpTmg==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-A+JSKGkRbPLVV2Kwx8TaDAV0yXIXm/gc8m98hSkVDGlPBBmydgzNdWy3X7HTUBM7IDk7YlWE7w2+RUGjdgpTmg==, + } + engines: { node: '>=10' } cpu: [x64] os: [win32] '@swc/core@1.13.3': - resolution: {integrity: sha512-ZaDETVWnm6FE0fc+c2UE8MHYVS3Fe91o5vkmGfgwGXFbxYvAjKSqxM/j4cRc9T7VZNSJjriXq58XkfCp3Y6f+w==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-ZaDETVWnm6FE0fc+c2UE8MHYVS3Fe91o5vkmGfgwGXFbxYvAjKSqxM/j4cRc9T7VZNSJjriXq58XkfCp3Y6f+w==, + } + engines: { node: '>=10' } peerDependencies: '@swc/helpers': '>=0.5.17' peerDependenciesMeta: @@ -531,22 +833,37 @@ packages: optional: true '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + resolution: + { + integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==, + } '@swc/types@0.1.23': - resolution: {integrity: sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==} + resolution: + { + integrity: sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==, + } '@testing-library/dom@10.4.0': - resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==, + } + engines: { node: '>=18' } '@testing-library/jest-dom@6.6.4': - resolution: {integrity: sha512-xDXgLjVunjHqczScfkCJ9iyjdNOVHvvCdqHSSxwM9L0l/wHkTRum67SDc020uAlCoqktJplgO2AAQeLP1wgqDQ==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + resolution: + { + integrity: sha512-xDXgLjVunjHqczScfkCJ9iyjdNOVHvvCdqHSSxwM9L0l/wHkTRum67SDc020uAlCoqktJplgO2AAQeLP1wgqDQ==, + } + engines: { node: '>=14', npm: '>=6', yarn: '>=1' } '@testing-library/react@16.3.0': - resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==, + } + engines: { node: '>=18' } peerDependencies: '@testing-library/dom': ^10.0.0 '@types/react': ^18.0.0 || ^19.0.0 @@ -560,106 +877,172 @@ packages: optional: true '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} - engines: {node: '>=12', npm: '>=6'} + resolution: + { + integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==, + } + engines: { node: '>=12', npm: '>=6' } peerDependencies: '@testing-library/dom': '>=7.21.4' '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + resolution: + { + integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==, + } '@types/chai@5.2.2': - resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + resolution: + { + integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==, + } '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + resolution: + { + integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, + } '@types/estree@1.0.6': - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + resolution: + { + integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==, + } '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + resolution: + { + integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, + } '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + resolution: + { + integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, + } '@types/react-dom@19.1.7': - resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} + resolution: + { + integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==, + } peerDependencies: '@types/react': ^19.0.0 '@types/react@19.1.9': - resolution: {integrity: sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==} + resolution: + { + integrity: sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==, + } '@typescript-eslint/eslint-plugin@8.38.0': - resolution: {integrity: sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: '@typescript-eslint/parser': ^8.38.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' '@typescript-eslint/parser@8.38.0': - resolution: {integrity: sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' '@typescript-eslint/project-service@8.38.0': - resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: '>=4.8.4 <5.9.0' '@typescript-eslint/scope-manager@8.38.0': - resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@typescript-eslint/tsconfig-utils@8.38.0': - resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: '>=4.8.4 <5.9.0' '@typescript-eslint/type-utils@8.38.0': - resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' '@typescript-eslint/types@8.38.0': - resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@typescript-eslint/typescript-estree@8.38.0': - resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: '>=4.8.4 <5.9.0' '@typescript-eslint/utils@8.38.0': - resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' '@typescript-eslint/visitor-keys@8.38.0': - resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@vitejs/plugin-react-swc@3.11.0': - resolution: {integrity: sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==} + resolution: + { + integrity: sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==, + } peerDependencies: vite: ^4 || ^5 || ^6 || ^7 '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + resolution: + { + integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==, + } '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + resolution: + { + integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==, + } peerDependencies: msw: ^2.4.9 vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 @@ -670,146 +1053,263 @@ packages: optional: true '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + resolution: + { + integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==, + } '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + resolution: + { + integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==, + } '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + resolution: + { + integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==, + } '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + resolution: + { + integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==, + } '@vitest/ui@3.2.4': - resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} + resolution: + { + integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==, + } peerDependencies: vitest: 3.2.4 '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + resolution: + { + integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==, + } acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + resolution: + { + integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, + } peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} + resolution: + { + integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==, + } + engines: { node: '>=0.4.0' } hasBin: true agent-base@7.1.3: - resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} - engines: {node: '>= 14'} + resolution: + { + integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==, + } + engines: { node: '>= 14' } ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + resolution: + { + integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==, + } ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, + } + engines: { node: '>=8' } ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==, + } + engines: { node: '>=4' } ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, + } + engines: { node: '>=8' } ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==, + } + engines: { node: '>=10' } argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + resolution: + { + integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, + } aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + resolution: + { + integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==, + } aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} + resolution: + { + integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==, + } + engines: { node: '>= 0.4' } assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} + resolution: + { + integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==, + } + engines: { node: '>=12' } balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + resolution: + { + integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, + } brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + resolution: + { + integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==, + } brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + resolution: + { + integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==, + } braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, + } + engines: { node: '>=8' } cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==, + } + engines: { node: '>=8' } callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, + } + engines: { node: '>=6' } chai@5.2.1: - resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==, + } + engines: { node: '>=18' } chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==, + } + engines: { node: '>=4' } chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, + } + engines: { node: '>=10' } check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} - engines: {node: '>= 16'} + resolution: + { + integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==, + } + engines: { node: '>= 16' } color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + resolution: + { + integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==, + } color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} + resolution: + { + integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, + } + engines: { node: '>=7.0.0' } color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + resolution: + { + integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==, + } color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + resolution: + { + integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, + } concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: + { + integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, + } cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + resolution: + { + integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, + } + engines: { node: '>= 8' } css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + resolution: + { + integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==, + } cssstyle@4.3.0: - resolution: {integrity: sha512-6r0NiY0xizYqfBvWp1G7WXJ06/bZyrk7Dc6PHql82C/pKGUTKu4yAX4Y8JPamb1ob9nBKuxWzCGTRuGwU3yxJQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-6r0NiY0xizYqfBvWp1G7WXJ06/bZyrk7Dc6PHql82C/pKGUTKu4yAX4Y8JPamb1ob9nBKuxWzCGTRuGwU3yxJQ==, + } + engines: { node: '>=18' } csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + resolution: + { + integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==, + } data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==, + } + engines: { node: '>=18' } debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} - engines: {node: '>=6.0'} + resolution: + { + integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==, + } + engines: { node: '>=6.0' } peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -817,8 +1317,11 @@ packages: optional: true debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} - engines: {node: '>=6.0'} + resolution: + { + integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==, + } + engines: { node: '>=6.0' } peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -826,71 +1329,148 @@ packages: optional: true decimal.js@10.5.0: - resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + resolution: + { + integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==, + } deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==, + } + engines: { node: '>=6' } deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + resolution: + { + integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, + } dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==, + } + engines: { node: '>=6' } dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + resolution: + { + integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==, + } dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + resolution: + { + integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==, + } entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} + resolution: + { + integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, + } + engines: { node: '>=0.12' } es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + resolution: + { + integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==, + } esbuild@0.25.8: - resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==, + } + engines: { node: '>=18' } hasBin: true escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} + resolution: + { + integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==, + } + engines: { node: '>=0.8.0' } escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, + } + engines: { node: '>=10' } + + eslint-config-prettier@10.1.8: + resolution: + { + integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==, + } + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.3: + resolution: + { + integrity: sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==, + } + engines: { node: ^14.18.0 || >=16.0.0 } + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true eslint-plugin-react-hooks@5.2.0: - resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==, + } + engines: { node: '>=10' } peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 eslint-plugin-react-refresh@0.4.20: - resolution: {integrity: sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==} + resolution: + { + integrity: sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==, + } peerDependencies: eslint: '>=8.40' eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { + integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } eslint@9.32.0: - resolution: {integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } hasBin: true peerDependencies: jiti: '*' @@ -899,50 +1479,95 @@ packages: optional: true espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { + integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} - engines: {node: '>=0.10'} + resolution: + { + integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==, + } + engines: { node: '>=0.10' } esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} + resolution: + { + integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==, + } + engines: { node: '>=4.0' } estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} + resolution: + { + integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, + } + engines: { node: '>=4.0' } estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + resolution: + { + integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, + } esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, + } + engines: { node: '>=0.10.0' } expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} - engines: {node: '>=12.0.0'} + resolution: + { + integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==, + } + engines: { node: '>=12.0.0' } fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + resolution: + { + integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, + } + + fast-diff@1.3.0: + resolution: + { + integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==, + } fast-glob@3.3.2: - resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} - engines: {node: '>=8.6.0'} + resolution: + { + integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==, + } + engines: { node: '>=8.6.0' } fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + resolution: + { + integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, + } fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + resolution: + { + integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, + } fastq@1.17.1: - resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + resolution: + { + integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==, + } fdir@6.4.6: - resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + resolution: + { + integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==, + } peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -950,125 +1575,242 @@ packages: optional: true fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + resolution: + { + integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==, + } file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} + resolution: + { + integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, + } + engines: { node: '>=16.0.0' } fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==, + } + engines: { node: '>=8' } find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, + } + engines: { node: '>=10' } flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} + resolution: + { + integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, + } + engines: { node: '>=16' } flatted@3.3.1: - resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + resolution: + { + integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==, + } flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + resolution: + { + integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==, + } fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + resolution: + { + integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, + } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } os: [darwin] glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + 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'} + resolution: + { + integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==, + } + engines: { node: '>=10.13.0' } globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==, + } + engines: { node: '>=18' } graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + resolution: + { + integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==, + } has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==, + } + engines: { node: '>=4' } has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, + } + engines: { node: '>=8' } html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==, + } + engines: { node: '>=18' } http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} + resolution: + { + integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==, + } + engines: { node: '>= 14' } https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} + resolution: + { + integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==, + } + engines: { node: '>= 14' } iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==, + } + engines: { node: '>=0.10.0' } ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} + resolution: + { + integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, + } + engines: { node: '>= 4' } ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} + resolution: + { + integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==, + } + engines: { node: '>= 4' } import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==, + } + engines: { node: '>=6' } imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} + resolution: + { + integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, + } + engines: { node: '>=0.8.19' } indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==, + } + engines: { node: '>=8' } is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, + } + engines: { node: '>=0.10.0' } is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, + } + engines: { node: '>=0.10.0' } is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} + resolution: + { + integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, + } + engines: { node: '>=0.12.0' } is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + resolution: + { + integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==, + } isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + resolution: + { + integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, + } + + jotai@2.13.0: + resolution: + { + integrity: sha512-H43zXdanNTdpfOEJ4NVbm4hgmrctpXLZagjJNcqAywhUv+sTE7esvFjwm5oBg/ywT9Qw63lIkM6fjrhFuW8UDg==, + } + engines: { node: '>=12.20.0' } + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + resolution: + { + integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, + } js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + resolution: + { + integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==, + } js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + resolution: + { + integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==, + } hasBin: true jsdom@26.1.0: - resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==, + } + engines: { node: '>=18' } peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: @@ -1076,323 +1818,603 @@ packages: optional: true json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + resolution: + { + integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, + } json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + resolution: + { + integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, + } json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + resolution: + { + integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, + } keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + resolution: + { + integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, + } levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} + resolution: + { + integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, + } + engines: { node: '>= 0.8.0' } locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, + } + engines: { node: '>=10' } lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + resolution: + { + integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, + } lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + resolution: + { + integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==, + } loupe@3.1.2: - resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} + resolution: + { + integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==, + } loupe@3.2.0: - resolution: {integrity: sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==} + resolution: + { + integrity: sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==, + } lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + resolution: + { + integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==, + } lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + resolution: + { + integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==, + } hasBin: true magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + resolution: + { + integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==, + } merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + resolution: + { + integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, + } + engines: { node: '>= 8' } micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + resolution: + { + integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, + } + engines: { node: '>=8.6' } min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==, + } + engines: { node: '>=4' } minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + resolution: + { + integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==, + } minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} + resolution: + { + integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==, + } + engines: { node: '>=16 || 14 >=14.17' } mrmime@2.0.0: - resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==, + } + engines: { node: '>=10' } ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + resolution: + { + integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, + } nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + resolution: + { + integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + resolution: + { + integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, + } nwsapi@2.2.20: - resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} + resolution: + { + integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==, + } optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} + resolution: + { + integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, + } + engines: { node: '>= 0.8.0' } p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, + } + engines: { node: '>=10' } p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, + } + engines: { node: '>=10' } parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, + } + engines: { node: '>=6' } parse5@7.2.1: - resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + resolution: + { + integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==, + } path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, + } + engines: { node: '>=8' } path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, + } + engines: { node: '>=8' } pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + resolution: + { + integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, + } pathval@2.0.0: - resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} - engines: {node: '>= 14.16'} + resolution: + { + integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==, + } + engines: { node: '>= 14.16' } picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + resolution: + { + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, + } picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} + resolution: + { + integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==, + } + engines: { node: '>=8.6' } picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} - engines: {node: '>=12'} + resolution: + { + integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==, + } + engines: { node: '>=12' } picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} + resolution: + { + integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, + } + engines: { node: '>=12' } postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} + resolution: + { + integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==, + } + engines: { node: ^10 || ^12 || >=14 } prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} + resolution: + { + integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, + } + engines: { node: '>= 0.8.0' } + + prettier-linter-helpers@1.0.0: + resolution: + { + integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==, + } + engines: { node: '>=6.0.0' } + + prettier@3.6.2: + resolution: + { + integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==, + } + engines: { node: '>=14' } + hasBin: true pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + resolution: + { + integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==, + } + engines: { node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0 } punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, + } + engines: { node: '>=6' } queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + resolution: + { + integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, + } react-dom@19.1.1: - resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} + resolution: + { + integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==, + } peerDependencies: react: ^19.1.1 react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + resolution: + { + integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==, + } react@19.1.1: - resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==, + } + engines: { node: '>=0.10.0' } redent@3.0.0: - resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==, + } + engines: { node: '>=8' } regenerator-runtime@0.14.1: - resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + resolution: + { + integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==, + } resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, + } + engines: { node: '>=4' } reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + resolution: + { + integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==, + } + engines: { iojs: '>=1.0.0', node: '>=0.10.0' } rollup@4.46.2: - resolution: {integrity: sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} + resolution: + { + integrity: sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==, + } + engines: { node: '>=18.0.0', npm: '>=8.0.0' } hasBin: true rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + resolution: + { + integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==, + } run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + resolution: + { + integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, + } safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + resolution: + { + integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, + } saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} + resolution: + { + integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==, + } + engines: { node: '>=v12.22.7' } scheduler@0.26.0: - resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + resolution: + { + integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==, + } semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==, + } + engines: { node: '>=10' } hasBin: true shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, + } + engines: { node: '>=8' } shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, + } + engines: { node: '>=8' } siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + resolution: + { + integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, + } sirv@3.0.1: - resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==, + } + engines: { node: '>=18' } source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, + } + engines: { node: '>=0.10.0' } stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + resolution: + { + integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, + } std-env@3.9.0: - resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + resolution: + { + integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==, + } strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==, + } + engines: { node: '>=8' } strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==, + } + engines: { node: '>=8' } strip-literal@3.0.0: - resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + resolution: + { + integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==, + } supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} + resolution: + { + integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==, + } + engines: { node: '>=4' } supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, + } + engines: { node: '>=8' } symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + resolution: + { + integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==, + } + + synckit@0.11.11: + resolution: + { + integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==, + } + engines: { node: ^14.18.0 || >=16.0.0 } tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + resolution: + { + integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, + } tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + resolution: + { + integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, + } tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} - engines: {node: '>=12.0.0'} + resolution: + { + integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==, + } + engines: { node: '>=12.0.0' } tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} + resolution: + { + integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==, + } + engines: { node: ^18.0.0 || >=20.0.0 } tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.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'} + resolution: + { + integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==, + } + engines: { node: '>=14.0.0' } tldts-core@6.1.86: - resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + resolution: + { + integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==, + } tldts@6.1.86: - resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + resolution: + { + integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==, + } hasBin: true to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + resolution: + { + integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, + } + engines: { node: '>=8.0' } totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} - engines: {node: '>=6'} + resolution: + { + integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==, + } + engines: { node: '>=6' } tough-cookie@5.1.2: - resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} - engines: {node: '>=16'} + resolution: + { + integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==, + } + engines: { node: '>=16' } tr46@5.1.1: - resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==, + } + engines: { node: '>=18' } ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} - engines: {node: '>=18.12'} + resolution: + { + integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==, + } + engines: { node: '>=18.12' } peerDependencies: typescript: '>=4.8.4' type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} + resolution: + { + integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, + } + engines: { node: '>= 0.8.0' } typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} - engines: {node: '>=14.17'} + resolution: + { + integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==, + } + engines: { node: '>=14.17' } hasBin: true uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + resolution: + { + integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, + } vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + resolution: + { + integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==, + } + engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } hasBin: true vite@7.0.6: - resolution: {integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { + integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 @@ -1431,8 +2453,11 @@ packages: optional: true vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + resolution: + { + integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==, + } + engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } hasBin: true peerDependencies: '@edge-runtime/vm': '*' @@ -1459,42 +2484,69 @@ packages: optional: true w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==, + } + engines: { node: '>=18' } webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} + resolution: + { + integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==, + } + engines: { node: '>=12' } whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==, + } + engines: { node: '>=18' } whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==, + } + engines: { node: '>=18' } whatwg-url@14.2.0: - resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==, + } + engines: { node: '>=18' } which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} + resolution: + { + integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, + } + engines: { node: '>= 8' } hasBin: true why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==, + } + engines: { node: '>=8' } hasBin: true word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, + } + engines: { node: '>=0.10.0' } ws@8.18.1: - resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} - engines: {node: '>=10.0.0'} + resolution: + { + integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==, + } + engines: { node: '>=10.0.0' } peerDependencies: bufferutil: ^4.0.1 utf-8-validate: '>=5.0.2' @@ -1505,18 +2557,26 @@ packages: optional: true xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} + resolution: + { + integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==, + } + engines: { node: '>=18' } xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + resolution: + { + integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==, + } yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + resolution: + { + integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, + } + engines: { node: '>=10' } snapshots: - '@adobe/css-tools@4.4.0': {} '@asamuzakjp/css-color@3.1.2': @@ -1657,6 +2717,10 @@ snapshots: '@eslint-community/regexpp@4.12.1': {} + '@eslint/compat@1.3.1(eslint@9.32.0)': + optionalDependencies: + eslint: 9.32.0 + '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 @@ -1721,6 +2785,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@pkgr/core@0.2.9': {} + '@polka/url@1.0.0-next.28': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -2216,6 +3282,19 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-config-prettier@10.1.8(eslint@9.32.0): + dependencies: + eslint: 9.32.0 + + eslint-plugin-prettier@5.5.3(eslint-config-prettier@10.1.8(eslint@9.32.0))(eslint@9.32.0)(prettier@3.6.2): + dependencies: + eslint: 9.32.0 + prettier: 3.6.2 + prettier-linter-helpers: 1.0.0 + synckit: 0.11.11 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@9.32.0) + eslint-plugin-react-hooks@5.2.0(eslint@9.32.0): dependencies: eslint: 9.32.0 @@ -2299,6 +3378,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2413,6 +3494,11 @@ snapshots: isexe@2.0.0: {} + jotai@2.13.0(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -2559,6 +3645,12 @@ snapshots: prelude-ls@1.2.1: {} + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + + prettier@3.6.2: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -2671,6 +3763,10 @@ snapshots: symbol-tree@3.2.4: {} + synckit@0.11.11: + dependencies: + '@pkgr/core': 0.2.9 + tinybench@2.9.0: {} tinyexec@0.3.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0ba40649..f004054c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,6 @@ ignoredBuiltDependencies: onlyBuiltDependencies: - '@swc/core' + +packages: + - '.' \ No newline at end of file diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1..83129542 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,443 +1,25 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useState } from 'react'; +import { Button } from './components/ui/Button'; +import { Notification as UINotification } from './components/ui/Notification'; +import { ShoppingPage } from './components/pages/ShoppingPage'; +import { AdminPage } from './components/pages/AdminPage'; +import { useNotifications } from './hooks/useNotifications'; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); + const { notification } = useNotifications(); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; return (
- {notifications.length > 0 && ( + {notification && (
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} + { + /* Handled by atom's setTimeout */ + }} + />
)}
@@ -445,680 +27,25 @@ const App = () => {

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )}
- {isAdmin ? ( -
-
-

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

-
-
- -
- - {activeTab === 'products' ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - - - ))} - -
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} -
- ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {products.length}개 상품 -
-
- {filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

-
- ) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

- )} -
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
-
- -
-
-
-

- - - - 장바구니 -

- {cart.length === 0 ? ( -
- - - -

장바구니가 비어있습니다

-
- ) : ( -
- {cart.map(item => { - const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; - const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - - return ( -
-
-

{item.product.name}

- -
-
-
- - {item.quantity} - -
-
- {hasDiscount && ( - -{discountRate}% - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
-
-
- ); - })} -
- )} -
- - {cart.length > 0 && ( - <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
- -
-

결제 정보

-
-
- 상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 -
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( -
- 할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 -
- )} -
- 결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 -
-
- - - -
-

* 실제 결제는 이루어지지 않습니다

-
-
- - )} -
-
-
- )} + {isAdmin ? : }
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55..38dfbc80 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,5 +1,11 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from '@testing-library/react'; import { vi } from 'vitest'; import App from '../App'; import '../../setupTests'; @@ -20,25 +26,30 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { render(); - + // 검색창에 "프리미엄" 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); + }, + { timeout: 600 } + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) const addButtons = screen.getAllByText('장바구니 담기'); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -46,64 +57,66 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { render(); - + // 상품1을 장바구니에 추가 const product1 = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) expect(screen.getByText('-15%')).toBeInTheDocument(); }); test('쿠폰을 선택하고 적용할 수 있다', () => { render(); - + // 상품 추가 const addButton = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(addButton); - + // 쿠폰 선택 const couponSelect = screen.getByRole('combobox'); fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + // 결제 정보에서 할인 금액 확인 const paymentSection = screen.getByText('결제 정보').closest('section'); - const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); + const discountRow = within(paymentSection) + .getByText('할인 금액') + .closest('div'); expect(within(discountRow).getByText('-5,000원')).toBeInTheDocument(); }); test('품절 임박 상품에 경고가 표시된다', async () => { render(); - + // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); - + // 상품 수정 const editButton = screen.getAllByText('수정')[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 fireEvent.change(stockInput, { target: { value: '5' } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 const editButtons = screen.getAllByText('수정'); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); @@ -112,39 +125,39 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('주문을 완료할 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); }); test('장바구니에서 상품을 삭제할 수 있다', () => { render(); - + // 상품 2개 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 장바구니 섹션 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole('button') + .filter((button) => button.querySelector('svg')); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); @@ -152,54 +165,56 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('재고를 초과하여 구매할 수 없다', async () => { render(); - + // 상품1 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 재고(20개) 이상으로 증가 시도 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + // 재고 부족 메시지 확인 await waitFor(() => { - expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/) + ).toBeInTheDocument(); }); }); test('장바구니에서 수량을 감소시킬 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + // 수량 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + // 1개로 더 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 @@ -214,31 +229,31 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('20개 이상 구매 시 최대 할인이 적용된다', async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getAllByText('수정')[0]); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '30' } }); - + const editButtons = screen.getAllByText('수정'); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 상품1을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 20개로 증가 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { const discount25 = screen.queryByText('-25%'); @@ -258,27 +273,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('새 상품을 추가할 수 있다', () => { // 새 상품 추가 버튼 클릭 fireEvent.click(screen.getByText('새 상품 추가')); - + // 폼 입력 - 상품명 입력 const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '25000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '50' } }); - + const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); + const descLabel = descLabels.find((el) => el.tagName === 'LABEL'); const descInput = descLabel.closest('div').querySelector('input'); fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + // 저장 fireEvent.click(screen.getByText('추가')); - + // 추가된 상품 확인 expect(screen.getByText('테스트 상품')).toBeInTheDocument(); expect(screen.getByText('25,000원')).toBeInTheDocument(); @@ -287,21 +302,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 버튼 클릭 const addCouponButton = screen.getByText('새 쿠폰 추가'); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - + fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { + target: { value: '테스트 쿠폰' }, + }); + fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { + target: { value: 'TEST2024' }, + }); + const discountInput = screen.getByPlaceholderText('5000'); fireEvent.change(discountInput, { target: { value: '7000' } }); - + // 쿠폰 생성 fireEvent.click(screen.getByText('쿠폰 생성')); - + // 생성된 쿠폰 확인 expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); expect(screen.getByText('TEST2024')).toBeInTheDocument(); @@ -311,25 +330,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('상품의 가격 입력 시 숫자만 허용된다', async () => { // 상품 수정 fireEvent.click(screen.getAllByText('수정')[0]); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 fireEvent.change(priceInput, { target: { value: 'abc123def' } }); expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + // 숫자만 입력 fireEvent.change(priceInput, { target: { value: '123' } }); expect(priceInput.value).toBe('123'); - + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 fireEvent.change(priceInput, { target: { value: '-100' } }); expect(priceInput.value).toBe('123'); // 이전 값 유지 - + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 fireEvent.change(priceInput, { target: { value: ' ' } }); expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 @@ -338,23 +357,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 할인율 검증이 작동한다', async () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 fireEvent.click(screen.getByText('새 쿠폰 추가')); - + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 const couponFormSelects = screen.getAllByRole('combobox'); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + // 100% 초과 할인율 입력 const discountInput = screen.getByPlaceholderText('10'); fireEvent.change(discountInput, { target: { value: '150' } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { - expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); + expect( + screen.getByText('할인율은 100%를 초과할 수 없습니다') + ).toBeInTheDocument(); }); }); @@ -362,15 +383,15 @@ describe('쇼핑몰 앱 통합 테스트', () => { // 초기 상품명들 확인 (테이블에서) const productTable = screen.getByRole('table'); expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole('button') + .filter((button) => button.textContent === '삭제'); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); expect(within(productTable).getByText('상품2')).toBeInTheDocument(); @@ -379,76 +400,79 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰을 삭제할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 초기 쿠폰들 확인 (h3 제목에서) const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const coupon5000 = couponTitles.find( + (el) => el.textContent === '5000원 할인' + ); + const coupon10 = couponTitles.find((el) => el.textContent === '10% 할인'); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole('button').filter((button) => { + return ( + button.querySelector('svg') && + button.querySelector('path[d*="M19 7l"]') + ); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); }); - }); describe('로컬스토리지 동기화', () => { test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { render(); - + // 상품을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // localStorage 확인 expect(localStorage.getItem('cart')).toBeTruthy(); expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + // 관리자 모드로 전환하여 새 상품 추가 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getByText('새 상품 추가')); - + const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '10000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '10' } }); - + fireEvent.click(screen.getByText('추가')); - + // localStorage에 products가 저장되었는지 확인 expect(localStorage.getItem('products')).toBeTruthy(); const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(products.some((p) => p.name === '저장 테스트')).toBe(true); }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { const { unmount } = render(); - + // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -459,13 +483,13 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { render(); - + // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { expect(screen.getByText('-15%')).toBeInTheDocument(); @@ -474,12 +498,12 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니 아이템 개수가 헤더에 표시된다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 헤더의 장바구니 아이콘 옆 숫자 확인 const cartCount = screen.getByText('3'); expect(cartCount).toBeInTheDocument(); @@ -487,42 +511,57 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('검색을 초기화할 수 있다', async () => { render(); - + // 검색어 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 검색 결과 확인 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); // 다른 상품들은 보이지 않음 - expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); + expect( + screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.') + ).not.toBeInTheDocument(); }); - + // 검색어 초기화 fireEvent.change(searchInput, { target: { value: '' } }); - + // 모든 상품이 다시 표시됨 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('대용량과 고성능을 자랑하는 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); + expect( + screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.') + ).toBeInTheDocument(); + expect( + screen.getByText('대용량과 고성능을 자랑하는 상품입니다.') + ).toBeInTheDocument(); }); }); test('알림 메시지가 자동으로 사라진다', async () => { render(); - + // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 알림 메시지 확인 expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect( + screen.queryByText('장바구니에 담았습니다') + ).not.toBeInTheDocument(); + }, + { timeout: 4000 } + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/advanced/atoms/cartAtoms.ts b/src/advanced/atoms/cartAtoms.ts new file mode 100644 index 00000000..d602c866 --- /dev/null +++ b/src/advanced/atoms/cartAtoms.ts @@ -0,0 +1,99 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { productsAtom } from './productAtoms'; +import { addNotificationAtom } from './notificationAtoms'; +import { calculateItemTotal } from '../utils/calculators'; +import { CartItem, Product, ProductWithUI } from '../types'; + +export const cartAtom = atomWithStorage('cart', []); + +export const getRemainingStockAtom = atom((get) => (product: Product) => { + const cart = get(cartAtom); + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}); + +export const addToCartAtom = atom(null, (get, set, product: ProductWithUI) => { + const getRemainingStock = get(getRemainingStockAtom); + const remainingStock = getRemainingStock(product); + + if (remainingStock <= 0) { + set(addNotificationAtom, '재고가 부족합니다!', 'error'); + return; + } + + set(cartAtom, (prevCart) => { + const existingItem = prevCart.find( + (item) => item.product.id === product.id + ); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + set( + addNotificationAtom, + `재고는 ${product.stock}개까지만 있습니다.`, + 'error' + ); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + set(addNotificationAtom, '장바구니에 담았습니다', 'success'); +}); + +export const removeFromCartAtom = atom(null, (_get, set, productId: string) => { + set(cartAtom, (prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); +}); + +export const updateQuantityAtom = atom( + null, + (get, set, productId: string, newQuantity: number) => { + const products = get(productsAtom); + + if (newQuantity <= 0) { + set(cartAtom, (prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); + return; + } + + const product = products.find((p) => p.id === productId); + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + set(addNotificationAtom, `재고는 ${maxStock}개까지만 있습니다.`, 'error'); + return; + } + + set(cartAtom, (prevCart) => + prevCart.map((item) => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + } +); + +export const calculateCartItemTotalAtom = atom((get) => (item: CartItem) => { + const cart = get(cartAtom); + return calculateItemTotal(item, cart); +}); + +export const clearCartAtom = atom(null, (_get, set) => { + set(cartAtom, []); +}); diff --git a/src/advanced/atoms/couponAtoms.ts b/src/advanced/atoms/couponAtoms.ts new file mode 100644 index 00000000..fa57f8b3 --- /dev/null +++ b/src/advanced/atoms/couponAtoms.ts @@ -0,0 +1,53 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { addNotificationAtom } from './notificationAtoms'; +import { cartAtom } from './cartAtoms'; +import { calculateCartTotal } from '../utils/calculators'; +import { Coupon, initialCoupons } from '../types'; + +export const couponsAtom = atomWithStorage('coupons', initialCoupons); +export const selectedCouponAtom = atom(null); + +export const applyCouponAtom = atom(null, (get, set, coupon: Coupon) => { + const cart = get(cartAtom); + const selectedCoupon = get(selectedCouponAtom); + + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + set( + addNotificationAtom, + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + + set(selectedCouponAtom, coupon); + set(addNotificationAtom, '쿠폰이 적용되었습니다.', 'success'); +}); + +export const addCouponAtom = atom(null, (get, set, newCoupon: Coupon) => { + const coupons = get(couponsAtom); + + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + set(addNotificationAtom, '이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + set(couponsAtom, (prev) => [...prev, newCoupon]); + set(addNotificationAtom, '쿠폰이 추가되었습니다.', 'success'); +}); + +export const deleteCouponAtom = atom(null, (get, set, couponCode: string) => { + const selectedCoupon = get(selectedCouponAtom); + + set(couponsAtom, (prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + set(selectedCouponAtom, null); + } + set(addNotificationAtom, '쿠폰이 삭제되었습니다.', 'success'); +}); diff --git a/src/advanced/atoms/notificationAtoms.ts b/src/advanced/atoms/notificationAtoms.ts new file mode 100644 index 00000000..a7ebd9ad --- /dev/null +++ b/src/advanced/atoms/notificationAtoms.ts @@ -0,0 +1,16 @@ +import { atom } from 'jotai'; + +export const notificationAtom = atom<{ + message: string; + type: 'error' | 'success' | 'warning'; +} | null>(null); + +export const addNotificationAtom = atom( + null, + (_get, set, message: string, type: 'error' | 'success' | 'warning') => { + set(notificationAtom, { message, type }); + setTimeout(() => { + set(notificationAtom, null); + }, 3000); + } +); diff --git a/src/advanced/atoms/productAtoms.ts b/src/advanced/atoms/productAtoms.ts new file mode 100644 index 00000000..8f1b0774 --- /dev/null +++ b/src/advanced/atoms/productAtoms.ts @@ -0,0 +1,68 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { Product, ProductWithUI } from '../types'; + +const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: '최고급 품질의 프리미엄 상품입니다.', + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true, + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; + +export const productsAtom = atomWithStorage( + 'products', + initialProducts +); + +export const addProductAtom = atom( + null, + (_get, set, newProduct: Omit) => { + const product: Product = { + ...newProduct, + id: `p${Date.now()}`, + }; + set(productsAtom, (prev) => [...prev, product]); + } +); + +export const updateProductAtom = atom( + null, + (_get, set, productId: string, updates: Partial) => { + set(productsAtom, (prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + } +); + +export const deleteProductAtom = atom(null, (_get, set, productId: string) => { + set(productsAtom, (prev) => prev.filter((p) => p.id !== productId)); +}); diff --git a/src/advanced/components/CartItem.tsx b/src/advanced/components/CartItem.tsx new file mode 100644 index 00000000..1012ab4e --- /dev/null +++ b/src/advanced/components/CartItem.tsx @@ -0,0 +1,86 @@ +import { CartItem } from '../types'; +import { Button } from './ui/Button'; + +// components/CartItem.tsx +interface CartItemProps { + item: CartItem; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; + calculateItemTotal: (item: CartItem) => number; +} + +export const CartItemComponent = ({ + item, + onUpdateQuantity, + onRemove, + calculateItemTotal, +}: CartItemProps) => { + const itemTotal = calculateItemTotal(item); + + return ( +
+
+

+ {item.product.name} +

+
+
+
+ + + {item.quantity} + + +
+

+ {itemTotal.toLocaleString()}원 +

+ {(() => { + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + return ( + hasDiscount && ( + + -{discountRate}% + + ) + ); + })()} +
+
+ ); +}; diff --git a/src/advanced/components/ProductCard.tsx b/src/advanced/components/ProductCard.tsx new file mode 100644 index 00000000..7d6e07d1 --- /dev/null +++ b/src/advanced/components/ProductCard.tsx @@ -0,0 +1,92 @@ +import { ProductWithUI } from '../types'; +import { Button } from './ui/Button'; + +interface ProductCardProps { + product: ProductWithUI; + onAddToCart: (product: ProductWithUI) => void; + getRemainingStock: (product: ProductWithUI) => number; + formatPrice: (price: number) => string; +} + +export const ProductCard = ({ + product, + onAddToCart, + getRemainingStock, + formatPrice, +}: ProductCardProps) => { + const remainingStock = getRemainingStock(product); + + return ( +
+
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

+ )} + + {/* 가격 정보 */} +
+

+ {formatPrice(product.price)} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인{' '} + {product.discounts[0].rate * 100}% +

+ )} +
+ + {/* 재고 상태 */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

+ 품절임박! {remainingStock}개 남음 +

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + +
+
+ ); +}; diff --git a/src/advanced/components/pages/AdminPage.tsx b/src/advanced/components/pages/AdminPage.tsx new file mode 100644 index 00000000..48231b7b --- /dev/null +++ b/src/advanced/components/pages/AdminPage.tsx @@ -0,0 +1,662 @@ +// components/pages/AdminPage.tsx +import { useState } from 'react'; +import { useProducts } from '../../hooks/useProducts'; +import { useCoupons } from '../../hooks/useCoupons'; +import { useSetAtom } from 'jotai'; +import { addNotificationAtom } from '../../atoms/notificationAtoms'; +import { formatPriceForAdmin as formatPrice } from '../../utils/formatters'; +import { Button } from '../ui/Button'; +import { ProductWithUI } from '../../types'; + +export const AdminPage = () => { + const setAddNotification = useSetAtom(addNotificationAtom); + const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + const { + coupons, + addCoupon, + deleteCoupon, + } = useCoupons(); + + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( + 'products' + ); + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, + }); + + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts, + }); + } + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + setShowCouponForm(false); + }; + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + + return ( +
+
+

관리자 대시보드

+

상품과 쿠폰을 관리할 수 있습니다

+
+
+ +
+ + {activeTab === 'products' ? ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {(activeTab === 'products' ? products : products).map( + (product) => ( + + + + + + + + ) + )} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPrice(product.price)} + + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + + {product.description || '-'} + + + +
+
+ {showProductForm && ( +
+
+

+ {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} +

+
+
+ + + setProductForm({ + ...productForm, + name: e.target.value, + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + required + /> +
+
+ + + setProductForm({ + ...productForm, + description: e.target.value, + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + setAddNotification( + '가격은 0보다 커야 합니다', + 'error' + ); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + setAddNotification( + '재고는 0보다 커야 합니다', + 'error' + ); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + setAddNotification( + '재고는 9999개를 초과할 수 없습니다', + 'error' + ); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = + parseInt(e.target.value) || 0; + setProductForm({ + ...productForm, + discounts: newDiscounts, + }); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = + (parseInt(e.target.value) || 0) / 100; + setProductForm({ + ...productForm, + discounts: newDiscounts, + }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 +
+ ))} + +
+
+ +
+ + +
+
+
+ )} +
+ ) : ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( +
+
+
+

+ {coupon.name} +

+

+ {coupon.code} +

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( +
+
+

+ 새 쿠폰 생성 +

+
+
+ + + setCouponForm({ + ...couponForm, + name: e.target.value, + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ + ...couponForm, + code: e.target.value.toUpperCase(), + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === 'percentage') { + if (value > 100) { + setAddNotification( + '할인율은 100%를 초과할 수 없습니다', + 'error' + ); + setCouponForm({ + ...couponForm, + discountValue: 100, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } else { + if (value > 100000) { + setAddNotification( + '할인 금액은 100,000원을 초과할 수 없습니다', + 'error' + ); + setCouponForm({ + ...couponForm, + discountValue: 100000, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder={ + couponForm.discountType === 'amount' ? '5000' : '10' + } + required + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +}; diff --git a/src/advanced/components/pages/ShoppingPage.tsx b/src/advanced/components/pages/ShoppingPage.tsx new file mode 100644 index 00000000..3148cfef --- /dev/null +++ b/src/advanced/components/pages/ShoppingPage.tsx @@ -0,0 +1,286 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { formatPriceForShop as formatPrice } from '../../utils/formatters'; +import { useProducts } from '../../hooks/useProducts'; +import { useCart } from '../../hooks/useCart'; +import { useCoupons } from '../../hooks/useCoupons'; +import { useNotifications } from '../../hooks/useNotifications'; +import { ProductCard } from '../ProductCard'; +import { CartItemComponent } from '../CartItem'; +import { Button } from '../ui/Button'; +import { cartAtom, clearCartAtom } from '../../atoms/cartAtoms'; +import { calculateCartTotal } from '../../utils/calculators'; + +export const ShoppingPage = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + + const { products } = useProducts(); + const { + cart, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + calculateItemTotal, + } = useCart(); + const { selectedCoupon, setSelectedCoupon, coupons, applyCoupon } = + useCoupons(); + const { addNotification } = useNotifications(); + + const totalItemCount = useAtomValue(cartAtom).reduce( + (sum, item) => sum + item.quantity, + 0 + ); + const cartTotal = calculateCartTotal(cart, selectedCoupon); + const clearCart = useSetAtom(clearCartAtom); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + 'success' + ); + clearCart(); + setSelectedCoupon(null); + }, [addNotification, clearCart, setSelectedCoupon]); + + const filteredProducts = debouncedSearchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + + return ( +
+
+
+
+
+

SHOP

+
+ setSearchTerm(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+
+ +
+
+
+
+
+
+ {/* 상품 목록 */} +
+
+

+ 전체 상품 +

+
+ 총 {products.length}개 상품 +
+
+ {filteredProducts.length === 0 ? ( +
+

+ "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다. +

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+
+ +
+
+
+

+ + + + 장바구니 +

+ {cart.length === 0 ? ( +
+ + + +

+ 장바구니가 비어있습니다 +

+
+ ) : ( +
+ {cart.map((item) => ( + + ))} +
+ )} +
+ + {cart.length > 0 && ( + <> +
+
+

+ 쿠폰 할인 +

+ +
+ {coupons.length > 0 && ( + + )} +
+ +
+

결제 정보

+
+
+ 상품 금액 + + {cartTotal.totalBeforeDiscount.toLocaleString()}원 + +
+ {cartTotal.totalBeforeDiscount - + cartTotal.totalAfterDiscount > + 0 && ( +
+ 할인 금액 + + - + {( + cartTotal.totalBeforeDiscount - + cartTotal.totalAfterDiscount + ).toLocaleString()} + 원 + +
+ )} +
+ 결제 예정 금액 + + {cartTotal.totalAfterDiscount.toLocaleString()}원 + +
+
+ + + +
+

* 실제 결제는 이루어지지 않습니다

+
+
+ + )} +
+
+
+
+
+ ); +}; diff --git a/src/advanced/components/ui/Button.tsx b/src/advanced/components/ui/Button.tsx new file mode 100644 index 00000000..fc052047 --- /dev/null +++ b/src/advanced/components/ui/Button.tsx @@ -0,0 +1,37 @@ +interface ButtonProps { + children?: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: 'primary' | 'secondary' | 'danger'; + className?: string; + icon?: React.ReactNode; + type?: 'button' | 'submit' | 'reset'; +} + +export const Button = ({ + children, + onClick, + disabled, + variant = 'primary', + className = '', + icon, +}: ButtonProps) => { + const baseClasses = + 'px-4 py-2 rounded-md font-medium transition-colors flex items-center justify-center'; + const variantClasses = { + primary: 'bg-gray-900 text-white hover:bg-gray-800', + secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200', + danger: 'bg-red-600 text-white hover:bg-red-700', + }; + + return ( + + ); +}; diff --git a/src/advanced/components/ui/Notification.tsx b/src/advanced/components/ui/Notification.tsx new file mode 100644 index 00000000..0d4dd96b --- /dev/null +++ b/src/advanced/components/ui/Notification.tsx @@ -0,0 +1,36 @@ +interface NotificationProps { + message: string; + type: 'error' | 'success' | 'warning'; + onClose: () => void; +} + +export const Notification = ({ message, type, onClose }: NotificationProps) => { + const bgColor = { + error: 'bg-red-600', + success: 'bg-green-600', + warning: 'bg-yellow-600', + }[type]; + + return ( +
+ {message} + +
+ ); +}; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 00000000..9fc9c7bf --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,27 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { + cartAtom, + addToCartAtom, + removeFromCartAtom, + updateQuantityAtom, + getRemainingStockAtom, + calculateCartItemTotalAtom, +} from '../atoms/cartAtoms'; + +export const useCart = () => { + const cart = useAtomValue(cartAtom); + const addToCart = useSetAtom(addToCartAtom); + const removeFromCart = useSetAtom(removeFromCartAtom); + const updateQuantity = useSetAtom(updateQuantityAtom); + const getRemainingStock = useAtomValue(getRemainingStockAtom); + const calculateItemTotal = useAtomValue(calculateCartItemTotalAtom); + + return { + cart, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + calculateItemTotal, + }; +}; diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts new file mode 100644 index 00000000..e387a3f6 --- /dev/null +++ b/src/advanced/hooks/useCoupons.ts @@ -0,0 +1,26 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { + couponsAtom, + selectedCouponAtom, + applyCouponAtom, + addCouponAtom, + deleteCouponAtom, +} from '../atoms/couponAtoms'; + +export const useCoupons = () => { + const coupons = useAtomValue(couponsAtom); + const selectedCoupon = useAtomValue(selectedCouponAtom); + const setSelectedCoupon = useSetAtom(selectedCouponAtom); + const applyCoupon = useSetAtom(applyCouponAtom); + const addCoupon = useSetAtom(addCouponAtom); + const deleteCoupon = useSetAtom(deleteCouponAtom); + + return { + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, + addCoupon, + deleteCoupon, + }; +}; diff --git a/src/advanced/hooks/useLocalStorage.ts b/src/advanced/hooks/useLocalStorage.ts new file mode 100644 index 00000000..aeed0b5c --- /dev/null +++ b/src/advanced/hooks/useLocalStorage.ts @@ -0,0 +1,25 @@ +import { useState, useEffect } from 'react'; + +export const useLocalStorage = (key: string, initialValue: T) => { + const [storedValue, setStoredValue] = useState(() => { + const saved = localStorage.getItem(key); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialValue; + } + } + return initialValue; + }); + + useEffect(() => { + if (storedValue !== undefined) { + localStorage.setItem(key, JSON.stringify(storedValue)); + } else { + localStorage.removeItem(key); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue] as const; +}; diff --git a/src/advanced/hooks/useNotifications.ts b/src/advanced/hooks/useNotifications.ts new file mode 100644 index 00000000..0f96a278 --- /dev/null +++ b/src/advanced/hooks/useNotifications.ts @@ -0,0 +1,15 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { + notificationAtom, + addNotificationAtom, +} from '../atoms/notificationAtoms'; + +export const useNotifications = () => { + const notification = useAtomValue(notificationAtom); + const addNotification = useSetAtom(addNotificationAtom); + + return { + notification, + addNotification, + }; +}; diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts new file mode 100644 index 00000000..acb6b2c6 --- /dev/null +++ b/src/advanced/hooks/useProducts.ts @@ -0,0 +1,21 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { + productsAtom, + addProductAtom, + updateProductAtom, + deleteProductAtom, +} from '../atoms/productAtoms'; + +export const useProducts = () => { + const products = useAtomValue(productsAtom); + const addProduct = useSetAtom(addProductAtom); + const updateProduct = useSetAtom(updateProductAtom); + const deleteProduct = useSetAtom(deleteProductAtom); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + }; +}; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a..f88d2765 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,12 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import { Provider } from 'jotai'; ReactDOM.createRoot(document.getElementById('root')!).render( - - , -) + + + + +); diff --git a/src/advanced/types.ts b/src/advanced/types.ts new file mode 100644 index 00000000..dc7cf77a --- /dev/null +++ b/src/advanced/types.ts @@ -0,0 +1,50 @@ +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface Coupon { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} + +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; diff --git a/src/advanced/utils/calculators.ts b/src/advanced/utils/calculators.ts new file mode 100644 index 00000000..e7b6d237 --- /dev/null +++ b/src/advanced/utils/calculators.ts @@ -0,0 +1,64 @@ +import { CartItem, Coupon } from "../types"; + +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] = [] +): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; + +export const calculateItemTotal = ( + item: CartItem, + cart: CartItem[] = [] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + return Math.round(price * quantity * (1 - discount)); +}; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +) => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 00000000..ffa8cc27 --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,7 @@ +export const formatPriceForAdmin = (price: number): string => { + return `${price.toLocaleString()}원`; +}; + +export const formatPriceForShop = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; \ No newline at end of file diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 00000000..ac05df65 --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,9 @@ +import { Product } from "../types"; + +export const validateProductForm = (form: Omit): string[] => { + const errors: string[] = []; + if (!form.name.trim()) errors.push('상품명은 필수입니다'); + if (form.price <= 0) errors.push('가격은 0보다 커야 합니다'); + if (form.stock < 0) errors.push('재고는 0 이상이어야 합니다'); + return errors; +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1..b81ed540 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,442 +1,41 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useState, useCallback } from 'react'; +import { Button } from './components/ui/Button'; +import { Notification as UINotification } from './components/ui/Notification'; +import { Notification } from '../types'; +import { ShoppingPage } from './components/pages/ShoppingPage'; +import { AdminPage } from './components/pages/AdminPage'; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const [isAdmin, setIsAdmin] = useState(false); return (
{notifications.length > 0 && (
- {notifications.map(notif => ( -
( + - {notif.message} - -
+ message={notif.message} + type={notif.type} + onClose={() => + setNotifications((prev) => + prev.filter((n) => n.id !== notif.id) + ) + } + /> ))}
)} @@ -445,42 +44,15 @@ const App = () => {

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )}
@@ -488,637 +60,13 @@ const App = () => {
{isAdmin ? ( -
-
-

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

-
-
- -
- - {activeTab === 'products' ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - - - ))} - -
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} -
+ ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {products.length}개 상품 -
-
- {filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

-
- ) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

- )} -
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
-
- -
-
-
-

- - - - 장바구니 -

- {cart.length === 0 ? ( -
- - - -

장바구니가 비어있습니다

-
- ) : ( -
- {cart.map(item => { - const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; - const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - - return ( -
-
-

{item.product.name}

- -
-
-
- - {item.quantity} - -
-
- {hasDiscount && ( - -{discountRate}% - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
-
-
- ); - })} -
- )} -
- - {cart.length > 0 && ( - <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
- -
-

결제 정보

-
-
- 상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 -
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( -
- 할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 -
- )} -
- 결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 -
-
- - - -
-

* 실제 결제는 이루어지지 않습니다

-
-
- - )} -
-
-
+ )}
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/__tests__/origin.test.tsx b/src/basic/__tests__/origin.test.tsx index 3f5c3d55..38dfbc80 100644 --- a/src/basic/__tests__/origin.test.tsx +++ b/src/basic/__tests__/origin.test.tsx @@ -1,5 +1,11 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from '@testing-library/react'; import { vi } from 'vitest'; import App from '../App'; import '../../setupTests'; @@ -20,25 +26,30 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { render(); - + // 검색창에 "프리미엄" 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); + }, + { timeout: 600 } + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) const addButtons = screen.getAllByText('장바구니 담기'); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -46,64 +57,66 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { render(); - + // 상품1을 장바구니에 추가 const product1 = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) expect(screen.getByText('-15%')).toBeInTheDocument(); }); test('쿠폰을 선택하고 적용할 수 있다', () => { render(); - + // 상품 추가 const addButton = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(addButton); - + // 쿠폰 선택 const couponSelect = screen.getByRole('combobox'); fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + // 결제 정보에서 할인 금액 확인 const paymentSection = screen.getByText('결제 정보').closest('section'); - const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); + const discountRow = within(paymentSection) + .getByText('할인 금액') + .closest('div'); expect(within(discountRow).getByText('-5,000원')).toBeInTheDocument(); }); test('품절 임박 상품에 경고가 표시된다', async () => { render(); - + // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); - + // 상품 수정 const editButton = screen.getAllByText('수정')[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 fireEvent.change(stockInput, { target: { value: '5' } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 const editButtons = screen.getAllByText('수정'); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); @@ -112,39 +125,39 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('주문을 완료할 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); }); test('장바구니에서 상품을 삭제할 수 있다', () => { render(); - + // 상품 2개 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 장바구니 섹션 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole('button') + .filter((button) => button.querySelector('svg')); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); @@ -152,54 +165,56 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('재고를 초과하여 구매할 수 없다', async () => { render(); - + // 상품1 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 재고(20개) 이상으로 증가 시도 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + // 재고 부족 메시지 확인 await waitFor(() => { - expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/) + ).toBeInTheDocument(); }); }); test('장바구니에서 수량을 감소시킬 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + // 수량 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + // 1개로 더 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 @@ -214,31 +229,31 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('20개 이상 구매 시 최대 할인이 적용된다', async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getAllByText('수정')[0]); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '30' } }); - + const editButtons = screen.getAllByText('수정'); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 상품1을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 20개로 증가 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { const discount25 = screen.queryByText('-25%'); @@ -258,27 +273,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('새 상품을 추가할 수 있다', () => { // 새 상품 추가 버튼 클릭 fireEvent.click(screen.getByText('새 상품 추가')); - + // 폼 입력 - 상품명 입력 const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '25000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '50' } }); - + const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); + const descLabel = descLabels.find((el) => el.tagName === 'LABEL'); const descInput = descLabel.closest('div').querySelector('input'); fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + // 저장 fireEvent.click(screen.getByText('추가')); - + // 추가된 상품 확인 expect(screen.getByText('테스트 상품')).toBeInTheDocument(); expect(screen.getByText('25,000원')).toBeInTheDocument(); @@ -287,21 +302,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 버튼 클릭 const addCouponButton = screen.getByText('새 쿠폰 추가'); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - + fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { + target: { value: '테스트 쿠폰' }, + }); + fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { + target: { value: 'TEST2024' }, + }); + const discountInput = screen.getByPlaceholderText('5000'); fireEvent.change(discountInput, { target: { value: '7000' } }); - + // 쿠폰 생성 fireEvent.click(screen.getByText('쿠폰 생성')); - + // 생성된 쿠폰 확인 expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); expect(screen.getByText('TEST2024')).toBeInTheDocument(); @@ -311,25 +330,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('상품의 가격 입력 시 숫자만 허용된다', async () => { // 상품 수정 fireEvent.click(screen.getAllByText('수정')[0]); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 fireEvent.change(priceInput, { target: { value: 'abc123def' } }); expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + // 숫자만 입력 fireEvent.change(priceInput, { target: { value: '123' } }); expect(priceInput.value).toBe('123'); - + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 fireEvent.change(priceInput, { target: { value: '-100' } }); expect(priceInput.value).toBe('123'); // 이전 값 유지 - + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 fireEvent.change(priceInput, { target: { value: ' ' } }); expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 @@ -338,23 +357,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 할인율 검증이 작동한다', async () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 fireEvent.click(screen.getByText('새 쿠폰 추가')); - + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 const couponFormSelects = screen.getAllByRole('combobox'); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + // 100% 초과 할인율 입력 const discountInput = screen.getByPlaceholderText('10'); fireEvent.change(discountInput, { target: { value: '150' } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { - expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); + expect( + screen.getByText('할인율은 100%를 초과할 수 없습니다') + ).toBeInTheDocument(); }); }); @@ -362,15 +383,15 @@ describe('쇼핑몰 앱 통합 테스트', () => { // 초기 상품명들 확인 (테이블에서) const productTable = screen.getByRole('table'); expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole('button') + .filter((button) => button.textContent === '삭제'); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); expect(within(productTable).getByText('상품2')).toBeInTheDocument(); @@ -379,76 +400,79 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰을 삭제할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 초기 쿠폰들 확인 (h3 제목에서) const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const coupon5000 = couponTitles.find( + (el) => el.textContent === '5000원 할인' + ); + const coupon10 = couponTitles.find((el) => el.textContent === '10% 할인'); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole('button').filter((button) => { + return ( + button.querySelector('svg') && + button.querySelector('path[d*="M19 7l"]') + ); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); }); - }); describe('로컬스토리지 동기화', () => { test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { render(); - + // 상품을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // localStorage 확인 expect(localStorage.getItem('cart')).toBeTruthy(); expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + // 관리자 모드로 전환하여 새 상품 추가 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getByText('새 상품 추가')); - + const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '10000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '10' } }); - + fireEvent.click(screen.getByText('추가')); - + // localStorage에 products가 저장되었는지 확인 expect(localStorage.getItem('products')).toBeTruthy(); const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(products.some((p) => p.name === '저장 테스트')).toBe(true); }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { const { unmount } = render(); - + // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -459,13 +483,13 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { render(); - + // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { expect(screen.getByText('-15%')).toBeInTheDocument(); @@ -474,12 +498,12 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니 아이템 개수가 헤더에 표시된다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 헤더의 장바구니 아이콘 옆 숫자 확인 const cartCount = screen.getByText('3'); expect(cartCount).toBeInTheDocument(); @@ -487,42 +511,57 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('검색을 초기화할 수 있다', async () => { render(); - + // 검색어 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 검색 결과 확인 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); // 다른 상품들은 보이지 않음 - expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); + expect( + screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.') + ).not.toBeInTheDocument(); }); - + // 검색어 초기화 fireEvent.change(searchInput, { target: { value: '' } }); - + // 모든 상품이 다시 표시됨 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('대용량과 고성능을 자랑하는 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); + expect( + screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.') + ).toBeInTheDocument(); + expect( + screen.getByText('대용량과 고성능을 자랑하는 상품입니다.') + ).toBeInTheDocument(); }); }); test('알림 메시지가 자동으로 사라진다', async () => { render(); - + // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 알림 메시지 확인 expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect( + screen.queryByText('장바구니에 담았습니다') + ).not.toBeInTheDocument(); + }, + { timeout: 4000 } + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/basic/components/CartItem.tsx b/src/basic/components/CartItem.tsx new file mode 100644 index 00000000..bb11ee6c --- /dev/null +++ b/src/basic/components/CartItem.tsx @@ -0,0 +1,86 @@ +import { CartItem } from '../../types'; +import { Button } from './ui/Button'; + +// components/CartItem.tsx +interface CartItemProps { + item: CartItem; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; + calculateItemTotal: (item: CartItem) => number; +} + +export const CartItemComponent = ({ + item, + onUpdateQuantity, + onRemove, + calculateItemTotal, +}: CartItemProps) => { + const itemTotal = calculateItemTotal(item); + + return ( +
+
+

+ {item.product.name} +

+
+
+
+ + + {item.quantity} + + +
+

+ {itemTotal.toLocaleString()}원 +

+ {(() => { + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + return ( + hasDiscount && ( + + -{discountRate}% + + ) + ); + })()} +
+
+ ); +}; diff --git a/src/basic/components/ProductCard.tsx b/src/basic/components/ProductCard.tsx new file mode 100644 index 00000000..98c3eb17 --- /dev/null +++ b/src/basic/components/ProductCard.tsx @@ -0,0 +1,92 @@ +import { Product, ProductWithUI } from '../../types'; +import { Button } from './ui/Button'; + +interface ProductCardProps { + product: ProductWithUI; + onAddToCart: (product: Product) => void; + getRemainingStock: (product: Product) => number; + formatPrice: (price: number, productId?: string) => string; +} + +export const ProductCard = ({ + product, + onAddToCart, + getRemainingStock, + formatPrice, +}: ProductCardProps) => { + const remainingStock = getRemainingStock(product); + + return ( +
+
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

+ )} + + {/* 가격 정보 */} +
+

+ {formatPrice(product.price, product.id)} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인{' '} + {product.discounts[0].rate * 100}% +

+ )} +
+ + {/* 재고 상태 */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

+ 품절임박! {remainingStock}개 남음 +

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + +
+
+ ); +}; diff --git a/src/basic/components/pages/AdminPage.tsx b/src/basic/components/pages/AdminPage.tsx new file mode 100644 index 00000000..d25e7ea0 --- /dev/null +++ b/src/basic/components/pages/AdminPage.tsx @@ -0,0 +1,660 @@ +// components/pages/AdminPage.tsx +import { useState } from 'react'; +import { useProducts } from '../../hooks/useProducts'; +import { useCoupons } from '../../hooks/useCoupons'; +import { formatPrice } from '../../utils/formatters'; +import { Button } from '../ui/Button'; +import { ProductWithUI } from '../../../types'; + +interface AdminPageProps { + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export const AdminPage = ({ addNotification }: AdminPageProps) => { + const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + const { + coupons, + addCoupon, + deleteCoupon, + } = useCoupons([], addNotification); + + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( + 'products' + ); + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, + }); + + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts, + }); + } + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + setShowCouponForm(false); + }; + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + + return ( +
+
+

관리자 대시보드

+

상품과 쿠폰을 관리할 수 있습니다

+
+
+ +
+ + {activeTab === 'products' ? ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {(activeTab === 'products' ? products : products).map( + (product) => ( + + + + + + + + ) + )} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPrice(product.price, undefined, true)} + + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + + {product.description || '-'} + + + +
+
+ {showProductForm && ( +
+
+

+ {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} +

+
+
+ + + setProductForm({ + ...productForm, + name: e.target.value, + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + required + /> +
+
+ + + setProductForm({ + ...productForm, + description: e.target.value, + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification('가격은 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification('재고는 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification( + '재고는 9999개를 초과할 수 없습니다', + 'error' + ); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = + parseInt(e.target.value) || 0; + setProductForm({ + ...productForm, + discounts: newDiscounts, + }); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = + (parseInt(e.target.value) || 0) / 100; + setProductForm({ + ...productForm, + discounts: newDiscounts, + }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 +
+ ))} + +
+
+ +
+ + +
+
+
+ )} +
+ ) : ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( +
+
+
+

+ {coupon.name} +

+

+ {coupon.code} +

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( +
+
+

+ 새 쿠폰 생성 +

+
+
+ + + setCouponForm({ + ...couponForm, + name: e.target.value, + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ + ...couponForm, + code: e.target.value.toUpperCase(), + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === 'percentage') { + if (value > 100) { + addNotification( + '할인율은 100%를 초과할 수 없습니다', + 'error' + ); + setCouponForm({ + ...couponForm, + discountValue: 100, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } else { + if (value > 100000) { + addNotification( + '할인 금액은 100,000원을 초과할 수 없습니다', + 'error' + ); + setCouponForm({ + ...couponForm, + discountValue: 100000, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder={ + couponForm.discountType === 'amount' ? '5000' : '10' + } + required + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +}; diff --git a/src/basic/components/pages/ShoppingPage.tsx b/src/basic/components/pages/ShoppingPage.tsx new file mode 100644 index 00000000..43e0d7b1 --- /dev/null +++ b/src/basic/components/pages/ShoppingPage.tsx @@ -0,0 +1,305 @@ +// components/pages/ShoppingPage.tsx +import { useState, useCallback, useEffect } from 'react'; +import { calculateCartTotal, calculateItemTotal } from '../../utils/calculators'; +import { formatPrice } from '../../utils/formatters'; +import { useProducts } from '../../hooks/useProducts'; +import { useCart } from '../../hooks/useCart'; +import { useCoupons } from '../../hooks/useCoupons'; +import { ProductCard } from '../ProductCard'; +import { CartItemComponent } from '../CartItem'; +import { Button } from '../ui/Button'; + +export const ShoppingPage = ({ + addNotification, +}: { + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const [totalItemCount, setTotalItemCount] = useState(0); + + const { products } = useProducts(); + const { + cart, + setCart, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + } = useCart(addNotification); + const { selectedCoupon, setSelectedCoupon, coupons, applyCoupon } = + useCoupons(cart, addNotification); + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + 'success' + ); + setCart([]); + setSelectedCoupon(null); + }, [addNotification, setCart, setSelectedCoupon]); + + const filteredProducts = debouncedSearchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + + return ( +
+
+
+
+
+

SHOP

+
+ setSearchTerm(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+
+ +
+
+
+
+
+
+ {/* 상품 목록 */} +
+
+

+ 전체 상품 +

+
+ 총 {products.length}개 상품 +
+
+ {filteredProducts.length === 0 ? ( +
+

+ "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다. +

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+
+ +
+
+
+

+ + + + 장바구니 +

+ {cart.length === 0 ? ( +
+ + + +

+ 장바구니가 비어있습니다 +

+
+ ) : ( +
+ {cart.map((item) => ( + calculateItemTotal(item, cart)} + /> + ))} +
+ )} +
+ + {cart.length > 0 && ( + <> +
+
+

+ 쿠폰 할인 +

+ +
+ {coupons.length > 0 && ( + + )} +
+ +
+

결제 정보

+
+
+ 상품 금액 + + {calculateCartTotal( + cart, + selectedCoupon + ).totalBeforeDiscount.toLocaleString()} + 원 + +
+ {calculateCartTotal(cart, selectedCoupon) + .totalBeforeDiscount - + calculateCartTotal(cart, selectedCoupon) + .totalAfterDiscount > + 0 && ( +
+ 할인 금액 + + - + {( + calculateCartTotal(cart, selectedCoupon) + .totalBeforeDiscount - + calculateCartTotal(cart, selectedCoupon) + .totalAfterDiscount + ).toLocaleString()} + 원 + +
+ )} +
+ 결제 예정 금액 + + {calculateCartTotal( + cart, + selectedCoupon + ).totalAfterDiscount.toLocaleString()} + 원 + +
+
+ + + +
+

* 실제 결제는 이루어지지 않습니다

+
+
+ + )} +
+
+
+
+
+ ); +}; diff --git a/src/basic/components/ui/Button.tsx b/src/basic/components/ui/Button.tsx new file mode 100644 index 00000000..fc052047 --- /dev/null +++ b/src/basic/components/ui/Button.tsx @@ -0,0 +1,37 @@ +interface ButtonProps { + children?: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: 'primary' | 'secondary' | 'danger'; + className?: string; + icon?: React.ReactNode; + type?: 'button' | 'submit' | 'reset'; +} + +export const Button = ({ + children, + onClick, + disabled, + variant = 'primary', + className = '', + icon, +}: ButtonProps) => { + const baseClasses = + 'px-4 py-2 rounded-md font-medium transition-colors flex items-center justify-center'; + const variantClasses = { + primary: 'bg-gray-900 text-white hover:bg-gray-800', + secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200', + danger: 'bg-red-600 text-white hover:bg-red-700', + }; + + return ( + + ); +}; diff --git a/src/basic/components/ui/Notification.tsx b/src/basic/components/ui/Notification.tsx new file mode 100644 index 00000000..0d4dd96b --- /dev/null +++ b/src/basic/components/ui/Notification.tsx @@ -0,0 +1,36 @@ +interface NotificationProps { + message: string; + type: 'error' | 'success' | 'warning'; + onClose: () => void; +} + +export const Notification = ({ message, type, onClose }: NotificationProps) => { + const bgColor = { + error: 'bg-red-600', + success: 'bg-green-600', + warning: 'bg-yellow-600', + }[type]; + + return ( +
+ {message} + +
+ ); +}; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 00000000..b5d009a3 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,106 @@ +import { useCallback } from 'react'; +import { CartItem, Product, ProductWithUI } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; +import { useProducts } from './useProducts'; + +export const useCart = ( + addNotification: ( + message: string, + type: 'error' | 'success' | 'warning' + ) => void +) => { + const [cart, setCart] = useLocalStorage('cart', []); + const { products } = useProducts(); + + const getRemainingStock = useCallback( + (product: Product): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); + }, + [cart] + ); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + return; + } + + setCart((prevCart) => { + const existingItem = prevCart.find( + (item) => item.product.id === product.id + ); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + addNotification( + `재고는 ${product.stock}개까지만 있습니다.`, + 'error' + ); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification('장바구니에 담았습니다', 'success'); + }, + [cart, addNotification, getRemainingStock] + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); + }, + [setCart] + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + const product = products.find((p) => p.id === productId); + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); + return; + } + + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, + [products, removeFromCart, addNotification, setCart] + ); + + return { + cart, + setCart, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + }; +}; diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 00000000..4c473b3e --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,72 @@ +import { useState, useCallback } from 'react'; +import { Coupon, initialCoupons, CartItem } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; +import { calculateCartTotal } from '../utils/calculators'; + +export const useCoupons = ( + cart: CartItem[], + addNotification: ( + message: string, + type: 'error' | 'success' | 'warning' + ) => void +) => { + const [coupons, setCoupons] = useLocalStorage( + 'coupons', + initialCoupons + ); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [addNotification, calculateCartTotal, cart, selectedCoupon] + ); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }, + [coupons, addNotification] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [selectedCoupon, addNotification] + ); + + return { + coupons, + selectedCoupon, + setSelectedCoupon, + addCoupon, + deleteCoupon, + applyCoupon, + }; +}; diff --git a/src/basic/hooks/useLocalStorage.ts b/src/basic/hooks/useLocalStorage.ts new file mode 100644 index 00000000..aeed0b5c --- /dev/null +++ b/src/basic/hooks/useLocalStorage.ts @@ -0,0 +1,25 @@ +import { useState, useEffect } from 'react'; + +export const useLocalStorage = (key: string, initialValue: T) => { + const [storedValue, setStoredValue] = useState(() => { + const saved = localStorage.getItem(key); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialValue; + } + } + return initialValue; + }); + + useEffect(() => { + if (storedValue !== undefined) { + localStorage.setItem(key, JSON.stringify(storedValue)); + } else { + localStorage.removeItem(key); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue] as const; +}; diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 00000000..71ba5eab --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,69 @@ +import { useCallback } from 'react'; +import { Product, ProductWithUI } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; + +const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: '최고급 품질의 프리미엄 상품입니다.', + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true, + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; + +export const useProducts = () => { + const [products, setProducts] = useLocalStorage( + 'products', + initialProducts + ); + + const addProduct = useCallback((newProduct: Omit) => { + const product: Product = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + }, []); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + }, + [] + ); + + const deleteProduct = useCallback((productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + }, []); + + return { products, addProduct, updateProduct, deleteProduct }; +}; diff --git a/src/basic/main.tsx b/src/basic/main.tsx index e63eef4a..5a0654ac 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,9 +1,9 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( - , -) + +); diff --git a/src/basic/utils/calculators.ts b/src/basic/utils/calculators.ts new file mode 100644 index 00000000..9171fbf1 --- /dev/null +++ b/src/basic/utils/calculators.ts @@ -0,0 +1,64 @@ +import { CartItem, Coupon } from '../../types'; + +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] +): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; + +export const calculateItemTotal = ( + item: CartItem, + cart: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + return Math.round(price * quantity * (1 - discount)); +}; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +) => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 00000000..419f0b09 --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,15 @@ +export const formatPrice = ( + price: number, + productId?: string, + isAdmin: boolean = false, + remainingStock?: number +): string => { + if (productId && remainingStock !== undefined && remainingStock <= 0) { + return 'SOLD OUT'; + } + + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + return `₩${price.toLocaleString()}`; +}; diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 00000000..71347eef --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,9 @@ +import { Product } from '../../types'; + +export const validateProductForm = (form: Omit): string[] => { + const errors: string[] = []; + if (!form.name.trim()) errors.push('상품명은 필수입니다'); + if (form.price <= 0) errors.push('가격은 0보다 커야 합니다'); + if (form.stock < 0) errors.push('재고는 0 이상이어야 합니다'); + return errors; +}; diff --git a/src/origin/App.tsx b/src/origin/App.tsx index a4369fe1..576f4480 100644 --- a/src/origin/App.tsx +++ b/src/origin/App.tsx @@ -21,20 +21,18 @@ const initialProducts: ProductWithUI[] = [ stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } + { quantity: 20, rate: 0.2 }, ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: '최고급 품질의 프리미엄 상품입니다.', }, { id: 'p2', name: '상품2', price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], + discounts: [{ quantity: 10, rate: 0.15 }], description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true + isRecommended: true, }, { id: 'p3', @@ -43,10 +41,10 @@ const initialProducts: ProductWithUI[] = [ stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } + { quantity: 30, rate: 0.25 }, ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, ]; const initialCoupons: Coupon[] = [ @@ -54,18 +52,17 @@ const initialCoupons: Coupon[] = [ name: '5000원 할인', code: 'AMOUNT5000', discountType: 'amount', - discountValue: 5000 + discountValue: 5000, }, { name: '10% 할인', code: 'PERCENT10', discountType: 'percentage', - discountValue: 10 - } + discountValue: 10, + }, ]; const App = () => { - const [products, setProducts] = useState(() => { const saved = localStorage.getItem('products'); if (saved) { @@ -106,7 +103,9 @@ const App = () => { const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( + 'products' + ); const [showProductForm, setShowProductForm] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); @@ -118,20 +117,19 @@ const App = () => { price: 0, stock: 0, description: '', - discounts: [] as Array<{ quantity: number; rate: number }> + discounts: [] as Array<{ quantity: number; rate: number }>, }); const [couponForm, setCouponForm] = useState({ name: '', code: '', discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 + discountValue: 0, }); - const formatPrice = (price: number, productId?: string): string => { if (productId) { - const product = products.find(p => p.id === productId); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { return 'SOLD OUT'; } @@ -140,25 +138,25 @@ const App = () => { if (isAdmin) { return `${price.toLocaleString()}원`; } - + return `₩${price.toLocaleString()}`; }; const getMaxApplicableDiscount = (item: CartItem): number => { const { discounts } = item.product; const { quantity } = item; - + const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 } - + return baseDiscount; }; @@ -166,7 +164,7 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; @@ -177,7 +175,7 @@ const App = () => { let totalBeforeDiscount = 0; let totalAfterDiscount = 0; - cart.forEach(item => { + cart.forEach((item) => { const itemPrice = item.product.price * item.quantity; totalBeforeDiscount += itemPrice; totalAfterDiscount += calculateItemTotal(item); @@ -185,36 +183,43 @@ const App = () => { if (selectedCoupon) { if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); } } return { totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) + totalAfterDiscount: Math.round(totalAfterDiscount), }; }; const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); + const cartItem = cart.find((item) => item.product.id === product.id); const remaining = product.stock - (cartItem?.quantity || 0); - + return remaining; }; - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); const [totalItemCount, setTotalItemCount] = useState(0); - useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); @@ -244,126 +249,161 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + return; + } + + setCart((prevCart) => { + const existingItem = prevCart.find( + (item) => item.product.id === product.id + ); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; + if (newQuantity > product.stock) { + addNotification( + `재고는 ${product.stock}개까지만 있습니다.`, + 'error' + ); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); } - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification('장바구니에 담았습니다', 'success'); + }, + [cart, addNotification, getRemainingStock] + ); const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } - const product = products.find(p => p.id === productId); - if (!product) return; + const product = products.find((p) => p.id === productId); + if (!product) return; - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); + return; + } - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, + [products, removeFromCart, addNotification, getRemainingStock] + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal().totalAfterDiscount; - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [addNotification, calculateCartTotal] + ); const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + 'success' + ); setCart([]); setSelectedCoupon(null); }, [addNotification]); - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [addNotification] + ); - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [addNotification] + ); - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }, + [coupons, addNotification] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [selectedCoupon, addNotification] + ); const handleProductSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -373,10 +413,16 @@ const App = () => { } else { addProduct({ ...productForm, - discounts: productForm.discounts + discounts: productForm.discounts, }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); setEditingProduct(null); setShowProductForm(false); }; @@ -388,7 +434,7 @@ const App = () => { name: '', code: '', discountType: 'amount', - discountValue: 0 + discountValue: 0, }); setShowCouponForm(false); }; @@ -400,7 +446,7 @@ const App = () => { price: product.price, stock: product.stock, description: product.description || '', - discounts: product.discounts || [] + discounts: product.discounts || [], }); setShowProductForm(true); }; @@ -408,9 +454,15 @@ const App = () => { const totals = calculateCartTotal(); const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) ) : products; @@ -418,22 +470,38 @@ const App = () => {
{notifications.length > 0 && (
- {notifications.map(notif => ( + {notifications.map((notif) => (
{notif.message} -
@@ -462,8 +530,8 @@ const App = () => { {!isAdmin && (
- - + + {cart.length > 0 && ( @@ -490,26 +568,30 @@ const App = () => { {isAdmin ? (
-

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

+

+ 관리자 대시보드 +

+

+ 상품과 쿠폰을 관리할 수 있습니다 +

-
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - +
+
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
+ + + + + + + - ))} - -
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); + + + {(activeTab === 'products' ? products : products).map( + (product) => ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + + + {product.description || '-'} + + + + + + + ) + )} + + +
+ {showProductForm && ( +
+ +

+ {editingProduct === 'new' + ? '새 상품 추가' + : '상품 수정'} +

+
+
+ + + setProductForm({ + ...productForm, + name: e.target.value, + }) } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + required + /> +
+
+ + + setProductForm({ + ...productForm, + description: e.target.value, + }) } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + /> +
+
+ + { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); + onChange={(e) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification( + '가격은 0보다 커야 합니다', + 'error' + ); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+ + + onChange={(e) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification( + '재고는 0보다 커야 합니다', + 'error' + ); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification( + '재고는 9999개를 초과할 수 없습니다', + 'error' + ); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} + { + const newDiscounts = [ + ...productForm.discounts, + ]; + newDiscounts[index].quantity = + parseInt(e.target.value) || 0; + setProductForm({ + ...productForm, + discounts: newDiscounts, + }); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [ + ...productForm.discounts, + ]; + newDiscounts[index].rate = + (parseInt(e.target.value) || 0) / 100; + setProductForm({ + ...productForm, + discounts: newDiscounts, + }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+
-
- -
- - -
- -
- )} + +
+ )} ) : (
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - +
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( +
+
+
+

+ {coupon.name} +

+

+ {coupon.code} +

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
-
-
- ))} - -
- -
-
+ ))} - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
+
-
-
- )} -
+ + {showCouponForm && ( +
+
+

+ 새 쿠폰 생성 +

+
+
+ + + setCouponForm({ + ...couponForm, + name: e.target.value, + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ + ...couponForm, + code: e.target.value.toUpperCase(), + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: + value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === 'percentage') { + if (value > 100) { + addNotification( + '할인율은 100%를 초과할 수 없습니다', + 'error' + ); + setCouponForm({ + ...couponForm, + discountValue: 100, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } else { + if (value > 100000) { + addNotification( + '할인 금액은 100,000원을 초과할 수 없습니다', + 'error' + ); + setCouponForm({ + ...couponForm, + discountValue: 100000, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder={ + couponForm.discountType === 'amount' + ? '5000' + : '10' + } + required + /> +
+
+
+ + +
+
+
+ )} +
)}
@@ -897,137 +1169,221 @@ const App = () => { {/* 상품 목록 */}
-

전체 상품

+

+ 전체 상품 +

총 {products.length}개 상품
{filteredProducts.length === 0 ? (
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

+

+ "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다. +

) : (
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

+ + ~ + {Math.max( + ...product.discounts.map((d) => d.rate) + ) * 100} + % + )}
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

+ + {/* 상품 정보 */} +
+

+ {product.name} +

+ {product.description && ( +

+ {product.description} +

)} + + {/* 가격 정보 */} +
+

+ {formatPrice(product.price, product.id)} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 + 할인 {product.discounts[0].rate * 100}% +

+ )} +
+ + {/* 재고 상태 */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

+ 품절임박! {remainingStock}개 남음 +

+ )} + {remainingStock > 5 && ( +

+ 재고 {remainingStock}개 +

+ )} +
+ + {/* 장바구니 버튼 */} +
- - {/* 장바구니 버튼 */} -
-
- ); + ); })}
)}
- +

- - + + 장바구니

{cart.length === 0 ? (
- - + + -

장바구니가 비어있습니다

+

+ 장바구니가 비어있습니다 +

) : (
- {cart.map(item => { + {cart.map((item) => { const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; + const originalPrice = + item.product.price * item.quantity; const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + return ( -
+
-

{item.product.name}

-
- - {item.quantity} -
{hasDiscount && ( - -{discountRate}% + + -{discountRate}% + )}

{Math.round(itemTotal).toLocaleString()}원 @@ -1053,27 +1411,33 @@ const App = () => { <>

-

쿠폰 할인

+

+ 쿠폰 할인 +

{coupons.length > 0 && ( - @@ -1085,27 +1449,40 @@ const App = () => {
상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 + + {totals.totalBeforeDiscount.toLocaleString()}원 +
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( + {totals.totalBeforeDiscount - + totals.totalAfterDiscount > + 0 && (
할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 + + - + {( + totals.totalBeforeDiscount - + totals.totalAfterDiscount + ).toLocaleString()} + 원 +
)}
결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 + + {totals.totalAfterDiscount.toLocaleString()}원 +
- + - +

* 실제 결제는 이루어지지 않습니다

@@ -1121,4 +1498,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/origin/__tests__/origin.test.tsx b/src/origin/__tests__/origin.test.tsx index 3f5c3d55..38dfbc80 100644 --- a/src/origin/__tests__/origin.test.tsx +++ b/src/origin/__tests__/origin.test.tsx @@ -1,5 +1,11 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from '@testing-library/react'; import { vi } from 'vitest'; import App from '../App'; import '../../setupTests'; @@ -20,25 +26,30 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { render(); - + // 검색창에 "프리미엄" 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); + }, + { timeout: 600 } + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) const addButtons = screen.getAllByText('장바구니 담기'); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -46,64 +57,66 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { render(); - + // 상품1을 장바구니에 추가 const product1 = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) expect(screen.getByText('-15%')).toBeInTheDocument(); }); test('쿠폰을 선택하고 적용할 수 있다', () => { render(); - + // 상품 추가 const addButton = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(addButton); - + // 쿠폰 선택 const couponSelect = screen.getByRole('combobox'); fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + // 결제 정보에서 할인 금액 확인 const paymentSection = screen.getByText('결제 정보').closest('section'); - const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); + const discountRow = within(paymentSection) + .getByText('할인 금액') + .closest('div'); expect(within(discountRow).getByText('-5,000원')).toBeInTheDocument(); }); test('품절 임박 상품에 경고가 표시된다', async () => { render(); - + // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); - + // 상품 수정 const editButton = screen.getAllByText('수정')[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 fireEvent.change(stockInput, { target: { value: '5' } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 const editButtons = screen.getAllByText('수정'); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); @@ -112,39 +125,39 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('주문을 완료할 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); }); test('장바구니에서 상품을 삭제할 수 있다', () => { render(); - + // 상품 2개 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 장바구니 섹션 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole('button') + .filter((button) => button.querySelector('svg')); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); @@ -152,54 +165,56 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('재고를 초과하여 구매할 수 없다', async () => { render(); - + // 상품1 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 재고(20개) 이상으로 증가 시도 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + // 재고 부족 메시지 확인 await waitFor(() => { - expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/) + ).toBeInTheDocument(); }); }); test('장바구니에서 수량을 감소시킬 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + // 수량 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + // 1개로 더 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 @@ -214,31 +229,31 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('20개 이상 구매 시 최대 할인이 적용된다', async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getAllByText('수정')[0]); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '30' } }); - + const editButtons = screen.getAllByText('수정'); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 상품1을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 20개로 증가 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { const discount25 = screen.queryByText('-25%'); @@ -258,27 +273,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('새 상품을 추가할 수 있다', () => { // 새 상품 추가 버튼 클릭 fireEvent.click(screen.getByText('새 상품 추가')); - + // 폼 입력 - 상품명 입력 const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '25000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '50' } }); - + const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); + const descLabel = descLabels.find((el) => el.tagName === 'LABEL'); const descInput = descLabel.closest('div').querySelector('input'); fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + // 저장 fireEvent.click(screen.getByText('추가')); - + // 추가된 상품 확인 expect(screen.getByText('테스트 상품')).toBeInTheDocument(); expect(screen.getByText('25,000원')).toBeInTheDocument(); @@ -287,21 +302,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 버튼 클릭 const addCouponButton = screen.getByText('새 쿠폰 추가'); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - + fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { + target: { value: '테스트 쿠폰' }, + }); + fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { + target: { value: 'TEST2024' }, + }); + const discountInput = screen.getByPlaceholderText('5000'); fireEvent.change(discountInput, { target: { value: '7000' } }); - + // 쿠폰 생성 fireEvent.click(screen.getByText('쿠폰 생성')); - + // 생성된 쿠폰 확인 expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); expect(screen.getByText('TEST2024')).toBeInTheDocument(); @@ -311,25 +330,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('상품의 가격 입력 시 숫자만 허용된다', async () => { // 상품 수정 fireEvent.click(screen.getAllByText('수정')[0]); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 fireEvent.change(priceInput, { target: { value: 'abc123def' } }); expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + // 숫자만 입력 fireEvent.change(priceInput, { target: { value: '123' } }); expect(priceInput.value).toBe('123'); - + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 fireEvent.change(priceInput, { target: { value: '-100' } }); expect(priceInput.value).toBe('123'); // 이전 값 유지 - + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 fireEvent.change(priceInput, { target: { value: ' ' } }); expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 @@ -338,23 +357,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 할인율 검증이 작동한다', async () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 fireEvent.click(screen.getByText('새 쿠폰 추가')); - + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 const couponFormSelects = screen.getAllByRole('combobox'); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + // 100% 초과 할인율 입력 const discountInput = screen.getByPlaceholderText('10'); fireEvent.change(discountInput, { target: { value: '150' } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { - expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); + expect( + screen.getByText('할인율은 100%를 초과할 수 없습니다') + ).toBeInTheDocument(); }); }); @@ -362,15 +383,15 @@ describe('쇼핑몰 앱 통합 테스트', () => { // 초기 상품명들 확인 (테이블에서) const productTable = screen.getByRole('table'); expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole('button') + .filter((button) => button.textContent === '삭제'); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); expect(within(productTable).getByText('상품2')).toBeInTheDocument(); @@ -379,76 +400,79 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰을 삭제할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 초기 쿠폰들 확인 (h3 제목에서) const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const coupon5000 = couponTitles.find( + (el) => el.textContent === '5000원 할인' + ); + const coupon10 = couponTitles.find((el) => el.textContent === '10% 할인'); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole('button').filter((button) => { + return ( + button.querySelector('svg') && + button.querySelector('path[d*="M19 7l"]') + ); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); }); - }); describe('로컬스토리지 동기화', () => { test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { render(); - + // 상품을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // localStorage 확인 expect(localStorage.getItem('cart')).toBeTruthy(); expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + // 관리자 모드로 전환하여 새 상품 추가 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getByText('새 상품 추가')); - + const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '10000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '10' } }); - + fireEvent.click(screen.getByText('추가')); - + // localStorage에 products가 저장되었는지 확인 expect(localStorage.getItem('products')).toBeTruthy(); const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(products.some((p) => p.name === '저장 테스트')).toBe(true); }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { const { unmount } = render(); - + // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -459,13 +483,13 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { render(); - + // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { expect(screen.getByText('-15%')).toBeInTheDocument(); @@ -474,12 +498,12 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니 아이템 개수가 헤더에 표시된다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 헤더의 장바구니 아이콘 옆 숫자 확인 const cartCount = screen.getByText('3'); expect(cartCount).toBeInTheDocument(); @@ -487,42 +511,57 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('검색을 초기화할 수 있다', async () => { render(); - + // 검색어 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 검색 결과 확인 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); // 다른 상품들은 보이지 않음 - expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); + expect( + screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.') + ).not.toBeInTheDocument(); }); - + // 검색어 초기화 fireEvent.change(searchInput, { target: { value: '' } }); - + // 모든 상품이 다시 표시됨 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('대용량과 고성능을 자랑하는 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); + expect( + screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.') + ).toBeInTheDocument(); + expect( + screen.getByText('대용량과 고성능을 자랑하는 상품입니다.') + ).toBeInTheDocument(); }); }); test('알림 메시지가 자동으로 사라진다', async () => { render(); - + // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 알림 메시지 확인 expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect( + screen.queryByText('장바구니에 담았습니다') + ).not.toBeInTheDocument(); + }, + { timeout: 4000 } + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/origin/main.tsx b/src/origin/main.tsx index e63eef4a..5a0654ac 100644 --- a/src/origin/main.tsx +++ b/src/origin/main.tsx @@ -1,9 +1,9 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( - , -) + +); diff --git a/src/refactoring(hint)/App.tsx b/src/refactoring(hint)/App.tsx index d8cc004c..1eec17d9 100644 --- a/src/refactoring(hint)/App.tsx +++ b/src/refactoring(hint)/App.tsx @@ -9,4 +9,4 @@ export function App() { // TODO: 구현 } -export default App; \ No newline at end of file +export default App; diff --git a/src/refactoring(hint)/components/AdminPage.tsx b/src/refactoring(hint)/components/AdminPage.tsx index afb5b1ae..6ec55e88 100644 --- a/src/refactoring(hint)/components/AdminPage.tsx +++ b/src/refactoring(hint)/components/AdminPage.tsx @@ -17,4 +17,4 @@ export function AdminPage() { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/components/CartPage.tsx b/src/refactoring(hint)/components/CartPage.tsx index 069edafc..e6ff46a3 100644 --- a/src/refactoring(hint)/components/CartPage.tsx +++ b/src/refactoring(hint)/components/CartPage.tsx @@ -4,7 +4,7 @@ // 2. 장바구니 관리 // 3. 쿠폰 적용 // 4. 주문 처리 -// +// // 필요한 hooks: // - useProducts: 상품 목록 관리 // - useCart: 장바구니 상태 관리 @@ -18,4 +18,4 @@ export function CartPage() { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/components/icons/index.tsx b/src/refactoring(hint)/components/icons/index.tsx index 1609d774..aaec6395 100644 --- a/src/refactoring(hint)/components/icons/index.tsx +++ b/src/refactoring(hint)/components/icons/index.tsx @@ -9,4 +9,4 @@ // - ChevronUpIcon: 위 화살표 // - CheckIcon: 체크 아이콘 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/constants/index.ts b/src/refactoring(hint)/constants/index.ts index bef3834f..9f891b90 100644 --- a/src/refactoring(hint)/constants/index.ts +++ b/src/refactoring(hint)/constants/index.ts @@ -5,4 +5,4 @@ // // 참고: origin/App.tsx의 초기 데이터 구조를 참조 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/hooks/useCart.ts b/src/refactoring(hint)/hooks/useCart.ts index 6db309aa..a9c7290d 100644 --- a/src/refactoring(hint)/hooks/useCart.ts +++ b/src/refactoring(hint)/hooks/useCart.ts @@ -26,4 +26,4 @@ export function useCart() { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/hooks/useCoupons.ts b/src/refactoring(hint)/hooks/useCoupons.ts index d2ad82ab..779ecf6d 100644 --- a/src/refactoring(hint)/hooks/useCoupons.ts +++ b/src/refactoring(hint)/hooks/useCoupons.ts @@ -10,4 +10,4 @@ export function useCoupons() { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/hooks/useProducts.ts b/src/refactoring(hint)/hooks/useProducts.ts index f4bef103..5c73269e 100644 --- a/src/refactoring(hint)/hooks/useProducts.ts +++ b/src/refactoring(hint)/hooks/useProducts.ts @@ -15,4 +15,4 @@ export function useProducts() { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/main.tsx b/src/refactoring(hint)/main.tsx index 589b1645..0a6203a0 100644 --- a/src/refactoring(hint)/main.tsx +++ b/src/refactoring(hint)/main.tsx @@ -1,4 +1,4 @@ // TODO: React 앱 엔트리 포인트 // App 컴포넌트를 root DOM 요소에 렌더링 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/models/cart.ts b/src/refactoring(hint)/models/cart.ts index 5c681048..590feb7a 100644 --- a/src/refactoring(hint)/models/cart.ts +++ b/src/refactoring(hint)/models/cart.ts @@ -15,4 +15,4 @@ // - 외부 상태에 의존하지 않음 // - 모든 필요한 데이터는 파라미터로 전달받음 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/utils/formatters.ts b/src/refactoring(hint)/utils/formatters.ts index ff157f5c..6fb3aae7 100644 --- a/src/refactoring(hint)/utils/formatters.ts +++ b/src/refactoring(hint)/utils/formatters.ts @@ -4,4 +4,4 @@ // - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 // - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/utils/hooks/useDebounce.ts b/src/refactoring(hint)/utils/hooks/useDebounce.ts index 53c8a374..6e2b08e8 100644 --- a/src/refactoring(hint)/utils/hooks/useDebounce.ts +++ b/src/refactoring(hint)/utils/hooks/useDebounce.ts @@ -8,4 +8,4 @@ export function useDebounce(value: T, delay: number): T { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts index 5dc72c50..53205ca5 100644 --- a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts +++ b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts @@ -12,4 +12,4 @@ export function useLocalStorage( initialValue: T ): [T, (value: T | ((val: T) => T)) => void] { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/utils/validators.ts b/src/refactoring(hint)/utils/validators.ts index 7d2dda44..32d99d9d 100644 --- a/src/refactoring(hint)/utils/validators.ts +++ b/src/refactoring(hint)/utils/validators.ts @@ -5,4 +5,4 @@ // - isValidPrice(price: number): boolean - 가격 검증 (양수) // - extractNumbers(value: string): string - 문자열에서 숫자만 추출 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/types.ts b/src/types.ts index 5489e296..dc7cf77a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,17 @@ export interface Product { discounts: Discount[]; } +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + export interface Discount { quantity: number; rate: number; @@ -22,3 +33,18 @@ export interface Coupon { discountType: 'amount' | 'percentage'; discountValue: number; } + +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292a..acc14de4 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src/advanced"] } diff --git a/vite.config.ts b/vite.config.ts index e6c4016b..b7383c04 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,12 +5,19 @@ import react from '@vitejs/plugin-react-swc'; export default mergeConfig( defineConfig({ plugins: [react()], + base: '/front_6th_chapter2-2/', // GitHub Pages 레포지토리 이름으로 변경 + build: { + outDir: 'dist/advanced', // src/advanced 빌드 결과물이 저장될 디렉토리 + rollupOptions: { + input: './index.html', // src/advanced의 진입점 HTML 파일 + }, + }, }), defineTestConfig({ test: { globals: true, environment: 'jsdom', - setupFiles: './src/setupTests.ts' + setupFiles: './src/setupTests.ts', }, }) -) +);