commit 06c5887fd8719b5ad8c59604ccdfd0f5cc0ac4cc Author: Заид Омар Медхат Date: Fri Dec 26 12:13:34 2025 +0500 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..5f710b7 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +VITE_API_HOST=http://localhost:3000 +NODE_ENV=development diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6b8060a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# API Host URL (used during build) +VITE_API_HOST=https://api-finance.ai-assistant-bot.xyz diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..a905105 --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +VITE_API_HOST=https://api-finance.ai-assistant-bot.xyz \ No newline at end of file diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..75e271f --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,24 @@ +name: Deploy Production + +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + deploy: + runs-on: docker + container: + image: docker:latest + steps: + - name: Install git + run: apk add --no-cache git + + - name: Clone repository + run: git clone --depth 1 https://git.ai-assistant-bot.xyz/root/finance-front.git . + + - name: Build & Deploy + run: | + docker compose -f docker-compose.server.yml build + docker compose -f docker-compose.server.yml up -d diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..412c1f5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build argument for API URL +ARG VITE_API_HOST=https://api-finance.ai-assistant-bot.xyz + +# Set environment variable for build +ENV VITE_API_HOST=$VITE_API_HOST + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine AS production + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built assets from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Expose port 80 +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/FRONTEND_FUNCTIONS.md b/FRONTEND_FUNCTIONS.md new file mode 100644 index 0000000..b125be5 --- /dev/null +++ b/FRONTEND_FUNCTIONS.md @@ -0,0 +1,241 @@ +# Recommended Frontend Functions (API Client) + +This document lists recommended frontend functions to implement against the backend REST API. + +Base URL: + +- Development: `http://localhost:3000/api/v1` + +Notes: + +- Authentication uses **HTTP-only cookies**. The frontend should use `fetch`/Axios with `credentials: 'include'`. +- All endpoints below are assumed to require auth unless explicitly public (e.g. register/login). +- Suggested return types are illustrative; align with the backend DTO/response shapes. + +## Shared HTTP Helpers + +- **`apiFetch(path: string, options?: RequestInit): Promise`** + - Sets `baseUrl`, JSON headers, `credentials: 'include'`, error normalization. + +- **`parseApiError(e: unknown): { messageRu: string; status?: number; details?: any }`** + - Normalizes backend error responses. + +- **`toQueryString(params: Record): string`** + - For query endpoints (transactions search, analytics ranges, etc.). + +## Auth (Session/Cookies) + +- **`authRegister(payload: { email: string; password: string; firstName?: string; lastName?: string; phone?: string }): Promise`** + - `POST /auth/register` + +- **`authLogin(payload: { email: string; password: string }): Promise`** + - `POST /auth/login` + +- **`authRefresh(): Promise`** + - `POST /auth/refresh` + +- **`authLogout(): Promise`** + - `POST /auth/logout` + +- **`authLogoutAll(): Promise`** + - `POST /auth/logout-all` + +- **`authGetProfile(): Promise`** + - `GET /auth/profile` + +- **`authUpdateProfile(payload: { firstName?: string; lastName?: string; phone?: string }): Promise`** + - `PUT /auth/profile` + +- **`authChangePassword(payload: { currentPassword: string; newPassword: string; confirmPassword: string }): Promise`** + - `POST /auth/change-password` + +Suggested UI helpers: + +- **`useAuth()` hook** + - `user`, `isAuthenticated`, `login()`, `logout()`, `refresh()`, `loadProfile()` + +## Categories + +- **`categoriesList(params?: { type?: 'INCOME' | 'EXPENSE' }): Promise`** + - `GET /categories?type=...` + +- **`categoriesGetGrouped(): Promise<{ ESSENTIAL: Category[]; PERSONAL: Category[]; SAVINGS: Category[]; UNCATEGORIZED: Category[] }>`** + - `GET /categories/grouped` + +- **`categoriesCreate(payload: { nameRu: string; nameEn?: string; type: 'INCOME' | 'EXPENSE'; groupType?: 'ESSENTIAL' | 'PERSONAL' | 'SAVINGS'; icon?: string; color?: string; parentId?: string }): Promise`** + - `POST /categories` + +- **`categoriesUpdate(id: string, payload: Partial<{ nameRu: string; nameEn?: string; type: 'INCOME' | 'EXPENSE'; groupType?: 'ESSENTIAL' | 'PERSONAL' | 'SAVINGS'; icon?: string; color?: string; parentId?: string }>): Promise`** + - `PUT /categories/:id` + +- **`categoriesDelete(id: string): Promise`** + - `DELETE /categories/:id` + +Suggested UI helpers: + +- **`getCategoryDisplayName(cat: Category, locale: 'ru' | 'en'): string`** +- **`getCategoryBadgeStyle(cat: Category): { color: string; icon: string }`** + +## Transactions + +Core CRUD: + +- **`transactionsList(params?: { page?: number; limit?: number; type?: 'INCOME' | 'EXPENSE'; startDate?: string; endDate?: string; categoryId?: string; search?: string; minAmount?: number; maxAmount?: number; paymentMethod?: 'CASH' | 'CARD' | 'BANK_TRANSFER' }): Promise<{ data: Transaction[]; meta: PaginationMeta }>`** + - `GET /transactions?...` + +- **`transactionsGet(id: string): Promise`** + - `GET /transactions/:id` + +- **`transactionsCreate(payload: { amount: number; currency?: string; type: 'INCOME' | 'EXPENSE'; categoryId?: string; description?: string; transactionDate: string; paymentMethod?: 'CASH' | 'CARD' | 'BANK_TRANSFER'; receiptUrl?: string; isRecurring?: boolean; recurringPattern?: any; isPlanned?: boolean }): Promise`** + - `POST /transactions` + +- **`transactionsUpdate(id: string, payload: Partial<{ amount: number; categoryId?: string; description?: string; transactionDate: string; paymentMethod?: 'CASH' | 'CARD' | 'BANK_TRANSFER'; receiptUrl?: string; isRecurring?: boolean; recurringPattern?: any; isPlanned?: boolean }>): Promise`** + - `PUT /transactions/:id` + +- **`transactionsDelete(id: string): Promise`** + - `DELETE /transactions/:id` + +Bulk + reports: + +- **`transactionsBulkImport(payload: { items: Array }): Promise<{ created: number; skipped: number; errors: any[] }>`** + - `POST /transactions/bulk-import` + +- **`transactionsSummary(params: { startDate: string; endDate: string }): Promise<{ totalIncome: number; totalExpense: number; net: number }>`** + - `GET /transactions/summary?startDate=...&endDate=...` + +- **`transactionsSpendingByCategory(params: { startDate: string; endDate: string }): Promise>`** + - `GET /transactions/spending-by-category?startDate=...&endDate=...` + +Suggested UI helpers: + +- **`formatMoneyRu(amount: number, currency: string = 'RUB'): string`** +- **`groupTransactionsByDay(transactions: Transaction[]): Record`** + +## Budgets (50/30/20) + +- **`budgetsList(): Promise`** + - `GET /budgets` + +- **`budgetsGetCurrent(): Promise`** + - `GET /budgets/current` + +- **`budgetsGetByMonth(month: string /* YYYY-MM-01 */): Promise`** + - `GET /budgets/month?month=...` + +- **`budgetsCreate(payload: { month: string; totalIncome: number; essentialsPercent?: number; personalPercent?: number; savingsPercent?: number }): Promise`** + - `POST /budgets` + +- **`budgetsUpdate(month: string, payload: Partial<{ totalIncome: number; essentialsPercent?: number; personalPercent?: number; savingsPercent?: number }>): Promise`** + - `PUT /budgets?month=...` + +- **`budgetsDelete(month: string): Promise`** + - `DELETE /budgets?month=...` + +- **`budgetsProgress(month: string): Promise<{ essentials: Progress; personal: Progress; savings: Progress; total: TotalProgress }>`** + - `GET /budgets/progress?month=...` + +Suggested UI helpers: + +- **`budgetProgressColor(pct: number): 'green' | 'yellow' | 'red'`** + +## Goals + +- **`goalsList(params?: { status?: 'ACTIVE' | 'COMPLETED' | 'PAUSED' | 'CANCELLED' }): Promise`** + - `GET /goals?status=...` + +- **`goalsGet(id: string): Promise`** + - `GET /goals/:id` + +- **`goalsCreate(payload: { titleRu: string; descriptionRu?: string; targetAmount: number; currentAmount?: number; targetDate?: string; priority?: 'LOW' | 'MEDIUM' | 'HIGH'; icon?: string; color?: string; autoSaveEnabled?: boolean; autoSaveAmount?: number; autoSaveFrequency?: 'DAILY' | 'WEEKLY' | 'MONTHLY' }): Promise`** + - `POST /goals` + +- **`goalsUpdate(id: string, payload: Partial<...same as create...>): Promise`** + - `PUT /goals/:id` + +- **`goalsDelete(id: string): Promise`** + - `DELETE /goals/:id` + +- **`goalsAddFunds(id: string, payload: { amount: number; note?: string }): Promise`** + - `POST /goals/:id/add-funds` + +- **`goalsWithdraw(id: string, payload: { amount: number; note?: string }): Promise`** + - `POST /goals/:id/withdraw` + +- **`goalsSummary(): Promise<{ totalGoals: number; activeGoals: number; completedGoals: number; totalTargetAmount: number; totalCurrentAmount: number; overallProgress: number }>`** + - `GET /goals/summary` + +- **`goalsUpcomingDeadlines(days?: number): Promise`** + - `GET /goals/upcoming?days=...` + +Suggested UI helpers: + +- **`goalProgress(goal: Goal): { percent: number; remaining: number }`** +- **`goalStatusLabelRu(status: string): string`** + +## Recommendations + +- **`recommendationsList(params?: { type?: 'SAVING' | 'SPENDING' | 'INVESTMENT' | 'TAX' | 'DEBT' | 'BUDGET' | 'GOAL' }): Promise`** + - `GET /recommendations?type=...` + +- **`recommendationsGet(id: string): Promise`** + - `GET /recommendations/:id` + +- **`recommendationsGenerate(): Promise`** + - `POST /recommendations/generate` + +- **`recommendationsMarkViewed(id: string): Promise`** + - `POST /recommendations/:id/view` + +- **`recommendationsApply(id: string): Promise`** + - `POST /recommendations/:id/apply` + +- **`recommendationsDismiss(id: string): Promise`** + - `POST /recommendations/:id/dismiss` + +- **`recommendationsStats(): Promise<{ total: number; new: number; viewed: number; applied: number; dismissed: number; potentialSavings: number }>`** + - `GET /recommendations/stats` + +Suggested UI helpers: + +- **`recommendationBadge(type: string): { labelRu: string; color: string }`** + +## Analytics + +- **`analyticsMonthlyOverview(month?: string /* YYYY-MM */): Promise`** + - `GET /analytics/overview?month=...` + +- **`analyticsTrends(months?: number): Promise`** + - `GET /analytics/trends?months=...` + +- **`analyticsCategoryBreakdown(params: { startDate: string; endDate: string; type?: 'INCOME' | 'EXPENSE' }): Promise`** + - `GET /analytics/categories?startDate=...&endDate=...&type=...` + +- **`analyticsIncomeVsExpenses(months?: number): Promise>`** + - `GET /analytics/income-vs-expenses?months=...` + +- **`analyticsFinancialHealth(): Promise`** + - `GET /analytics/health` + +- **`analyticsYearly(year?: number): Promise`** + - `GET /analytics/yearly?year=...` + +Suggested UI helpers: + +- **`financialHealthBadge(grade: 'A'|'B'|'C'|'D'|'F'): { labelRu: string; color: string }`** + +## Suggested Frontend Architecture + +- `src/api/`: + - `client.ts` (`apiFetch`, interceptors, base URL) + - `auth.ts`, `categories.ts`, `transactions.ts`, `budgets.ts`, `goals.ts`, `recommendations.ts`, `analytics.ts` +- `src/types/`: + - shared types mirrored from backend DTOs/entities +- `src/hooks/`: + - `useAuth`, `useTransactions`, `useBudgets`, `useGoals`, `useRecommendations`, `useAnalytics` + +## Minimal Types (Suggested) + +- `User`, `Category`, `Transaction`, `Budget`, `Goal`, `Recommendation` +- `PaginationMeta` + +(Define based on Swagger at `/api/docs` once backend is running.) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4dcad1f --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress. + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/docker-compose.server.yml b/docker-compose.server.yml new file mode 100644 index 0000000..9c5551d --- /dev/null +++ b/docker-compose.server.yml @@ -0,0 +1,24 @@ +version: "3.8" + +services: + frontend: + build: + context: . + dockerfile: Dockerfile + args: + VITE_API_HOST: https://api-finance.ai-assistant-bot.xyz + container_name: finance_front + networks: + - proxy + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.docker.network=proxy + - traefik.http.routers.finance.rule=Host(`finance.ai-assistant-bot.xyz`) + - traefik.http.routers.finance.entrypoints=web,websecure + - traefik.http.routers.finance.tls.certresolver=le + - traefik.http.services.finance.loadbalancer.server.port=80 + +networks: + proxy: + external: true diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..191c0a3 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + finance-frontend + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..f85442b --- /dev/null +++ b/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied expired no-cache no-store private auth; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Handle SPA routing - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c894652 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4326 @@ +{ + "name": "finance-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "finance-frontend", + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@reduxjs/toolkit": "^2.11.2", + "@tailwindcss/vite": "^4.1.18", + "axios": "^1.13.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.69.0", + "react-redux": "^9.2.0", + "react-router": "^7.11.0", + "redux": "^5.0.1", + "tailwindcss": "^4.1.18", + "zod": "^4.2.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vite-plugin-mkcert": "^1.17.9" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/core": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.7.tgz", + "integrity": "sha512-kTGB8XI7P+pTKW83tnUEDVP4zduF951u3UAOn5eTi0vyW6MvL56A3+ggMdfuVFtDI0/DsbSzf5z34HVBbuScWw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.7", + "@swc/core-darwin-x64": "1.15.7", + "@swc/core-linux-arm-gnueabihf": "1.15.7", + "@swc/core-linux-arm64-gnu": "1.15.7", + "@swc/core-linux-arm64-musl": "1.15.7", + "@swc/core-linux-x64-gnu": "1.15.7", + "@swc/core-linux-x64-musl": "1.15.7", + "@swc/core-win32-arm64-msvc": "1.15.7", + "@swc/core-win32-ia32-msvc": "1.15.7", + "@swc/core-win32-x64-msvc": "1.15.7" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.7.tgz", + "integrity": "sha512-+hNVUfezUid7LeSHqnhoC6Gh3BROABxjlDNInuZ/fie1RUxaEX4qzDwdTgozJELgHhvYxyPIg1ro8ibnKtgO4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.7.tgz", + "integrity": "sha512-ZAFuvtSYZTuXPcrhanaD5eyp27H8LlDzx2NAeVyH0FchYcuXf0h5/k3GL9ZU6Jw9eQ63R1E8KBgpXEJlgRwZUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.7.tgz", + "integrity": "sha512-K3HTYocpqnOw8KcD8SBFxiDHjIma7G/X+bLdfWqf+qzETNBrzOub/IEkq9UaeupaJiZJkPptr/2EhEXXWryS/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.7.tgz", + "integrity": "sha512-HCnVIlsLnCtQ3uXcXgWrvQ6SAraskLA9QJo9ykTnqTH6TvUYqEta+TdTdGjzngD6TOE7XjlAiUs/RBtU8Z0t+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.7.tgz", + "integrity": "sha512-/OOp9UZBg4v2q9+x/U21Jtld0Wb8ghzBScwhscI7YvoSh4E8RALaJ1msV8V8AKkBkZH7FUAFB7Vbv0oVzZsezA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.7.tgz", + "integrity": "sha512-VBbs4gtD4XQxrHuQ2/2+TDZpPQQgrOHYRnS6SyJW+dw0Nj/OomRqH+n5Z4e/TgKRRbieufipeIGvADYC/90PYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.7.tgz", + "integrity": "sha512-kVuy2unodso6p0rMauS2zby8/bhzoGRYxBDyD6i2tls/fEYAE74oP0VPFzxIyHaIjK1SN6u5TgvV9MpyJ5xVug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.7.tgz", + "integrity": "sha512-uddYoo5Xmo1XKLhAnh4NBIyy5d0xk33x1sX3nIJboFySLNz878ksCFCZ3IBqrt1Za0gaoIWoOSSSk0eNhAc/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.7.tgz", + "integrity": "sha512-rqq8JjNMLx3QNlh0aPTtN/4+BGLEHC94rj9mkH1stoNRf3ra6IksNHMHy+V1HUqElEgcZyx+0yeXx3eLOTcoFw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.7.tgz", + "integrity": "sha512-4BK06EGdPnuplgcNhmSbOIiLdRgHYX3v1nl4HXo5uo4GZMfllXaCyBUes+0ePRfwbn9OFgVhCWPcYYjMT6hycQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", + "integrity": "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.50.1", + "@typescript-eslint/type-utils": "8.50.1", + "@typescript-eslint/utils": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.50.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz", + "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.50.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.1.tgz", + "integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.50.1", + "@typescript-eslint/types": "^8.50.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.1.tgz", + "integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.1.tgz", + "integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.1.tgz", + "integrity": "sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1", + "@typescript-eslint/utils": "8.50.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.1.tgz", + "integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.1.tgz", + "integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.50.1", + "@typescript-eslint/tsconfig-utils": "8.50.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.1.tgz", + "integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.50.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.1.tgz", + "integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz", + "integrity": "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.47", + "@swc/core": "^1.13.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.0.tgz", + "integrity": "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-hook-form": { + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.69.0.tgz", + "integrity": "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", + "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.1.tgz", + "integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1", + "@typescript-eslint/utils": "8.50.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-mkcert": { + "version": "1.17.9", + "resolved": "https://registry.npmjs.org/vite-plugin-mkcert/-/vite-plugin-mkcert-1.17.9.tgz", + "integrity": "sha512-SwI7yqp2Cq4r2XItarnHRCj2uzHPqevbxFNMLpyN+LDXd5w1vmZeM4l5X/wCZoP4mjPQYN+9+4kmE6e3nPO5fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.12.2", + "debug": "^4.4.3", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=v16.7.0" + }, + "peerDependencies": { + "vite": ">=3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..98b3d23 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "finance-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@reduxjs/toolkit": "^2.11.2", + "@tailwindcss/vite": "^4.1.18", + "axios": "^1.13.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.69.0", + "react-redux": "^9.2.0", + "react-router": "^7.11.0", + "redux": "^5.0.1", + "tailwindcss": "^4.1.18", + "zod": "^4.2.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vite-plugin-mkcert": "^1.17.9" + } +} diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/api/analytics.ts b/src/api/analytics.ts new file mode 100644 index 0000000..73dd50b --- /dev/null +++ b/src/api/analytics.ts @@ -0,0 +1,31 @@ +import { + type AnalyticsOverviewApi, + type AnalyticsTrendsApi, + type AnalyticsCategoryBreakdownApi, + type AnalyticsIncomeVsExpensesApi, + type AnalyticsFinancialHealthApi, + type AnalyticsYearlySummaryApi, +} from "../interfaces/analytics"; +import { requestInstance } from "../request-instance"; + +export const analyticsOverviewApi: AnalyticsOverviewApi = async (params) => + (await requestInstance.get("analytics/overview", { params })).data; + +export const analyticsTrendsApi: AnalyticsTrendsApi = async (params) => + (await requestInstance.get("analytics/trends", { params })).data; + +export const analyticsCategoryBreakdownApi: AnalyticsCategoryBreakdownApi = + async (params) => + (await requestInstance.get("analytics/categories", { params })).data; + +export const analyticsIncomeVsExpensesApi: AnalyticsIncomeVsExpensesApi = + async (params) => + (await requestInstance.get("analytics/income-vs-expenses", { params })) + .data; + +export const analyticsFinancialHealthApi: AnalyticsFinancialHealthApi = + async () => (await requestInstance.get("analytics/health")).data; + +export const analyticsYearlySummaryApi: AnalyticsYearlySummaryApi = async ( + params, +) => (await requestInstance.get("analytics/yearly", { params })).data; diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..98881c3 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,18 @@ +import { + type AuthLoginApi, + type AuthRegisterApi, + type ReApi, +} from "../interfaces/auth"; +import { requestInstance } from "../request-instance"; + +export const authLoginApi: AuthLoginApi = async (body) => + (await requestInstance.post("auth/login", body)).data; + +export const authRegisterApi: AuthRegisterApi = async (body) => + (await requestInstance.post("auth/register", body)).data; + +// export const authLogoutApi: LogoutApi = async () => +// (await requestInstance.post("auth/logout")).data; + +export const authReApi: ReApi = async () => + (await requestInstance.post("auth/refresh")).data; diff --git a/src/api/budgets.ts b/src/api/budgets.ts new file mode 100644 index 0000000..8cb2cbc --- /dev/null +++ b/src/api/budgets.ts @@ -0,0 +1,31 @@ +import { + type BudgetsListApi, + type BudgetsGetCurrentApi, + type BudgetsGetByMonthApi, + type BudgetsCreateApi, + type BudgetsUpdateApi, + type BudgetsDeleteApi, + type BudgetsProgressApi, +} from "../interfaces/budgets"; +import { requestInstance } from "../request-instance"; + +export const budgetsListApi: BudgetsListApi = async () => + (await requestInstance.get("budgets")).data; + +export const budgetsGetCurrentApi: BudgetsGetCurrentApi = async () => + (await requestInstance.get("budgets/current")).data; + +export const budgetsGetByMonthApi: BudgetsGetByMonthApi = async (month) => + (await requestInstance.get("budgets/month", { params: { month } })).data; + +export const budgetsCreateApi: BudgetsCreateApi = async (body) => + (await requestInstance.post("budgets", body)).data; + +export const budgetsUpdateApi: BudgetsUpdateApi = async ({ month, body }) => + (await requestInstance.put("budgets", body, { params: { month } })).data; + +export const budgetsDeleteApi: BudgetsDeleteApi = async (month) => + (await requestInstance.delete("budgets", { params: { month } })).data; + +export const budgetsProgressApi: BudgetsProgressApi = async (month) => + (await requestInstance.get("budgets/progress", { params: { month } })).data; diff --git a/src/api/categories.ts b/src/api/categories.ts new file mode 100644 index 0000000..f955d0f --- /dev/null +++ b/src/api/categories.ts @@ -0,0 +1,27 @@ +import { + type CategoriesListApi, + type CategoriesGetApi, + type CategoriesGetGroupedApi, + type CategoriesCreateApi, + type CategoriesUpdateApi, + type CategoriesDeleteApi, +} from "../interfaces/categories"; +import { requestInstance } from "../request-instance"; + +export const categoriesListApi: CategoriesListApi = async (params) => + (await requestInstance.get("categories", { params })).data; + +export const categoriesGetApi: CategoriesGetApi = async (id) => + (await requestInstance.get(`categories/${id}`)).data; + +export const categoriesGetGroupedApi: CategoriesGetGroupedApi = async () => + (await requestInstance.get("categories/grouped")).data; + +export const categoriesCreateApi: CategoriesCreateApi = async (body) => + (await requestInstance.post("categories", body)).data; + +export const categoriesUpdateApi: CategoriesUpdateApi = async ({ id, body }) => + (await requestInstance.put(`categories/${id}`, body)).data; + +export const categoriesDeleteApi: CategoriesDeleteApi = async (id) => + (await requestInstance.delete(`categories/${id}`)).data; diff --git a/src/api/goals.ts b/src/api/goals.ts new file mode 100644 index 0000000..e7e8442 --- /dev/null +++ b/src/api/goals.ts @@ -0,0 +1,43 @@ +import { + type GoalsListApi, + type GoalsGetApi, + type GoalsCreateApi, + type GoalsUpdateApi, + type GoalsDeleteApi, + type GoalsAddFundsApi, + type GoalsWithdrawApi, + type GoalsSummaryApi, + type GoalsUpcomingApi, +} from "../interfaces/goals"; +import { requestInstance } from "../request-instance"; + +export const goalsListApi: GoalsListApi = async (params) => + (await requestInstance.get("goals", { params })).data; + +export const goalsGetApi: GoalsGetApi = async (id) => + (await requestInstance.get(`goals/${id}`)).data; + +export const goalsCreateApi: GoalsCreateApi = async (body) => + (await requestInstance.post("goals", body)).data; + +export const goalsUpdateApi: GoalsUpdateApi = async ({ id, body }) => + (await requestInstance.put(`goals/${id}`, body)).data; + +export const goalsDeleteApi: GoalsDeleteApi = async (id) => + (await requestInstance.delete(`goals/${id}`)).data; + +export const goalsAddFundsApi: GoalsAddFundsApi = async ({ id, body }) => + (await requestInstance.post(`goals/${id}/add-funds`, body)).data; + +export const goalsWithdrawApi: GoalsWithdrawApi = async ({ id, body }) => + (await requestInstance.post(`goals/${id}/withdraw`, body)).data; + +export const goalsSummaryApi: GoalsSummaryApi = async () => + (await requestInstance.get("goals/summary")).data; + +export const goalsUpcomingApi: GoalsUpcomingApi = async (days) => + ( + await requestInstance.get("goals/upcoming", { + params: days ? { days } : undefined, + }) + ).data; diff --git a/src/api/parse-api-error.ts b/src/api/parse-api-error.ts new file mode 100644 index 0000000..994775b --- /dev/null +++ b/src/api/parse-api-error.ts @@ -0,0 +1,39 @@ +export type ParsedApiError = { + statusCode?: number; + messages: string[]; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +export const parseApiError = (error: unknown): ParsedApiError => { + const defaultResult: ParsedApiError = { + messages: ["Произошла ошибка. Попробуйте ещё раз."], + }; + + if (!isRecord(error)) return defaultResult; + + const response = error.response; + if (!isRecord(response)) return defaultResult; + + const data = response.data; + if (!isRecord(data)) return defaultResult; + + const statusCode = + typeof data.statusCode === "number" ? data.statusCode : undefined; + const message = data.message; + + if (typeof message === "string") { + return { statusCode, messages: [message] }; + } + + if (Array.isArray(message) && message.every((m) => typeof m === "string")) { + return { statusCode, messages: message }; + } + + if (typeof data.message === "string") { + return { statusCode, messages: [data.message] }; + } + + return { ...defaultResult, statusCode }; +}; diff --git a/src/api/profile.ts b/src/api/profile.ts new file mode 100644 index 0000000..ed8bc61 --- /dev/null +++ b/src/api/profile.ts @@ -0,0 +1,32 @@ +import { requestInstance } from "../request-instance"; +import type { + ChangePasswordPayload, + UpdateProfilePayload, + UserProfile, +} from "../interfaces/auth"; + +export const authGetProfile = async (): Promise => { + const response = await requestInstance.get("auth/profile"); + return response.data; +}; + +export const authUpdateProfile = async ( + payload: UpdateProfilePayload, +): Promise => { + const response = await requestInstance.put("auth/profile", payload); + return response.data; +}; + +export const authChangePassword = async ( + payload: ChangePasswordPayload, +): Promise => { + await requestInstance.post("auth/change-password", payload); +}; + +export const authLogout = async (): Promise => { + await requestInstance.post("auth/logout"); +}; + +export const authLogoutAll = async (): Promise => { + await requestInstance.post("auth/logout-all"); +}; diff --git a/src/api/recommendations.ts b/src/api/recommendations.ts new file mode 100644 index 0000000..f25f81a --- /dev/null +++ b/src/api/recommendations.ts @@ -0,0 +1,32 @@ +import { + type RecommendationsListApi, + type RecommendationsGetApi, + type RecommendationsGenerateApi, + type RecommendationsMarkViewedApi, + type RecommendationsApplyApi, + type RecommendationsDismissApi, + type RecommendationsStatsApi, +} from "../interfaces/recommendations"; +import { requestInstance } from "../request-instance"; + +export const recommendationsListApi: RecommendationsListApi = async (params) => + (await requestInstance.get("recommendations", { params })).data; + +export const recommendationsGetApi: RecommendationsGetApi = async (id) => + (await requestInstance.get(`recommendations/${id}`)).data; + +export const recommendationsGenerateApi: RecommendationsGenerateApi = + async () => (await requestInstance.post("recommendations/generate")).data; + +export const recommendationsMarkViewedApi: RecommendationsMarkViewedApi = + async (id) => (await requestInstance.post(`recommendations/${id}/view`)).data; + +export const recommendationsApplyApi: RecommendationsApplyApi = async (id) => + (await requestInstance.post(`recommendations/${id}/apply`)).data; + +export const recommendationsDismissApi: RecommendationsDismissApi = async ( + id, +) => (await requestInstance.post(`recommendations/${id}/dismiss`)).data; + +export const recommendationsStatsApi: RecommendationsStatsApi = async () => + (await requestInstance.get("recommendations/stats")).data; diff --git a/src/api/transactions.ts b/src/api/transactions.ts new file mode 100644 index 0000000..1ac0794 --- /dev/null +++ b/src/api/transactions.ts @@ -0,0 +1,39 @@ +import { + type TransactionsListApi, + type TransactionsGetApi, + type TransactionsCreateApi, + type TransactionsUpdateApi, + type TransactionsDeleteApi, + type TransactionsBulkCreateApi, + type TransactionsSummaryApi, + type TransactionsByCategoryApi, +} from "../interfaces/transactions"; +import { requestInstance } from "../request-instance"; + +export const transactionsListApi: TransactionsListApi = async (filters) => + (await requestInstance.get("transactions", { params: filters })).data; + +export const transactionsGetApi: TransactionsGetApi = async (id) => + (await requestInstance.get(`transactions/${id}`)).data; + +export const transactionsCreateApi: TransactionsCreateApi = async (body) => + (await requestInstance.post("transactions", body)).data; + +export const transactionsUpdateApi: TransactionsUpdateApi = async ({ + id, + body, +}) => (await requestInstance.put(`transactions/${id}`, body)).data; + +export const transactionsDeleteApi: TransactionsDeleteApi = async (id) => + (await requestInstance.delete(`transactions/${id}`)).data; + +export const transactionsBulkCreateApi: TransactionsBulkCreateApi = async ( + transactions, +) => (await requestInstance.post("transactions/bulk", transactions)).data; + +export const transactionsSummaryApi: TransactionsSummaryApi = async (params) => + (await requestInstance.get("transactions/summary", { params })).data; + +export const transactionsByCategoryApi: TransactionsByCategoryApi = async ( + params, +) => (await requestInstance.get("transactions/by-category", { params })).data; diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/browser-router.ts b/src/browser-router.ts new file mode 100644 index 0000000..2f20fc2 --- /dev/null +++ b/src/browser-router.ts @@ -0,0 +1,3 @@ +import { createBrowserRouter } from "react-router"; +import routes from "./router"; +export const browserRouter = createBrowserRouter(routes); diff --git a/src/components/landing/FAQItem.tsx b/src/components/landing/FAQItem.tsx new file mode 100644 index 0000000..cbd5e9d --- /dev/null +++ b/src/components/landing/FAQItem.tsx @@ -0,0 +1,30 @@ +type FAQItemProps = { + question: string; + answer: string; +}; + +export const FAQItem = ({ question, answer }: FAQItemProps) => { + return ( +
+ + + {question} + + + +

{answer}

+
+ ); +}; diff --git a/src/components/landing/FeatureCard.tsx b/src/components/landing/FeatureCard.tsx new file mode 100644 index 0000000..e665c5b --- /dev/null +++ b/src/components/landing/FeatureCard.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +type FeatureCardProps = { + icon: ReactNode; + title: string; + description: string; +}; + +export const FeatureCard = ({ icon, title, description }: FeatureCardProps) => { + return ( +
+
+
+ {icon} +
+
+

{title}

+

{description}

+
+
+
+ ); +}; diff --git a/src/components/landing/Section.tsx b/src/components/landing/Section.tsx new file mode 100644 index 0000000..68bc9f0 --- /dev/null +++ b/src/components/landing/Section.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; + +type SectionProps = { + id: string; + title: string; + subtitle?: string; + children: ReactNode; + className?: string; +}; + +export const Section = ({ + id, + title, + subtitle, + children, + className, +}: SectionProps) => { + return ( +
+
+
+

+ {title} +

+ {subtitle ? ( +

+ {subtitle} +

+ ) : null} +
+ {children} +
+
+ ); +}; diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..7045cf0 --- /dev/null +++ b/src/components/layout/AppShell.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { Outlet } from "react-router"; +import { Sidebar } from "./Sidebar"; +import { Topbar } from "./Topbar"; +import { TransactionModal } from "../transactions/TransactionModal"; + +export const AppShell = () => { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [transactionModalOpen, setTransactionModalOpen] = useState(false); + + return ( +
+ setSidebarOpen(false)} /> + +
+ setSidebarOpen(true)} + onAddTransaction={() => setTransactionModalOpen(true)} + /> + +
+ +
+
+ + setTransactionModalOpen(false)} + /> +
+ ); +}; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..6afc470 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,205 @@ +import { NavLink } from "react-router"; + +const navItems = [ + { + label: "Обзор", + path: "/dashboard", + icon: ( + + + + ), + }, + { + label: "Транзакции", + path: "/dashboard/transactions", + icon: ( + + + + ), + }, + { + label: "Категории", + path: "/dashboard/categories", + icon: ( + + + + ), + }, + { + label: "Бюджет", + path: "/dashboard/budgets", + icon: ( + + + + ), + }, + { + label: "Цели", + path: "/dashboard/goals", + icon: ( + + + + ), + }, + { + label: "Рекомендации", + path: "/dashboard/recommendations", + icon: ( + + + + ), + }, + { + label: "Аналитика", + path: "/dashboard/analytics", + icon: ( + + + + ), + }, + { + label: "Настройки", + path: "/dashboard/settings", + icon: ( + + + + + ), + }, +]; + +interface SidebarProps { + isOpen: boolean; + onClose: () => void; +} + +export const Sidebar = ({ isOpen, onClose }: SidebarProps) => { + return ( + <> + {/* Mobile overlay */} + {isOpen && ( +
+ )} + + {/* Sidebar */} + + + ); +}; diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx new file mode 100644 index 0000000..cceb56d --- /dev/null +++ b/src/components/layout/Topbar.tsx @@ -0,0 +1,48 @@ +interface TopbarProps { + onMenuClick: () => void; + onAddTransaction: () => void; +} + +export const Topbar = ({ onMenuClick, onAddTransaction }: TopbarProps) => { + return ( +
+ {/* Left side */} +
+ {/* Mobile menu button */} + +
+ + {/* Right side */} +
+ {/* Add transaction button */} + +
+
+ ); +}; diff --git a/src/components/loading-component/index.tsx b/src/components/loading-component/index.tsx new file mode 100644 index 0000000..e7f5e31 --- /dev/null +++ b/src/components/loading-component/index.tsx @@ -0,0 +1,35 @@ +import type { FC, ReactNode } from "react"; +import { LoadingStatus } from "../../store/loading/types"; +import { Spinner } from "../spinner"; + +interface PageContentProps { + children?: ReactNode; + error?: ReactNode; + loader?: ReactNode; + loadingStatus?: LoadingStatus; + emptyInitial?: boolean; + renderOnFail?: boolean; +} + +export const LoadingContentComponent: FC = ({ + loadingStatus, + children, + loader = , + error = "Ошибка загрузки данных", + emptyInitial = false, + renderOnFail = false, +}) => { + if (emptyInitial && loadingStatus === LoadingStatus.Initial) { + return null; + } + + if (loadingStatus === LoadingStatus.Fulfilled || renderOnFail) { + return <>{children}; + } + + if (loadingStatus === LoadingStatus.Rejected) { + return
{error}
; + } + + return
{loader}
; +}; diff --git a/src/components/spinner/index.tsx b/src/components/spinner/index.tsx new file mode 100644 index 0000000..8c33c3a --- /dev/null +++ b/src/components/spinner/index.tsx @@ -0,0 +1,21 @@ +export const Spinner = () => ( +
+ + Loading... +
+); diff --git a/src/components/transactions/TransactionModal.tsx b/src/components/transactions/TransactionModal.tsx new file mode 100644 index 0000000..bfa1390 --- /dev/null +++ b/src/components/transactions/TransactionModal.tsx @@ -0,0 +1,224 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useAppDispatch, useAppSelector } from "../../store/hooks"; +import { categoriesSelector } from "../../store/categories/selectors"; +import { categoriesList } from "../../store/categories/async-thunks"; +import { + transactionsCreate, + transactionsUpdate, +} from "../../store/transactions/async-thunks"; +import { parseApiError } from "../../api/parse-api-error"; +import type { Transaction } from "../../interfaces/transactions"; +import { Modal } from "../ui/Modal"; +import { Button } from "../ui/Button"; +import { Input } from "../ui/Input"; +import { Select } from "../ui/Select"; + +const schema = z.object({ + amount: z.number().positive("Сумма должна быть положительной"), + type: z.enum(["INCOME", "EXPENSE"] as const), + categoryId: z.string().min(1, "Выберите категорию"), + description: z.string().optional(), + transactionDate: z.string().min(1, "Выберите дату"), + paymentMethod: z.enum(["CASH", "CARD", "BANK_TRANSFER"] as const).optional(), +}); + +type FormValues = z.infer; + +interface TransactionModalProps { + isOpen: boolean; + onClose: () => void; + transaction?: Transaction; + onSuccess?: () => void; +} + +export const TransactionModal = ({ + isOpen, + onClose, + transaction, + onSuccess, +}: TransactionModalProps) => { + const dispatch = useAppDispatch(); + const categories = useAppSelector(categoriesSelector); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const isEditing = !!transaction; + + const { + register, + handleSubmit, + reset, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + amount: transaction?.amount || 0, + type: transaction?.type || "EXPENSE", + categoryId: transaction?.categoryId ?? "", + description: transaction?.description || "", + transactionDate: + transaction?.transactionDate?.split("T")[0] || + new Date().toISOString().split("T")[0], + paymentMethod: transaction?.paymentMethod || undefined, + }, + }); + + const selectedType = watch("type"); + + useEffect(() => { + if (isOpen) { + dispatch(categoriesList()); + if (transaction) { + reset({ + amount: transaction.amount, + type: transaction.type, + categoryId: transaction.categoryId, + description: transaction.description || "", + transactionDate: transaction.transactionDate.split("T")[0], + paymentMethod: transaction.paymentMethod || undefined, + }); + } else { + reset({ + amount: 0, + type: "EXPENSE", + categoryId: "", + description: "", + transactionDate: new Date().toISOString().split("T")[0], + paymentMethod: undefined, + }); + } + } + }, [dispatch, isOpen, transaction, reset]); + + const onSubmit = async (values: FormValues) => { + setIsLoading(true); + setError(null); + + try { + if (isEditing && transaction) { + await dispatch( + transactionsUpdate({ + id: transaction.id, + body: { + ...values, + transactionDate: new Date(values.transactionDate).toISOString(), + }, + }), + ).unwrap(); + } else { + await dispatch( + transactionsCreate({ + ...values, + transactionDate: new Date(values.transactionDate).toISOString(), + }), + ).unwrap(); + } + onSuccess?.(); + onClose(); + } catch (err) { + const parsed = parseApiError(err); + setError(parsed.messages[0] || "Произошла ошибка"); + } finally { + setIsLoading(false); + } + }; + + const filteredCategories = categories.filter( + (cat) => cat.type === selectedType, + ); + + const typeOptions = [ + { value: "EXPENSE", label: "Расход" }, + { value: "INCOME", label: "Доход" }, + ]; + + const paymentMethodOptions = [ + { value: "", label: "Не указан" }, + { value: "CASH", label: "Наличные" }, + { value: "CARD", label: "Карта" }, + { value: "BANK_TRANSFER", label: "Банковский перевод" }, + ]; + + const categoryOptions = [ + { value: "", label: "Без категории" }, + ...filteredCategories.map((cat) => ({ + value: cat.id, + label: cat.nameRu, + })), + ]; + + return ( + + {error && ( +
+ {error} +
+ )} + +
+
+ + + + + +
+ + + {error && ( +

+ {error} +

+ )} + {hint && !error && ( +

{hint}

+ )} +
+ ); + }, +); + +Input.displayName = "Input"; diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..357148d --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, type ReactNode } from "react"; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + size?: "sm" | "md" | "lg" | "xl"; +} + +const sizeClasses = { + sm: "max-w-sm", + md: "max-w-md", + lg: "max-w-lg", + xl: "max-w-xl", +}; + +export const Modal = ({ + isOpen, + onClose, + title, + children, + size = "md", +}: ModalProps) => { + const overlayRef = useRef(null); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + + if (isOpen) { + document.addEventListener("keydown", handleEscape); + document.body.style.overflow = "hidden"; + } + + return () => { + document.removeEventListener("keydown", handleEscape); + document.body.style.overflow = ""; + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose(); + }; + + return ( +
+
+
+ + +
+
{children}
+
+
+ ); +}; diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..ace734c --- /dev/null +++ b/src/components/ui/PageHeader.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +interface PageHeaderProps { + title: string; + description?: string; + actions?: ReactNode; +} + +export const PageHeader = ({ + title, + description, + actions, +}: PageHeaderProps) => { + return ( +
+
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ); +}; diff --git a/src/components/ui/ProgressBar.tsx b/src/components/ui/ProgressBar.tsx new file mode 100644 index 0000000..7abdfbf --- /dev/null +++ b/src/components/ui/ProgressBar.tsx @@ -0,0 +1,57 @@ +interface ProgressBarProps { + value: number; + max?: number; + label?: string; + showValue?: boolean; + size?: "sm" | "md" | "lg"; + color?: "default" | "success" | "warning" | "danger"; +} + +const colorClasses = { + default: "bg-slate-900", + success: "bg-emerald-500", + warning: "bg-amber-500", + danger: "bg-rose-500", +}; + +const sizeClasses = { + sm: "h-1.5", + md: "h-2", + lg: "h-3", +}; + +export const ProgressBar = ({ + value, + max = 100, + label, + showValue = false, + size = "md", + color = "default", +}: ProgressBarProps) => { + const percentage = Math.min(Math.max((value / max) * 100, 0), 100); + + return ( +
+ {(label || showValue) && ( +
+ {label && {label}} + {showValue && ( + {Math.round(percentage)}% + )} +
+ )} +
+
+
+
+ ); +}; diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx new file mode 100644 index 0000000..72b98d1 --- /dev/null +++ b/src/components/ui/Select.tsx @@ -0,0 +1,60 @@ +import { forwardRef, type SelectHTMLAttributes } from "react"; + +interface SelectOption { + value: string; + label: string; +} + +interface SelectProps extends SelectHTMLAttributes { + label?: string; + error?: string; + options: SelectOption[]; + placeholder?: string; +} + +export const Select = forwardRef( + ( + { label, error, options, placeholder, className = "", id, ...props }, + ref, + ) => { + const selectId = id || props.name; + + return ( +
+ {label && ( + + )} + + {error &&

{error}

} +
+ ); + }, +); + +Select.displayName = "Select"; diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..8a4fbc6 --- /dev/null +++ b/src/components/ui/Skeleton.tsx @@ -0,0 +1,50 @@ +interface SkeletonProps { + className?: string; + variant?: "text" | "circular" | "rectangular"; +} + +export const Skeleton = ({ + className = "", + variant = "rectangular", +}: SkeletonProps) => { + const baseClasses = "animate-pulse bg-slate-200"; + + const variantClasses = { + text: "h-4 rounded", + circular: "rounded-full", + rectangular: "rounded-xl", + }; + + return ( +
+ ); +}; + +export const CardSkeleton = () => ( +
+ + + +
+); + +export const TableRowSkeleton = ({ columns = 4 }: { columns?: number }) => ( + + {Array.from({ length: columns }).map((_, i) => ( + + + + ))} + +); + +export const ListItemSkeleton = () => ( +
+ +
+ + +
+ +
+); diff --git a/src/components/ui/StatCard.tsx b/src/components/ui/StatCard.tsx new file mode 100644 index 0000000..678f7c8 --- /dev/null +++ b/src/components/ui/StatCard.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from "react"; + +interface StatCardProps { + title: string; + value: string | number; + subtitle?: string; + icon?: ReactNode; + trend?: { + value: number; + isPositive: boolean; + }; + className?: string; +} + +export const StatCard = ({ + title, + value, + subtitle, + icon, + trend, + className = "", +}: StatCardProps) => { + return ( +
+
+
+

{title}

+

{value}

+ {subtitle && ( +

{subtitle}

+ )} + {trend && ( +

+ {trend.isPositive ? "+" : ""} + {trend.value}% +

+ )} +
+ {icon && ( +
+ {icon} +
+ )} +
+
+ ); +}; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..967b367 --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,16 @@ +export { Badge } from "./Badge"; +export { Button } from "./Button"; +export { ConfirmDialog } from "./ConfirmDialog"; +export { EmptyState } from "./EmptyState"; +export { Input } from "./Input"; +export { Modal } from "./Modal"; +export { PageHeader } from "./PageHeader"; +export { ProgressBar } from "./ProgressBar"; +export { Select } from "./Select"; +export { + CardSkeleton, + ListItemSkeleton, + Skeleton, + TableRowSkeleton, +} from "./Skeleton"; +export { StatCard } from "./StatCard"; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..475c801 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,3 @@ +export const API_HOST = import.meta.env.VITE_API_HOST; + +export const appRootId = "app" as const; diff --git a/src/constants/analytics.ts b/src/constants/analytics.ts new file mode 100644 index 0000000..3c9d86c --- /dev/null +++ b/src/constants/analytics.ts @@ -0,0 +1,12 @@ +import type { AnalyticsInitialState } from "../interfaces/analytics"; + +export const analytics = "analytics" as const; + +export const analyticsInitialState: AnalyticsInitialState = { + overview: null, + trends: [], + categoryBreakdown: [], + financialHealth: null, + isLoading: false, + error: undefined, +}; diff --git a/src/constants/auth.ts b/src/constants/auth.ts new file mode 100644 index 0000000..d1d19d3 --- /dev/null +++ b/src/constants/auth.ts @@ -0,0 +1,13 @@ +import { + AuthStage, + AuthStatus, + type AuthInitialState, +} from "../interfaces/auth"; + +export const auth = "auth" as const; + +export const authInitialState: AuthInitialState = { + authStatus: AuthStatus.None, + stage: AuthStage.Login, + error: undefined, +}; diff --git a/src/constants/budgets.ts b/src/constants/budgets.ts new file mode 100644 index 0000000..d966a5c --- /dev/null +++ b/src/constants/budgets.ts @@ -0,0 +1,11 @@ +import type { BudgetsInitialState } from "../interfaces/budgets"; + +export const budgets = "budgets" as const; + +export const budgetsInitialState: BudgetsInitialState = { + budgets: [], + currentBudget: null, + progress: null, + isLoading: false, + error: undefined, +}; diff --git a/src/constants/categories.ts b/src/constants/categories.ts new file mode 100644 index 0000000..bf8d317 --- /dev/null +++ b/src/constants/categories.ts @@ -0,0 +1,10 @@ +import type { CategoriesInitialState } from "../interfaces/categories"; + +export const categories = "categories" as const; + +export const categoriesInitialState: CategoriesInitialState = { + categories: [], + grouped: null, + isLoading: false, + error: undefined, +}; diff --git a/src/constants/goals.ts b/src/constants/goals.ts new file mode 100644 index 0000000..6f3c8b6 --- /dev/null +++ b/src/constants/goals.ts @@ -0,0 +1,11 @@ +import type { GoalsInitialState } from "../interfaces/goals"; + +export const goals = "goals" as const; + +export const goalsInitialState: GoalsInitialState = { + goals: [], + summary: null, + upcoming: [], + isLoading: false, + error: undefined, +}; diff --git a/src/constants/recommendations.ts b/src/constants/recommendations.ts new file mode 100644 index 0000000..3e05348 --- /dev/null +++ b/src/constants/recommendations.ts @@ -0,0 +1,10 @@ +import type { RecommendationsInitialState } from "../interfaces/recommendations"; + +export const recommendations = "recommendations" as const; + +export const recommendationsInitialState: RecommendationsInitialState = { + recommendations: [], + stats: null, + isLoading: false, + error: undefined, +}; diff --git a/src/constants/transactions.ts b/src/constants/transactions.ts new file mode 100644 index 0000000..ef5312a --- /dev/null +++ b/src/constants/transactions.ts @@ -0,0 +1,12 @@ +import type { TransactionsInitialState } from "../interfaces/transactions"; + +export const transactions = "transactions" as const; + +export const transactionsInitialState: TransactionsInitialState = { + transactions: [], + meta: null, + summary: null, + spendingByCategory: [], + isLoading: false, + error: undefined, +}; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/src/interfaces/analytics.ts b/src/interfaces/analytics.ts new file mode 100644 index 0000000..d2cb850 --- /dev/null +++ b/src/interfaces/analytics.ts @@ -0,0 +1,98 @@ +import type { TransactionType } from "./transactions"; + +export interface TopCategory { + categoryId: string; + categoryName: string; + amount: number; + percentage: number; +} + +export interface AnalyticsOverview { + month: string; + totalIncome: number; + totalExpenses: number; + netSavings: number; + savingsRate: number; + topCategories: TopCategory[]; +} + +export interface TrendDataPoint { + period: string; + amount: number; + change: number; + changePercent: number; +} + +export interface CategoryBreakdown { + categoryId: string; + categoryName: string; + categoryIcon: string; + categoryColor: string; + amount: number; + percentage: number; + transactionCount: number; + averageTransaction: number; +} + +export interface HealthFactor { + name: string; + score: number; + description: string; + recommendation?: string; +} + +export interface FinancialHealth { + score: number; + grade: "A" | "B" | "C" | "D" | "F"; + factors: HealthFactor[]; +} + +export interface BaseResponse { + success: boolean; + timestamp: string; +} + +export interface AnalyticsOverviewResponse extends BaseResponse { + data: AnalyticsOverview; +} + +export interface AnalyticsTrendsResponse extends BaseResponse { + data: TrendDataPoint[]; +} + +export interface AnalyticsCategoryBreakdownResponse extends BaseResponse { + data: CategoryBreakdown[]; +} + +export interface AnalyticsFinancialHealthResponse extends BaseResponse { + data: FinancialHealth; +} + +export interface AnalyticsInitialState { + overview: AnalyticsOverview | null; + trends: TrendDataPoint[]; + categoryBreakdown: CategoryBreakdown[]; + financialHealth: FinancialHealth | null; + isLoading: boolean; + error: string | undefined; +} + +export type AnalyticsOverviewApi = (params?: { + month?: string; +}) => Promise; +export type AnalyticsTrendsApi = (params?: { + months?: number; +}) => Promise; +export type AnalyticsCategoryBreakdownApi = (params: { + startDate: string; + endDate: string; + type?: TransactionType; +}) => Promise; +export type AnalyticsIncomeVsExpensesApi = (params?: { + months?: number; +}) => Promise; +export type AnalyticsFinancialHealthApi = + () => Promise; +export type AnalyticsYearlySummaryApi = (params?: { + year?: number; +}) => Promise; diff --git a/src/interfaces/auth.ts b/src/interfaces/auth.ts new file mode 100644 index 0000000..b27c91a --- /dev/null +++ b/src/interfaces/auth.ts @@ -0,0 +1,71 @@ +export enum AuthStatus { + None, + Authorized, + NotAuthorized, +} + +export enum AuthStage { + Login, +} + +export interface AuthInitialState { + authStatus: AuthStatus; + stage: AuthStage; + error: string | undefined; +} +export interface LoginBody { + email: string; + password: string; +} + +export interface RegisterBody { + email: string; + password: string; + firstName?: string; + lastName?: string; + phone?: string; +} +export interface User { + userId: string; + email: string; + firstName: string | null; + lastName: string | null; + tokens: Tokens; +} + +export interface Tokens { + accessToken: string; + expiresIn: number; +} + +export type AuthLoginApi = (body: LoginBody) => Promise; +export type AuthRegisterApi = (body: RegisterBody) => Promise; +export type ReApi = () => Promise; + +export interface UserProfile { + id: string; + email: string; + firstName?: string; + lastName?: string; + phone?: string; + monthlyIncome?: number; + timezone?: string; + language?: string; + createdAt: string; + updatedAt: string; +} + +export interface UpdateProfilePayload { + firstName?: string; + lastName?: string; + phone?: string; + monthlyIncome?: number; + timezone?: string; + language?: string; +} + +export interface ChangePasswordPayload { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} diff --git a/src/interfaces/budgets.ts b/src/interfaces/budgets.ts new file mode 100644 index 0000000..9d65a3e --- /dev/null +++ b/src/interfaces/budgets.ts @@ -0,0 +1,78 @@ +export interface Budget { + id: string; + month: string; + totalIncome: number; + essentialsLimit: number; + personalLimit: number; + savingsLimit: number; + essentialsSpent?: number; + personalSpent?: number; + savingsSpent?: number; +} + +export interface CreateBudgetBody { + month: string; + totalIncome: number; + essentialsPercent?: number; + personalPercent?: number; + savingsPercent?: number; +} + +export type UpdateBudgetBody = Partial>; + +export interface BudgetProgress { + limit: number; + spent: number; + percent: number; +} + +export interface BudgetProgressResponse { + month: string; + essentials: BudgetProgress; + personal: BudgetProgress; + savings: BudgetProgress; +} + +export interface BaseResponse { + success: boolean; + timestamp: string; +} + +export interface BudgetsListResponse extends BaseResponse { + data: Budget[]; +} + +export interface BudgetResponse extends BaseResponse { + data: Budget; +} + +export interface BudgetProgressApiResponse extends BaseResponse { + data: BudgetProgressResponse; +} + +export interface BudgetsInitialState { + budgets: Budget[]; + currentBudget: Budget | null; + progress: BudgetProgressResponse | null; + isLoading: boolean; + error: string | undefined; +} + +export interface BudgetsUpdateArgs { + month: string; + body: UpdateBudgetBody; +} + +export type BudgetsListApi = () => Promise; +export type BudgetsGetCurrentApi = () => Promise; +export type BudgetsGetByMonthApi = (month: string) => Promise; +export type BudgetsCreateApi = ( + body: CreateBudgetBody, +) => Promise; +export type BudgetsUpdateApi = ( + args: BudgetsUpdateArgs, +) => Promise; +export type BudgetsDeleteApi = (month: string) => Promise; +export type BudgetsProgressApi = ( + month: string, +) => Promise; diff --git a/src/interfaces/categories.ts b/src/interfaces/categories.ts new file mode 100644 index 0000000..c44873c --- /dev/null +++ b/src/interfaces/categories.ts @@ -0,0 +1,77 @@ +export type CategoryType = "INCOME" | "EXPENSE"; +export type CategoryGroupType = "ESSENTIAL" | "PERSONAL" | "SAVINGS"; + +export interface Category { + id: string; + nameRu: string; + nameEn?: string; + type: CategoryType; + groupType?: CategoryGroupType; + icon?: string; + color?: string; + parentId?: string; + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateCategoryBody { + nameRu: string; + nameEn?: string; + type: CategoryType; + groupType?: CategoryGroupType; + icon?: string; + color?: string; + parentId?: string; +} + +export type UpdateCategoryBody = Partial; + +export interface GroupedCategories { + ESSENTIAL: Category[]; + PERSONAL: Category[]; + SAVINGS: Category[]; +} + +export interface CategoriesInitialState { + categories: Category[]; + grouped: GroupedCategories | null; + isLoading: boolean; + error: string | undefined; +} + +export interface CategoriesUpdateArgs { + id: string; + body: UpdateCategoryBody; +} + +export interface BaseResponse { + success: boolean; + timestamp: string; +} + +export interface CategoriesListResponse extends BaseResponse { + data: Category[]; +} + +export interface CategoriesGetGroupedResponse extends BaseResponse { + data: GroupedCategories; +} + +export interface CategoryResponse extends BaseResponse { + data: Category; +} + +export type CategoriesListApi = (params?: { + type?: CategoryType; +}) => Promise; +export type CategoriesGetApi = (id: string) => Promise; +export type CategoriesGetGroupedApi = + () => Promise; +export type CategoriesCreateApi = ( + body: CreateCategoryBody, +) => Promise; +export type CategoriesUpdateApi = ( + args: CategoriesUpdateArgs, +) => Promise; +export type CategoriesDeleteApi = (id: string) => Promise; diff --git a/src/interfaces/goals.ts b/src/interfaces/goals.ts new file mode 100644 index 0000000..d8e55e7 --- /dev/null +++ b/src/interfaces/goals.ts @@ -0,0 +1,110 @@ +export type GoalStatus = "ACTIVE" | "COMPLETED" | "PAUSED" | "CANCELLED"; +export type GoalPriority = "LOW" | "MEDIUM" | "HIGH"; +export type AutoSaveFrequency = "DAILY" | "WEEKLY" | "MONTHLY"; + +export interface Goal { + id: string; + titleRu: string; + descriptionRu?: string; + targetAmount: number; + currentAmount: number; + targetDate?: string; + status: GoalStatus; + priority: GoalPriority; + icon?: string; + color?: string; + autoSaveEnabled: boolean; + autoSaveAmount?: number; + autoSaveFrequency?: AutoSaveFrequency; + createdAt: string; + updatedAt: string; +} + +export interface CreateGoalBody { + titleRu: string; + descriptionRu?: string; + targetAmount: number; + currentAmount?: number; + targetDate?: string; + priority?: GoalPriority; + icon?: string; + color?: string; + autoSaveEnabled?: boolean; + autoSaveAmount?: number; + autoSaveFrequency?: AutoSaveFrequency; +} + +export interface UpdateGoalBody { + titleRu?: string; + descriptionRu?: string; + targetAmount?: number; + targetDate?: string; + status?: GoalStatus; + priority?: GoalPriority; + icon?: string; + color?: string; + autoSaveEnabled?: boolean; + autoSaveAmount?: number; + autoSaveFrequency?: AutoSaveFrequency; +} + +export interface GoalFundsBody { + amount: number; + note?: string; +} + +export interface GoalsSummary { + totalGoals: number; + activeGoals: number; + completedGoals: number; + totalTargetAmount: number; + totalCurrentAmount: number; + overallProgress: number; +} + +export interface BaseResponse { + success: boolean; + timestamp: string; +} + +export interface GoalsListResponse extends BaseResponse { + data: Goal[]; +} + +export interface GoalResponse extends BaseResponse { + data: Goal; +} + +export interface GoalsSummaryResponse extends BaseResponse { + data: GoalsSummary; +} + +export interface GoalsInitialState { + goals: Goal[]; + summary: GoalsSummary | null; + upcoming: Goal[]; + isLoading: boolean; + error: string | undefined; +} + +export interface GoalsUpdateArgs { + id: string; + body: UpdateGoalBody; +} + +export interface GoalsFundsArgs { + id: string; + body: GoalFundsBody; +} + +export type GoalsListApi = (params?: { + status?: GoalStatus; +}) => Promise; +export type GoalsGetApi = (id: string) => Promise; +export type GoalsCreateApi = (body: CreateGoalBody) => Promise; +export type GoalsUpdateApi = (args: GoalsUpdateArgs) => Promise; +export type GoalsDeleteApi = (id: string) => Promise; +export type GoalsAddFundsApi = (args: GoalsFundsArgs) => Promise; +export type GoalsWithdrawApi = (args: GoalsFundsArgs) => Promise; +export type GoalsSummaryApi = () => Promise; +export type GoalsUpcomingApi = (days?: number) => Promise; diff --git a/src/interfaces/recommendations.ts b/src/interfaces/recommendations.ts new file mode 100644 index 0000000..822beb6 --- /dev/null +++ b/src/interfaces/recommendations.ts @@ -0,0 +1,83 @@ +export type RecommendationType = + | "SAVING" + | "SPENDING" + | "INVESTMENT" + | "TAX" + | "DEBT" + | "BUDGET" + | "GOAL"; + +export type RecommendationStatus = "NEW" | "VIEWED" | "APPLIED" | "DISMISSED"; + +export interface Recommendation { + id: string; + userId: string; + type: RecommendationType; + titleRu: string; + descriptionRu: string; + priorityScore: number; + confidenceScore: number; + source: string; + status: RecommendationStatus; + actionData?: Record; + expiresAt?: string; + createdAt: string; + viewedAt?: string; + appliedAt?: string; +} + +export interface RecommendationStats { + total: number; + new: number; + viewed: number; + applied: number; + dismissed: number; +} + +export interface RecommendationsInitialState { + recommendations: Recommendation[]; + stats: RecommendationStats | null; + isLoading: boolean; + error: string | undefined; +} + +export interface BaseResponse { + success: boolean; + timestamp: string; +} + +export interface RecommendationListResponse extends BaseResponse { + data: Recommendation[]; +} + +export interface RecommendationResponse extends BaseResponse { + data: Recommendation; +} + +export interface RecommendationStatsResponse extends BaseResponse { + data: RecommendationStats; +} + +export type RecommendationsListApi = (params?: { + type?: RecommendationType; +}) => Promise; + +export type RecommendationsGetApi = ( + id: string, +) => Promise; + +export type RecommendationsGenerateApi = + () => Promise; + +export type RecommendationsMarkViewedApi = ( + id: string, +) => Promise; + +export type RecommendationsApplyApi = ( + id: string, +) => Promise; +export type RecommendationsDismissApi = ( + id: string, +) => Promise; +export type RecommendationsStatsApi = + () => Promise; diff --git a/src/interfaces/transactions.ts b/src/interfaces/transactions.ts new file mode 100644 index 0000000..1563eb6 --- /dev/null +++ b/src/interfaces/transactions.ts @@ -0,0 +1,127 @@ +import type { Category } from "./categories"; + +export type TransactionType = "INCOME" | "EXPENSE"; +export type PaymentMethod = "CASH" | "CARD" | "BANK_TRANSFER"; +export type TransactionSort = + | "date_asc" + | "date_desc" + | "amount_asc" + | "amount_desc"; + +export interface Transaction { + id: string; + amount: number; + type: TransactionType; + categoryId: string; + category?: Category; + transactionDate: string; + description?: string; + paymentMethod?: PaymentMethod; + receiptUrl?: string; + isPlanned?: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateTransactionBody { + amount: number; + type: TransactionType; + categoryId: string; + transactionDate: string; + description?: string; + paymentMethod?: PaymentMethod; + receiptUrl?: string; + isPlanned?: boolean; +} + +export type UpdateTransactionBody = Partial; + +export interface TransactionFilters { + page?: number; + limit?: number; + type?: TransactionType; + categoryId?: string; + paymentMethod?: PaymentMethod; + startDate?: string; + endDate?: string; + search?: string; + sort?: TransactionSort; +} + +export interface TransactionSummary { + totalIncome: number; + totalExpense: number; + balance: number; +} + +export interface TransactionSummaryResponse extends BaseResponse { + data: TransactionSummary; +} + +export interface SpendingByCategory { + categoryId: string; + categoryName: string; + total: number; + percentage: number; +} + +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export interface BaseResponse { + success: boolean; + timestamp: string; +} + +export interface PaginatedTransactions extends BaseResponse { + data: { data: Transaction[]; meta: PaginationMeta }; +} + +export interface TransactionResponse extends BaseResponse { + data: Transaction; +} + +export interface TransactionsInitialState { + transactions: Transaction[]; + meta: PaginationMeta | null; + summary: TransactionSummary | null; + spendingByCategory: SpendingByCategory[]; + isLoading: boolean; + error: string | undefined; +} + +export interface TransactionsUpdateArgs { + id: string; + body: UpdateTransactionBody; +} + +export type TransactionsListApi = ( + filters?: TransactionFilters, +) => Promise; +export type TransactionsGetApi = (id: string) => Promise; +export type TransactionsCreateApi = ( + body: CreateTransactionBody, +) => Promise; +export type TransactionsUpdateApi = ( + args: TransactionsUpdateArgs, +) => Promise; +export type TransactionsDeleteApi = (id: string) => Promise; +export type TransactionsBulkCreateApi = ( + transactions: CreateTransactionBody[], +) => Promise; +export type TransactionsSummaryApi = (params: { + startDate: string; + endDate: string; +}) => Promise; +export interface SpendingByCategoryResponse extends BaseResponse { + data: SpendingByCategory[]; +} + +export type TransactionsByCategoryApi = (params: { + startDate: string; + endDate: string; +}) => Promise; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..dd63ca0 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,16 @@ +import { Provider as StoreProvider } from "react-redux"; +import { RouterProvider } from "react-router/dom"; +import { browserRouter } from "./browser-router"; +import { createRoot } from "react-dom/client"; +import { asyncStore } from "./store"; +import { Suspense } from "react"; +import "./index.css"; +import { LoadingContentComponent } from "./components/loading-component"; + +createRoot(document.getElementById("root")!).render( + + }> + + + , +); diff --git a/src/pages/dashboard.tsx b/src/pages/dashboard.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/dashboard/analytics.tsx b/src/pages/dashboard/analytics.tsx new file mode 100644 index 0000000..10d9cb7 --- /dev/null +++ b/src/pages/dashboard/analytics.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState } from "react"; +import { + PageHeader, + StatCard, + ProgressBar, + CardSkeleton, + EmptyState, + Select, +} from "../../components/ui"; +import { useAppDispatch, useAppSelector } from "../../store/hooks"; +import { + analyticsOverviewSelector, + analyticsTrendsSelector, + analyticsCategoryBreakdownSelector, + analyticsFinancialHealthSelector, + analyticsLoadingSelector, +} from "../../store/analytics/selectors"; +import { + analyticsOverview, + analyticsTrends, + analyticsCategoryBreakdown, + analyticsFinancialHealth, +} from "../../store/analytics/async-thunks"; + +const formatMoney = (amount: number): string => { + return new Intl.NumberFormat("ru-RU", { + style: "currency", + currency: "RUB", + maximumFractionDigits: 0, + }).format(amount); +}; + +const gradeColors: Record = { + A: "text-emerald-600 bg-emerald-100", + B: "text-sky-600 bg-sky-100", + C: "text-amber-600 bg-amber-100", + D: "text-orange-600 bg-orange-100", + F: "text-rose-600 bg-rose-100", +}; + +const AnalyticsPage = () => { + const dispatch = useAppDispatch(); + const overview = useAppSelector(analyticsOverviewSelector); + const trends = useAppSelector(analyticsTrendsSelector); + const categoryBreakdown = useAppSelector(analyticsCategoryBreakdownSelector); + const health = useAppSelector(analyticsFinancialHealthSelector); + const isLoading = useAppSelector(analyticsLoadingSelector); + + const [period, setPeriod] = useState("6"); + + useEffect(() => { + const now = new Date(); + const startDate = new Date( + now.getFullYear(), + now.getMonth() - parseInt(period), + 1, + ).toISOString(); + const endDate = now.toISOString(); + + dispatch(analyticsOverview()); + dispatch(analyticsTrends({ months: parseInt(period) })); + dispatch( + analyticsCategoryBreakdown({ startDate, endDate, type: "EXPENSE" }), + ); + dispatch(analyticsFinancialHealth()); + }, [dispatch, period]); + + const periodOptions = [ + { value: "3", label: "3 месяца" }, + { value: "6", label: "6 месяцев" }, + { value: "12", label: "12 месяцев" }, + ]; + + const maxTrendValue = Math.max(...trends.map((t) => t.amount), 1); + + if (isLoading) { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ); + } + + return ( +
+ setPeriod(e.target.value)} + className="w-36" + /> + } + /> + + {/* Overview Stats */} +
+ + + + +
+ +
+ {/* Trends Chart */} +
+

Тренды

+ {trends.length > 0 ? ( +
+ {trends.map((point) => ( +
+
+ {point.period} +
+ + {formatMoney(point.amount)} + + {point.changePercent !== 0 && ( + = 0 + ? "text-emerald-600" + : "text-rose-600" + } + > + {point.change >= 0 ? "+" : ""} + {point.changePercent.toFixed(1)}% + + )} +
+
+
+
+
+
+ ))} +
+ ) : ( + + )} +
+ + {/* Category Breakdown */} +
+

+ Расходы по категориям +

+ {categoryBreakdown.length > 0 ? ( +
+ {categoryBreakdown.slice(0, 8).map((cat) => ( +
+
+ + {cat.categoryName} + + + {formatMoney(cat.amount)} ({cat.percentage.toFixed(1)}%) + +
+ +
+ ))} +
+ ) : ( + + )} +
+ + {/* Financial Health */} +
+

+ Финансовое здоровье +

+ {health ? ( +
+
+
+ {health.grade} +
+
+

+ {health.score}/100 +

+

Общий балл

+
+
+
+ {health.factors.map((factor, i) => ( +
+
+ {factor.name} + {factor.score}% +
+ +

+ {factor.description} +

+ {factor.recommendation && ( +

+ {factor.recommendation} +

+ )} +
+ ))} +
+
+ ) : ( + + )} +
+
+
+ ); +}; + +export default AnalyticsPage; diff --git a/src/pages/dashboard/budgets.tsx b/src/pages/dashboard/budgets.tsx new file mode 100644 index 0000000..63c5e27 --- /dev/null +++ b/src/pages/dashboard/budgets.tsx @@ -0,0 +1,454 @@ +import { useEffect, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + PageHeader, + Button, + EmptyState, + ProgressBar, + Modal, + Input, + CardSkeleton, +} from "../../components/ui"; +import { useAppDispatch, useAppSelector } from "../../store/hooks"; +import { + budgetsCurrentSelector, + budgetsProgressSelector, + budgetsLoadingSelector, +} from "../../store/budgets/selectors"; +import { + budgetsGetByMonth, + budgetsCreate, + budgetsUpdate, + budgetsProgress, +} from "../../store/budgets/async-thunks"; +import { parseApiError } from "../../api/parse-api-error"; + +const schema = z.object({ + totalIncome: z.number().positive("Введите сумму дохода"), + essentialsPercent: z.number().min(0).max(100), + personalPercent: z.number().min(0).max(100), + savingsPercent: z.number().min(0).max(100), +}); + +type FormValues = z.infer; + +const formatMoney = (amount: number): string => { + return new Intl.NumberFormat("ru-RU", { + style: "currency", + currency: "RUB", + maximumFractionDigits: 0, + }).format(amount); +}; + +const getMonthOptions = () => { + const options = []; + const now = new Date(); + for (let i = -6; i <= 6; i++) { + const date = new Date(now.getFullYear(), now.getMonth() + i, 1); + options.push({ + value: date.toISOString().split("T")[0], + label: date.toLocaleDateString("ru-RU", { + month: "long", + year: "numeric", + }), + }); + } + return options; +}; + +const BudgetsPage = () => { + const dispatch = useAppDispatch(); + const budget = useAppSelector(budgetsCurrentSelector); + const progress = useAppSelector(budgetsProgressSelector); + const isLoading = useAppSelector(budgetsLoadingSelector); + + const [selectedMonth, setSelectedMonth] = useState( + new Date(new Date().getFullYear(), new Date().getMonth(), 1) + .toISOString() + .split("T")[0], + ); + const [modalOpen, setModalOpen] = useState(false); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const { + register, + handleSubmit, + reset, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + totalIncome: 0, + essentialsPercent: 50, + personalPercent: 30, + savingsPercent: 20, + }, + }); + + const watchedValues = watch(); + const totalPercent = + (watchedValues.essentialsPercent || 0) + + (watchedValues.personalPercent || 0) + + (watchedValues.savingsPercent || 0); + + useEffect(() => { + dispatch(budgetsGetByMonth(selectedMonth)); + dispatch(budgetsProgress(selectedMonth)); + }, [dispatch, selectedMonth]); + + useEffect(() => { + if (budget) { + // Calculate percentages from limits + const essentialsPercent = + budget.totalIncome > 0 + ? Math.round((budget.essentialsLimit / budget.totalIncome) * 100) + : 50; + const personalPercent = + budget.totalIncome > 0 + ? Math.round((budget.personalLimit / budget.totalIncome) * 100) + : 30; + const savingsPercent = + budget.totalIncome > 0 + ? Math.round((budget.savingsLimit / budget.totalIncome) * 100) + : 20; + reset({ + totalIncome: budget.totalIncome, + essentialsPercent, + personalPercent, + savingsPercent, + }); + } else { + reset({ + totalIncome: 0, + essentialsPercent: 50, + personalPercent: 30, + savingsPercent: 20, + }); + } + }, [budget, reset]); + + const onSubmit = async (values: FormValues) => { + if (totalPercent !== 100) { + setError("Сумма процентов должна быть равна 100%"); + return; + } + + setIsSaving(true); + setError(null); + + try { + if (budget) { + await dispatch( + budgetsUpdate({ month: selectedMonth, body: values }), + ).unwrap(); + } else { + await dispatch( + budgetsCreate({ month: selectedMonth, ...values }), + ).unwrap(); + } + setModalOpen(false); + dispatch(budgetsGetByMonth(selectedMonth)); + dispatch(budgetsProgress(selectedMonth)); + } catch (err) { + const parsed = parseApiError(err); + setError(parsed.messages[0] || "Произошла ошибка"); + } finally { + setIsSaving(false); + } + }; + + const getProgressColor = ( + percentage: number, + ): "success" | "warning" | "danger" => { + if (percentage <= 80) return "success"; + if (percentage <= 100) return "warning"; + return "danger"; + }; + + const monthOptions = getMonthOptions(); + + return ( +
+ + + +
+ } + /> + + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : !budget ? ( + setModalOpen(true)}>Создать бюджет + } + /> + ) : ( +
+ {/* Summary */} +
+
+
+

+ Общий доход +

+

+ {formatMoney(budget.totalIncome)} +

+
+ {progress && ( +
+

Потрачено

+

+ {formatMoney( + progress.essentials.spent + + progress.personal.spent + + progress.savings.spent, + )} +

+
+ )} +
+ {progress && ( + + )} +
+ + {/* Categories */} +
+ {/* Essentials */} +
+
+
+

Необходимое

+

+ {budget.totalIncome > 0 + ? Math.round( + (budget.essentialsLimit / budget.totalIncome) * 100, + ) + : 50} + % от дохода +

+
+
+ + + +
+
+

+ {formatMoney( + progress?.essentials.limit ?? budget.essentialsLimit, + )} +

+ {progress && ( + <> + +

+ Потрачено: {formatMoney(progress.essentials.spent)} +

+ + )} +
+ + {/* Personal */} +
+
+
+

Желания

+

+ {budget.totalIncome > 0 + ? Math.round( + (budget.personalLimit / budget.totalIncome) * 100, + ) + : 30} + % от дохода +

+
+
+ + + +
+
+

+ {formatMoney(progress?.personal.limit ?? budget.personalLimit)} +

+ {progress && ( + <> + +

+ Потрачено: {formatMoney(progress.personal.spent)} +

+ + )} +
+ + {/* Savings */} +
+
+
+

Сбережения

+

+ {budget.totalIncome > 0 + ? Math.round( + (budget.savingsLimit / budget.totalIncome) * 100, + ) + : 20} + % от дохода +

+
+
+ + + +
+
+

+ {formatMoney(progress?.savings.limit ?? budget.savingsLimit)} +

+ {progress && ( + <> + +

+ Отложено: {formatMoney(progress.savings.spent)} +

+ + )} +
+
+
+ )} + + setModalOpen(false)} + title={budget ? "Редактировать бюджет" : "Создать бюджет"} + > + {error && ( +
+ {error} +
+ )} + + +
+ + + +
+

+ Сумма: {totalPercent}%{" "} + {totalPercent !== 100 && "(должно быть 100%)"} +

+
+ + +
+ +
+
+ ); +}; + +export default BudgetsPage; diff --git a/src/pages/dashboard/categories.tsx b/src/pages/dashboard/categories.tsx new file mode 100644 index 0000000..5642e9a --- /dev/null +++ b/src/pages/dashboard/categories.tsx @@ -0,0 +1,358 @@ +import { useEffect, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + PageHeader, + Button, + EmptyState, + Badge, + ConfirmDialog, + Modal, + Input, + Select, + CardSkeleton, +} from "../../components/ui"; +import { useAppDispatch, useAppSelector } from "../../store/hooks"; +import { + categoriesSelector, + categoriesGroupedSelector, + categoriesLoadingSelector, +} from "../../store/categories/selectors"; +import { + categoriesList, + categoriesGetGrouped, + categoriesCreate, + categoriesUpdate, + categoriesDelete, +} from "../../store/categories/async-thunks"; +import { parseApiError } from "../../api/parse-api-error"; +import type { Category, CreateCategoryBody } from "../../interfaces/categories"; + +const schema = z.object({ + nameRu: z.string().min(1, "Введите название"), + type: z.enum(["INCOME", "EXPENSE"] as const), + groupType: z.enum(["ESSENTIAL", "PERSONAL", "SAVINGS"] as const).optional(), + icon: z.string().optional(), + color: z.string().optional(), +}); + +type FormValues = z.infer; + +const groupLabels: Record = { + ESSENTIAL: "Необходимое (50%)", + PERSONAL: "Желания (30%)", + SAVINGS: "Сбережения (20%)", +}; + +const CategoriesPage = () => { + const dispatch = useAppDispatch(); + const categories = useAppSelector(categoriesSelector); + const grouped = useAppSelector(categoriesGroupedSelector); + const isLoading = useAppSelector(categoriesLoadingSelector); + + const [modalOpen, setModalOpen] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + const [deleteId, setDeleteId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [viewMode, setViewMode] = useState<"list" | "grouped">("grouped"); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + nameRu: "", + type: "EXPENSE", + groupType: undefined, + }, + }); + + useEffect(() => { + dispatch(categoriesList()); + dispatch(categoriesGetGrouped()); + }, [dispatch]); + + const handleEdit = (category: Category) => { + setEditingCategory(category); + reset({ + nameRu: category.nameRu, + type: category.type, + groupType: category.groupType, + icon: category.icon || "", + color: category.color || "", + }); + setModalOpen(true); + }; + + const handleCreate = () => { + setEditingCategory(null); + reset({ + nameRu: "", + type: "EXPENSE", + groupType: undefined, + }); + setModalOpen(true); + }; + + const handleDelete = async () => { + if (!deleteId) return; + setIsDeleting(true); + try { + await dispatch(categoriesDelete(deleteId)).unwrap(); + dispatch(categoriesList()); + dispatch(categoriesGetGrouped()); + } catch { + // Handle error + } finally { + setIsDeleting(false); + setDeleteId(null); + } + }; + + const onSubmit = async (values: FormValues) => { + setIsSaving(true); + setError(null); + + try { + const payload: CreateCategoryBody = { + ...values, + groupType: values.groupType || undefined, + }; + + if (editingCategory) { + await dispatch( + categoriesUpdate({ id: editingCategory.id, body: payload }), + ).unwrap(); + } else { + await dispatch(categoriesCreate(payload)).unwrap(); + } + setModalOpen(false); + dispatch(categoriesList()); + dispatch(categoriesGetGrouped()); + } catch (err) { + const parsed = parseApiError(err); + setError(parsed.messages[0] || "Произошла ошибка"); + } finally { + setIsSaving(false); + } + }; + + const typeOptions = [ + { value: "EXPENSE", label: "Расход" }, + { value: "INCOME", label: "Доход" }, + ]; + + const groupOptions = [ + { value: "", label: "Не указана" }, + { value: "ESSENTIAL", label: "Необходимое (50%)" }, + { value: "PERSONAL", label: "Желания (30%)" }, + { value: "SAVINGS", label: "Сбережения (20%)" }, + ]; + + const renderCategoryCard = (category: Category) => ( +
+
+
+ {category.icon || "📁"} +
+
+

{category.nameRu}

+
+ + {category.type === "INCOME" ? "Доход" : "Расход"} + + {category.isDefault && ( + + По умолчанию + + )} +
+
+
+ {!category.isDefault && ( +
+ + +
+ )} +
+ ); + + return ( +
+ +
+ + +
+ +
+ } + /> + + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : viewMode === "grouped" && grouped ? ( +
+ {(["ESSENTIAL", "PERSONAL", "SAVINGS"] as const).map((group) => ( +
+

+ {groupLabels[group]} +

+ {grouped[group].length > 0 ? ( +
+ {grouped[group].map(renderCategoryCard)} +
+ ) : ( +

+ Нет категорий в этой группе +

+ )} +
+ ))} +
+ ) : categories.length === 0 ? ( + Создать категорию} + /> + ) : ( +
+ {categories.map(renderCategoryCard)} +
+ )} + + setModalOpen(false)} + title={editingCategory ? "Редактировать категорию" : "Новая категория"} + > + {error && ( +
+ {error} +
+ )} +
+ + +
+ + +
+
+
+ + setDeleteId(null)} + onConfirm={handleDelete} + title="Удалить категорию?" + message="Транзакции с этой категорией останутся без категории." + confirmText="Удалить" + variant="danger" + isLoading={isDeleting} + /> +
+ ); +}; + +export default CategoriesPage; diff --git a/src/pages/dashboard/goals.tsx b/src/pages/dashboard/goals.tsx new file mode 100644 index 0000000..a73eb38 --- /dev/null +++ b/src/pages/dashboard/goals.tsx @@ -0,0 +1,534 @@ +import { useEffect, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + PageHeader, + Button, + EmptyState, + Badge, + ProgressBar, + ConfirmDialog, + Modal, + Input, + Select, + CardSkeleton, +} from "../../components/ui"; +import { useAppDispatch, useAppSelector } from "../../store/hooks"; +import { + goalsSelector, + goalsUpcomingSelector, + goalsLoadingSelector, +} from "../../store/goals/selectors"; +import { + goalsList, + goalsCreate, + goalsUpdate, + goalsDelete, + goalsAddFunds, + goalsWithdraw, + goalsUpcoming, +} from "../../store/goals/async-thunks"; +import { parseApiError } from "../../api/parse-api-error"; +import type { Goal, GoalStatus, CreateGoalBody } from "../../interfaces/goals"; + +const schema = z.object({ + titleRu: z.string().min(1, "Введите название"), + descriptionRu: z.string().optional(), + targetAmount: z.number().positive("Введите целевую сумму"), + currentAmount: z.number().min(0).optional(), + targetDate: z.string().optional(), + priority: z.enum(["LOW", "MEDIUM", "HIGH"] as const), +}); + +type FormValues = z.infer; + +const fundsSchema = z.object({ + amount: z.number().positive("Введите сумму"), + note: z.string().optional(), +}); + +type FundsFormValues = z.infer; + +const formatMoney = (amount: number): string => { + return new Intl.NumberFormat("ru-RU", { + style: "currency", + currency: "RUB", + maximumFractionDigits: 0, + }).format(amount); +}; + +const statusLabels: Record = { + ACTIVE: "Активная", + COMPLETED: "Достигнута", + PAUSED: "Приостановлена", + CANCELLED: "Отменена", +}; + +const statusVariants: Record< + GoalStatus, + "success" | "warning" | "danger" | "default" +> = { + ACTIVE: "success", + COMPLETED: "success", + PAUSED: "warning", + CANCELLED: "danger", +}; + +const GoalsPage = () => { + const dispatch = useAppDispatch(); + const goals = useAppSelector(goalsSelector); + const upcoming = useAppSelector(goalsUpcomingSelector); + const isLoading = useAppSelector(goalsLoadingSelector); + + const [statusFilter, setStatusFilter] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [fundsModalOpen, setFundsModalOpen] = useState(false); + const [fundsMode, setFundsMode] = useState<"add" | "withdraw">("add"); + const [selectedGoal, setSelectedGoal] = useState(null); + const [deleteId, setDeleteId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + titleRu: "", + descriptionRu: "", + targetAmount: 0, + currentAmount: 0, + targetDate: "", + priority: "MEDIUM", + }, + }); + + const { + register: registerFunds, + handleSubmit: handleSubmitFunds, + reset: resetFunds, + formState: { errors: fundsErrors }, + } = useForm({ + resolver: zodResolver(fundsSchema), + defaultValues: { amount: 0, note: "" }, + }); + + useEffect(() => { + dispatch(goalsList(statusFilter ? { status: statusFilter } : undefined)); + dispatch(goalsUpcoming(30)); + }, [dispatch, statusFilter]); + + const handleCreate = () => { + setSelectedGoal(null); + reset({ + titleRu: "", + descriptionRu: "", + targetAmount: 0, + currentAmount: 0, + targetDate: "", + priority: "MEDIUM", + }); + setModalOpen(true); + }; + + const handleEdit = (goal: Goal) => { + setSelectedGoal(goal); + reset({ + titleRu: goal.titleRu, + descriptionRu: goal.descriptionRu || "", + targetAmount: goal.targetAmount, + currentAmount: goal.currentAmount, + targetDate: goal.targetDate?.split("T")[0] || "", + priority: goal.priority, + }); + setModalOpen(true); + }; + + const handleAddFunds = (goal: Goal) => { + setSelectedGoal(goal); + setFundsMode("add"); + resetFunds({ amount: 0, note: "" }); + setFundsModalOpen(true); + }; + + const handleWithdraw = (goal: Goal) => { + setSelectedGoal(goal); + setFundsMode("withdraw"); + resetFunds({ amount: 0, note: "" }); + setFundsModalOpen(true); + }; + + const handleDelete = async () => { + if (!deleteId) return; + setIsDeleting(true); + try { + await dispatch(goalsDelete(deleteId)).unwrap(); + dispatch(goalsList(statusFilter ? { status: statusFilter } : undefined)); + } catch { + // Handle error + } finally { + setIsDeleting(false); + setDeleteId(null); + } + }; + + const onSubmit = async (values: FormValues) => { + setIsSaving(true); + setError(null); + + try { + const payload: CreateGoalBody = { + ...values, + targetDate: values.targetDate + ? new Date(values.targetDate).toISOString() + : undefined, + }; + + if (selectedGoal) { + await dispatch( + goalsUpdate({ id: selectedGoal.id, body: payload }), + ).unwrap(); + } else { + await dispatch(goalsCreate(payload)).unwrap(); + } + setModalOpen(false); + dispatch(goalsList(statusFilter ? { status: statusFilter } : undefined)); + } catch (err) { + const parsed = parseApiError(err); + setError(parsed.messages[0] || "Произошла ошибка"); + } finally { + setIsSaving(false); + } + }; + + const onSubmitFunds = async (values: FundsFormValues) => { + if (!selectedGoal) return; + setIsSaving(true); + setError(null); + + try { + if (fundsMode === "add") { + await dispatch( + goalsAddFunds({ id: selectedGoal.id, body: values }), + ).unwrap(); + } else { + await dispatch( + goalsWithdraw({ id: selectedGoal.id, body: values }), + ).unwrap(); + } + setFundsModalOpen(false); + dispatch(goalsList(statusFilter ? { status: statusFilter } : undefined)); + } catch (err) { + const parsed = parseApiError(err); + setError(parsed.messages[0] || "Произошла ошибка"); + } finally { + setIsSaving(false); + } + }; + + const statusOptions = [ + { value: "", label: "Все цели" }, + { value: "ACTIVE", label: "Активные" }, + { value: "COMPLETED", label: "Достигнутые" }, + { value: "PAUSED", label: "Приостановленные" }, + { value: "CANCELLED", label: "Отменённые" }, + ]; + + const priorityOptions = [ + { value: "LOW", label: "Низкий" }, + { value: "MEDIUM", label: "Средний" }, + { value: "HIGH", label: "Высокий" }, + ]; + + return ( +
+ + + +
+ + +
+
+ + + +
+ + +
+ + + + setDeleteId(null)} + onConfirm={handleDelete} + title="Удалить цель?" + message="Это действие нельзя отменить." + confirmText="Удалить" + variant="danger" + isLoading={isDeleting} + /> +
+ ); +}; + +export default GoalsPage; diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx new file mode 100644 index 0000000..c76528a --- /dev/null +++ b/src/pages/dashboard/index.tsx @@ -0,0 +1,340 @@ +import { useEffect } from "react"; +import { + PageHeader, + StatCard, + ProgressBar, + CardSkeleton, + EmptyState, +} from "../../components/ui"; +import { useAppDispatch, useAppSelector } from "../../store/hooks"; +import { + analyticsOverviewSelector, + analyticsLoadingSelector, +} from "../../store/analytics/selectors"; +import { analyticsOverview } from "../../store/analytics/async-thunks"; +import { + budgetsCurrentSelector, + budgetsProgressSelector, +} from "../../store/budgets/selectors"; +import { + budgetsGetCurrent, + budgetsProgress, +} from "../../store/budgets/async-thunks"; +import { goalsSummarySelector } from "../../store/goals/selectors"; +import { goalsSummary } from "../../store/goals/async-thunks"; +import { recommendationsStatsSelector } from "../../store/recommendations/selectors"; +import { recommendationsStats } from "../../store/recommendations/async-thunks"; + +const formatMoney = (amount: number): string => { + return new Intl.NumberFormat("ru-RU", { + style: "currency", + currency: "RUB", + maximumFractionDigits: 0, + }).format(amount); +}; + +const DashboardPage = () => { + const dispatch = useAppDispatch(); + const overview = useAppSelector(analyticsOverviewSelector); + const isLoading = useAppSelector(analyticsLoadingSelector); + const budget = useAppSelector(budgetsCurrentSelector); + const budgetProgress = useAppSelector(budgetsProgressSelector); + const goals = useAppSelector(goalsSummarySelector); + const recommendations = useAppSelector(recommendationsStatsSelector); + + useEffect(() => { + dispatch(analyticsOverview()); + dispatch(budgetsGetCurrent()); + dispatch(goalsSummary()); + dispatch(recommendationsStats()); + }, [dispatch]); + + useEffect(() => { + if (budget?.month) { + dispatch(budgetsProgress(budget.month)); + } + }, [dispatch, budget?.month]); + + const getProgressColor = ( + percentage: number, + ): "success" | "warning" | "danger" => { + if (percentage <= 80) return "success"; + if (percentage <= 100) return "warning"; + return "danger"; + }; + + if (isLoading) { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ); + } + + return ( +
+ + + {/* Main Stats */} +
+ + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> +
+ + {/* Budget Progress */} +
+
+

+ Бюджет 50/30/20 +

+

+ Прогресс по категориям расходов +

+ + {budgetProgress ? ( +
+
+
+ + Необходимое (50%) + + + {formatMoney(budgetProgress.essentials.spent)} /{" "} + {formatMoney(budgetProgress.essentials.limit)} + +
+ +
+
+
+ + Желания (30%) + + + {formatMoney(budgetProgress.personal.spent)} /{" "} + {formatMoney(budgetProgress.personal.limit)} + +
+ +
+
+
+ + Сбережения (20%) + + + {formatMoney(budgetProgress.savings.spent)} /{" "} + {formatMoney(budgetProgress.savings.limit)} + +
+ +
+
+ ) : ( + + )} +
+ + {/* Goals Summary */} +
+

Цели

+

+ Прогресс по финансовым целям +

+ + {goals && goals.totalGoals > 0 ? ( +
+
+
+

+ {goals.activeGoals} +

+

Активных целей

+
+
+

+ {goals.completedGoals} +

+

Достигнуто

+
+
+
+
+ + Общий прогресс + + + {Math.round(goals.overallProgress)}% + +
+ +
+
+

Накоплено: {formatMoney(goals.totalCurrentAmount)}

+

Цель: {formatMoney(goals.totalTargetAmount)}

+
+
+ ) : ( + + )} +
+
+ + {/* Top Categories & Recommendations */} +
+ {/* Top Categories */} +
+

+ Топ категорий +

+

+ Основные статьи расходов +

+ + {overview?.topCategories && overview.topCategories.length > 0 ? ( +
+ {overview.topCategories.slice(0, 5).map((cat) => ( +
+
+
+ + {cat.categoryName} + +
+
+

+ {formatMoney(cat.amount)} +

+

+ {cat.percentage.toFixed(1)}% +

+
+
+ ))} +
+ ) : ( + + )} +
+ + {/* Recommendations */} +
+

Рекомендации

+

+ Советы по улучшению финансов +

+ + {recommendations && recommendations.total > 0 ? ( +
+
+
+

+ {recommendations.new} +

+

Новых

+
+
+

+ {recommendations.applied} +

+

Применено

+
+
+

+ Применено рекомендаций: {recommendations.applied} из{" "} + {recommendations.total} +

+
+ ) : ( + + )} +
+
+
+ ); +}; + +export default DashboardPage; diff --git a/src/pages/dashboard/recommendations.tsx b/src/pages/dashboard/recommendations.tsx new file mode 100644 index 0000000..e1be367 --- /dev/null +++ b/src/pages/dashboard/recommendations.tsx @@ -0,0 +1,280 @@ +import { useEffect, useState } from "react"; +import { + PageHeader, + Button, + EmptyState, + Badge, + CardSkeleton, +} from "../../components/ui"; +import { useAppDispatch, useAppSelector } from "../../store/hooks"; +import { + recommendationsSelector, + recommendationsStatsSelector, + recommendationsLoadingSelector, +} from "../../store/recommendations/selectors"; +import { + recommendationsList, + recommendationsGenerate, + recommendationsApply, + recommendationsDismiss, + recommendationsStats, +} from "../../store/recommendations/async-thunks"; +import type { RecommendationType } from "../../interfaces/recommendations"; + +const typeLabels: Record = { + SAVING: "Экономия", + SPENDING: "Расходы", + INVESTMENT: "Инвестиции", + TAX: "Налоги", + DEBT: "Долги", + BUDGET: "Бюджет", + GOAL: "Цели", +}; + +const typeVariants: Record< + RecommendationType, + "success" | "warning" | "danger" | "info" | "default" +> = { + SAVING: "success", + SPENDING: "warning", + INVESTMENT: "info", + TAX: "default", + DEBT: "danger", + BUDGET: "info", + GOAL: "success", +}; + +const RecommendationsPage = () => { + const dispatch = useAppDispatch(); + const recommendations = useAppSelector(recommendationsSelector); + const stats = useAppSelector(recommendationsStatsSelector); + const isLoading = useAppSelector(recommendationsLoadingSelector); + + const [isGenerating, setIsGenerating] = useState(false); + const [processingId, setProcessingId] = useState(null); + + useEffect(() => { + dispatch(recommendationsList()); + dispatch(recommendationsStats()); + }, [dispatch]); + + const handleGenerate = async () => { + setIsGenerating(true); + try { + await dispatch(recommendationsGenerate()).unwrap(); + dispatch(recommendationsList()); + dispatch(recommendationsStats()); + } catch { + // Handle error + } finally { + setIsGenerating(false); + } + }; + + const handleApply = async (id: string) => { + setProcessingId(id); + try { + await dispatch(recommendationsApply(id)).unwrap(); + dispatch(recommendationsList()); + dispatch(recommendationsStats()); + } catch { + // Handle error + } finally { + setProcessingId(null); + } + }; + + const handleDismiss = async (id: string) => { + setProcessingId(id); + try { + await dispatch(recommendationsDismiss(id)).unwrap(); + dispatch(recommendationsList()); + dispatch(recommendationsStats()); + } catch { + // Handle error + } finally { + setProcessingId(null); + } + }; + + const newRecommendations = recommendations.filter((r) => r.status === "NEW"); + const appliedRecommendations = recommendations.filter( + (r) => r.status === "APPLIED", + ); + + return ( +
+ + + + + Обновить + + } + /> + + {/* Stats */} + {stats && ( +
+
+

Всего

+

+ {stats.total} +

+
+
+

Новых

+

+ {stats.new} +

+
+
+

Применено

+

+ {stats.applied} +

+
+
+

Отклонено

+

+ {stats.dismissed} +

+
+
+ )} + + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : recommendations.length === 0 ? ( + + Сгенерировать рекомендации + + } + /> + ) : ( +
+ {/* New Recommendations */} + {newRecommendations.length > 0 && ( +
+

+ Новые рекомендации +

+
+ {newRecommendations.map((rec) => ( +
+
+
+
+ + {typeLabels[rec.type]} + + {rec.confidenceScore > 0.8 && ( + + Высокая уверенность + + )} +
+

+ {rec.titleRu} +

+

+ {rec.descriptionRu} +

+
+
+ + +
+
+
+ ))} +
+
+ )} + + {/* Applied Recommendations */} + {appliedRecommendations.length > 0 && ( +
+

+ Применённые рекомендации +

+
+ {appliedRecommendations.map((rec) => ( +
+
+
+ + + +
+
+
+ + {typeLabels[rec.type]} + +
+

+ {rec.titleRu} +

+

+ {rec.descriptionRu} +

+
+
+
+ ))} +
+
+ )} +
+ )} +
+ ); +}; + +export default RecommendationsPage; diff --git a/src/pages/dashboard/settings.tsx b/src/pages/dashboard/settings.tsx new file mode 100644 index 0000000..195dc6d --- /dev/null +++ b/src/pages/dashboard/settings.tsx @@ -0,0 +1,279 @@ +import { useEffect, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useNavigate } from "react-router"; +import { PageHeader, Button, Input, CardSkeleton } from "../../components/ui"; +import { + authGetProfile, + authUpdateProfile, + authChangePassword, + authLogout, +} from "../../api/profile"; +import { parseApiError } from "../../api/parse-api-error"; +import type { UserProfile } from "../../interfaces/auth"; + +const profileSchema = z.object({ + firstName: z.string().optional(), + lastName: z.string().optional(), + phone: z.string().optional(), +}); + +type ProfileFormValues = z.infer; + +const passwordSchema = z + .object({ + currentPassword: z.string().min(1, "Введите текущий пароль"), + newPassword: z.string().min(8, "Минимум 8 символов"), + confirmPassword: z.string().min(1, "Подтвердите пароль"), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Пароли не совпадают", + path: ["confirmPassword"], + }); + +type PasswordFormValues = z.infer; + +const SettingsPage = () => { + const navigate = useNavigate(); + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSavingProfile, setIsSavingProfile] = useState(false); + const [isSavingPassword, setIsSavingPassword] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); + const [profileError, setProfileError] = useState(null); + const [profileSuccess, setProfileSuccess] = useState(false); + const [passwordError, setPasswordError] = useState(null); + const [passwordSuccess, setPasswordSuccess] = useState(false); + + const { + register: registerProfile, + handleSubmit: handleSubmitProfile, + reset: resetProfile, + formState: { errors: profileErrors }, + } = useForm({ + resolver: zodResolver(profileSchema), + }); + + const { + register: registerPassword, + handleSubmit: handleSubmitPassword, + reset: resetPassword, + formState: { errors: passwordErrors }, + } = useForm({ + resolver: zodResolver(passwordSchema), + }); + + useEffect(() => { + loadProfile(); + }, []); + + const loadProfile = async () => { + setIsLoading(true); + try { + const data = await authGetProfile(); + setProfile(data); + resetProfile({ + firstName: data.firstName || "", + lastName: data.lastName || "", + phone: data.phone || "", + }); + } catch { + // Handle error + } finally { + setIsLoading(false); + } + }; + + const onSubmitProfile = async (values: ProfileFormValues) => { + setIsSavingProfile(true); + setProfileError(null); + setProfileSuccess(false); + + try { + const updated = await authUpdateProfile(values); + setProfile(updated); + setProfileSuccess(true); + setTimeout(() => setProfileSuccess(false), 3000); + } catch (err) { + const parsed = parseApiError(err); + setProfileError(parsed.messages[0] || "Произошла ошибка"); + } finally { + setIsSavingProfile(false); + } + }; + + const onSubmitPassword = async (values: PasswordFormValues) => { + setIsSavingPassword(true); + setPasswordError(null); + setPasswordSuccess(false); + + try { + await authChangePassword(values); + setPasswordSuccess(true); + resetPassword(); + setTimeout(() => setPasswordSuccess(false), 3000); + } catch (err) { + const parsed = parseApiError(err); + setPasswordError(parsed.messages[0] || "Произошла ошибка"); + } finally { + setIsSavingPassword(false); + } + }; + + const handleLogout = async () => { + setIsLoggingOut(true); + try { + await authLogout(); + navigate("/auth/login"); + } catch { + // Even if logout fails, redirect to login + navigate("/auth/login"); + } + }; + + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + return ( +
+ + +
+ {/* Profile Section */} +
+

Профиль

+ + {profileError && ( +
+ {profileError} +
+ )} + + {profileSuccess && ( +
+ Профиль успешно обновлён +
+ )} + +
+
+

Email

+

{profile?.email}

+
+ +
+ + +
+ + + +
+ +
+
+
+ + {/* Password Section */} +
+

+ Изменить пароль +

+ + {passwordError && ( +
+ {passwordError} +
+ )} + + {passwordSuccess && ( +
+ Пароль успешно изменён +
+ )} + +
+ + + + +
+ +
+
+
+ + {/* Logout Section */} +
+

Выход

+

+ Выйти из аккаунта на этом устройстве +

+ +
+
+
+ ); +}; + +export default SettingsPage; diff --git a/src/pages/dashboard/transactions.tsx b/src/pages/dashboard/transactions.tsx new file mode 100644 index 0000000..f9cdbe7 --- /dev/null +++ b/src/pages/dashboard/transactions.tsx @@ -0,0 +1,404 @@ +import { useEffect, useState } from "react"; +import { + PageHeader, + Button, + EmptyState, + Badge, + ConfirmDialog, + ListItemSkeleton, + Input, + Select, +} from "../../components/ui"; +import { TransactionModal } from "../../components/transactions/TransactionModal"; +import { useAppDispatch, useAppSelector } from "../../store/hooks"; +import { + transactionsSelector, + transactionsSummarySelector, + transactionsLoadingSelector, +} from "../../store/transactions/selectors"; +import { + transactionsList, + transactionsDelete, + transactionsSummary, +} from "../../store/transactions/async-thunks"; +import { categoriesSelector } from "../../store/categories/selectors"; +import { categoriesList } from "../../store/categories/async-thunks"; +import type { + Transaction, + TransactionFilters, +} from "../../interfaces/transactions"; + +const formatMoney = (amount: number): string => { + return new Intl.NumberFormat("ru-RU", { + style: "currency", + currency: "RUB", + maximumFractionDigits: 0, + }).format(amount); +}; + +const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleDateString("ru-RU", { + day: "numeric", + month: "short", + }); +}; + +const TransactionsPage = () => { + const dispatch = useAppDispatch(); + const transactions = useAppSelector(transactionsSelector); + const categories = useAppSelector(categoriesSelector); + const summary = useAppSelector(transactionsSummarySelector); + const isLoading = useAppSelector(transactionsLoadingSelector); + + const [filters, setFilters] = useState({ + page: 1, + limit: 50, + }); + + const [modalOpen, setModalOpen] = useState(false); + const [editingTransaction, setEditingTransaction] = useState< + Transaction | undefined + >(); + const [deleteId, setDeleteId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + dispatch(transactionsList(filters)); + dispatch( + transactionsSummary({ + startDate: + filters.startDate || + new Date( + new Date().getFullYear(), + new Date().getMonth(), + 1, + ).toISOString(), + endDate: filters.endDate || new Date().toISOString(), + }), + ); + }, [dispatch, filters]); + + useEffect(() => { + dispatch(categoriesList()); + }, [dispatch]); + + const handleEdit = (transaction: Transaction) => { + setEditingTransaction(transaction); + setModalOpen(true); + }; + + const handleDelete = async () => { + if (!deleteId) return; + setIsDeleting(true); + try { + await dispatch(transactionsDelete(deleteId)).unwrap(); + dispatch(transactionsList(filters)); + } catch { + // Handle error + } finally { + setIsDeleting(false); + setDeleteId(null); + } + }; + + const handleModalClose = () => { + setModalOpen(false); + setEditingTransaction(undefined); + }; + + const handleSuccess = () => { + dispatch(transactionsList(filters)); + }; + + const safeTransactions = Array.isArray(transactions) ? transactions : []; + const groupedTransactions = safeTransactions.reduce< + Record + >((groups, transaction) => { + const date = transaction.transactionDate.split("T")[0]; + if (!groups[date]) groups[date] = []; + groups[date].push(transaction); + return groups; + }, {}); + + const typeOptions = [ + { value: "", label: "Все типы" }, + { value: "INCOME", label: "Доходы" }, + { value: "EXPENSE", label: "Расходы" }, + ]; + + const categoryOptions = [ + { value: "", label: "Все категории" }, + ...(categories || []).map((cat) => ({ value: cat.id, label: cat.nameRu })), + ]; + + return ( +
+ setModalOpen(true)}> + + + + Добавить + + } + /> + + {/* Summary Cards */} + {summary && ( +
+
+

Доходы

+

+ +{formatMoney(summary.totalIncome)} +

+
+
+

Расходы

+

+ -{formatMoney(summary.totalExpense)} +

+
+
+

Баланс

+

= 0 ? "text-slate-900" : "text-rose-600"}`} + > + {formatMoney(summary.balance)} +

+
+
+ )} + + {/* Filters */} +
+
+ + setFilters({ + ...filters, + categoryId: e.target.value || undefined, + }) + } + /> + + setFilters({ + ...filters, + startDate: e.target.value + ? new Date(e.target.value).toISOString() + : undefined, + }) + } + /> + + setFilters({ + ...filters, + endDate: e.target.value + ? new Date(e.target.value).toISOString() + : undefined, + }) + } + /> +
+
+ + {/* Transactions List */} +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : transactions.length === 0 ? ( + setModalOpen(true)}> + Добавить транзакцию + + } + /> + ) : ( +
+ {Object.entries(groupedTransactions) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([date, dayTransactions]) => ( +
+
+

+ {new Date(date).toLocaleDateString("ru-RU", { + weekday: "long", + day: "numeric", + month: "long", + })} +

+
+ {dayTransactions.map((transaction) => ( +
+
+
+ {transaction.type === "INCOME" ? ( + + + + ) : ( + + + + )} +
+
+

+ {transaction.description || + transaction.category?.nameRu || + "Без описания"} +

+
+ {transaction.category && ( + + {transaction.category.nameRu} + + )} + + {formatDate(transaction.transactionDate)} + +
+
+
+
+

+ {transaction.type === "INCOME" ? "+" : "-"} + {formatMoney(transaction.amount)} +

+
+ + +
+
+
+ ))} +
+ ))} +
+ )} +
+ + + + setDeleteId(null)} + onConfirm={handleDelete} + title="Удалить транзакцию?" + message="Это действие нельзя отменить. Транзакция будет удалена навсегда." + confirmText="Удалить" + variant="danger" + isLoading={isDeleting} + /> +
+ ); +}; + +export default TransactionsPage; diff --git a/src/pages/index.tsx b/src/pages/index.tsx new file mode 100644 index 0000000..44cd9d9 --- /dev/null +++ b/src/pages/index.tsx @@ -0,0 +1,493 @@ +import { useMemo } from "react"; +import { Link } from "react-router"; +import { FeatureCard } from "../components/landing/FeatureCard"; +import { FAQItem } from "../components/landing/FAQItem"; +import { Section } from "../components/landing/Section"; + +const LandingPage = () => { + const navItems = useMemo( + () => [ + { label: "Возможности", href: "#features" }, + { label: "Как это работает", href: "#how" }, + { label: "Безопасность", href: "#security" }, + { label: "FAQ", href: "#faq" }, + ], + [], + ); + + const scrollToId = (id: string) => { + const el = document.getElementById(id); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + return ( +
+
+
+
+
+ +
+
+
+ + + Finance App + +
+ + + +
+ + Войти + + + + Начать бесплатно + +
+
+
+ +
+
+
+
+
+

+ Контролируй деньги. Достигай целей. Спокойнее живи. +

+

+ Доходы и расходы, категории, бюджет 50/30/20, цели, аналитика + и рекомендации — в одном месте. Быстрый старт и понятные + инсайты. +

+ +
+ + Начать бесплатно + + + +
+ +
+
+
+ Время до старта +
+
+ 2 мин +
+
+
+
+ Бюджет +
+
+ 50/30/20 +
+
+
+
+ Фокус +
+
+ Цели +
+
+
+
+ +
+
+
+
+

+ Панель управления +

+

+ Декабрь • Обзор месяца +

+
+
+
+
+ +
+
+

+ Доходы +

+

+ 185 400 ₽ +

+
+
+
+
+ +
+

+ Расходы +

+

+ 119 800 ₽ +

+
+
+
+
+ +
+

+ Сбережения +

+

+ 32 600 ₽ +

+

+ +8% к прошлому месяцу +

+
+ +
+

+ Прогресс целей +

+

+ 64% +

+
+
+
+
+
+ +
+

+ Рекомендации (скоро) +

+

+ “Снизьте долю трат на подписки до 5% и направьте разницу в + цель «Подушка безопасности».” +

+
+
+
+
+
+
+ +
+
+ + + + } + title="Доходы / Расходы" + description="Записывай транзакции быстро и получай понятную картину движения денег." + /> + + + + } + title="Категории" + description="Гибкая структура: еда, жильё, транспорт, подписки — всё на своих местах." + /> + + + + } + title="Бюджеты 50/30/20" + description="Следи за потребностями, желаниями и сбережениями по популярной формуле." + /> + + + + } + title="Финансовые цели" + description="Создавай цели и отслеживай прогресс: подушка безопасности, отпуск, техника." + /> + + + + } + title="Рекомендации" + description="AI-style подсказки (плейсхолдер): что улучшить в бюджете и где оптимизировать." + /> + + + + } + title="Аналитика" + description="Обзор месяца, тренды, и понятный health score — чтобы видеть прогресс." + /> +
+
+ +
+
    +
  1. +

    Шаг 1

    +

    + Создай аккаунт +

    +

    + Регистрация занимает минуты. Затем заходи в личный кабинет. +

    +
  2. +
  3. +

    Шаг 2

    +

    + Добавь транзакции / категории +

    +

    + Заноси доходы и расходы и группируй их так, как удобно тебе. +

    +
  4. +
  5. +

    Шаг 3

    +

    + Получи бюджет, цели и аналитику +

    +

    + Следи за 50/30/20, ростом целей и динамикой расходов по месяцам. +

    +
  6. +
+
+ +
+
+
+

+ Безопасно по умолчанию +

+

+ Сессии через HTTP-only cookies, JWT и ограничение доступа на + уровне API. Мы проектируем систему так, чтобы безопасное + поведение было стандартом. +

+
+
+

+ Прозрачность +

+

+ Никаких лишних интеграций. Данные используются только для + расчётов бюджета, целей и аналитики внутри приложения. +

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

+ Начни сегодня — это займёт 2 минуты +

+

+ Создай аккаунт, добавь первые транзакции и получи понятный + бюджет, цели и аналитику. +

+
+ + Создать аккаунт + +
+
+
+
+
+ + +
+ ); +}; + +export default LandingPage; diff --git a/src/pages/login.tsx b/src/pages/login.tsx new file mode 100644 index 0000000..6cbaffe --- /dev/null +++ b/src/pages/login.tsx @@ -0,0 +1,155 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Link, useNavigate } from "react-router"; +import { z } from "zod"; +import { authLogin } from "../store/auth/aysnc-thunks"; +import { useAppDispatch, useAppSelector } from "../store/hooks"; +import { authStatusSelector } from "../store/auth/selectors"; +import { useEffect } from "react"; +import { AuthStatus } from "../interfaces/auth"; + +const schema = z.object({ + email: z.email("Введите корректный email"), + password: z.string().min(6, "Минимум 6 символов"), +}); + +type LoginFormValues = z.infer; + +const LoginPage = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + email: "", + password: "", + }, + mode: "onTouched", + }); + + const onSubmit = async (values: LoginFormValues) => { + dispatch(authLogin(values)); + }; + + const authStatus = useAppSelector(authStatusSelector); + + useEffect(() => { + if (authStatus === AuthStatus.Authorized) { + navigate("/dashboard"); + } + }, [authStatus, navigate]); + + return ( +
+
+
+
+ + + Finance App + +

+ Войти +

+

+ Введите email и пароль, чтобы открыть личный кабинет. +

+
+ +
+
+
+ + + {errors.email ? ( +

+ {errors.email.message} +

+ ) : null} +
+ +
+ + + {errors.password ? ( +

+ {errors.password.message} +

+ ) : null} +
+ + +
+ +
+ Нет аккаунта?{" "} + + Начать бесплатно + +
+
+
+
+
+ ); +}; + +export default LoginPage; diff --git a/src/pages/protected-layout.tsx b/src/pages/protected-layout.tsx new file mode 100644 index 0000000..d89670b --- /dev/null +++ b/src/pages/protected-layout.tsx @@ -0,0 +1,40 @@ +import { useNavigate } from "react-router"; +import { authStatusSelector } from "../store/auth/selectors"; +import { useAppDispatch, useAppSelector } from "../store/hooks"; +import { useEffect } from "react"; +import { authRe } from "../store/auth/aysnc-thunks"; +import { AuthStatus } from "../interfaces/auth"; +import { Spinner } from "../components/spinner"; +import { AppShell } from "../components/layout/AppShell"; + +const ProtectedLayout = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const authStatus = useAppSelector(authStatusSelector); + + useEffect(() => { + dispatch(authRe()); + }, [dispatch]); + + useEffect(() => { + if (authStatus === AuthStatus.NotAuthorized) { + navigate("/auth/login"); + } + }, [authStatus, navigate]); + + if (authStatus === AuthStatus.None) { + return ( +
+ +
+ ); + } + + if (authStatus === AuthStatus.Authorized) { + return ; + } + + return null; +}; + +export default ProtectedLayout; diff --git a/src/pages/register.tsx b/src/pages/register.tsx new file mode 100644 index 0000000..27f4496 --- /dev/null +++ b/src/pages/register.tsx @@ -0,0 +1,216 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Link } from "react-router"; +import { z } from "zod"; +import { authRegister } from "../store/auth/aysnc-thunks"; +import { useAppDispatch } from "../store/hooks"; + +const schema = z.object({ + email: z.email("Введите корректный email"), + password: z.string().min(6, "Минимум 6 символов"), + firstName: z.string().trim().optional(), + lastName: z.string().trim().optional(), + phone: z + .string() + .trim() + .optional() + .refine( + (value) => { + if (!value) return true; + return /^[+]?[-0-9()\s]{7,}$/.test(value); + }, + { message: "Введите корректный номер телефона" }, + ), +}); + +type RegisterFormValues = z.infer; + +const RegisterPage = () => { + const dispatch = useAppDispatch(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + email: "", + password: "", + firstName: "", + lastName: "", + phone: "", + }, + mode: "onTouched", + }); + + const onSubmit = (values: RegisterFormValues) => { + dispatch(authRegister(values)); + }; + + return ( +
+
+
+
+ + + Finance App + +

+ Начать бесплатно +

+

+ Создайте аккаунт, чтобы настроить бюджет 50/30/20, цели и + аналитику. +

+
+ +
+
+
+ + + {errors.email ? ( +

+ {errors.email.message} +

+ ) : null} +
+ +
+ + + {errors.password ? ( +

+ {errors.password.message} +

+ ) : null} +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + {errors.phone ? ( +

+ {errors.phone.message} +

+ ) : null} +
+ + +
+ +
+ Уже есть аккаунт?{" "} + + Войти + +
+
+
+
+
+ ); +}; + +export default RegisterPage; diff --git a/src/request-instance.ts b/src/request-instance.ts new file mode 100644 index 0000000..a4ce5b2 --- /dev/null +++ b/src/request-instance.ts @@ -0,0 +1,18 @@ +import axios, { type CreateAxiosDefaults } from "axios"; +import { API_HOST } from "./constants"; + +const config: CreateAxiosDefaults = { + baseURL: `${API_HOST}/api/v1/`, + withCredentials: true, + paramsSerializer: { + indexes: null, + }, +}; + +export const requestInstance = axios.create(config); + +requestInstance.interceptors.request.use((requestConfig) => { + const modifiedConfig = { ...requestConfig }; + + return modifiedConfig; +}); diff --git a/src/router.tsx b/src/router.tsx new file mode 100644 index 0000000..9231fb0 --- /dev/null +++ b/src/router.tsx @@ -0,0 +1,49 @@ +import type { RouteObject } from "react-router"; +import LandingPage from "./pages"; +import { lazy } from "react"; + +const LoginPage = lazy(() => import("./pages/login")); +const RegisterPage = lazy(() => import("./pages/register")); +const ProtectedLayout = lazy(() => import("./pages/protected-layout")); +const DashboardPage = lazy(() => import("./pages/dashboard/index")); +const TransactionsPage = lazy(() => import("./pages/dashboard/transactions")); +const CategoriesPage = lazy(() => import("./pages/dashboard/categories")); +const BudgetsPage = lazy(() => import("./pages/dashboard/budgets")); +const GoalsPage = lazy(() => import("./pages/dashboard/goals")); +const RecommendationsPage = lazy( + () => import("./pages/dashboard/recommendations"), +); +const AnalyticsPage = lazy(() => import("./pages/dashboard/analytics")); +const SettingsPage = lazy(() => import("./pages/dashboard/settings")); + +export const routes: RouteObject[] = [ + { + element: , + path: "/", + }, + { + element: , + path: "/auth/login", + }, + { + element: , + path: "/auth/register", + }, + + { + path: "/dashboard", + element: , + children: [ + { index: true, element: }, + { path: "transactions", element: }, + { path: "categories", element: }, + { path: "budgets", element: }, + { path: "goals", element: }, + { path: "recommendations", element: }, + { path: "analytics", element: }, + { path: "settings", element: }, + ], + }, +]; + +export default routes; diff --git a/src/store/analytics/async-thunks.ts b/src/store/analytics/async-thunks.ts new file mode 100644 index 0000000..0b8cb8c --- /dev/null +++ b/src/store/analytics/async-thunks.ts @@ -0,0 +1,40 @@ +import { + analyticsOverviewApi, + analyticsTrendsApi, + analyticsCategoryBreakdownApi, + analyticsIncomeVsExpensesApi, + analyticsFinancialHealthApi, + analyticsYearlySummaryApi, +} from "../../api/analytics"; +import { analytics } from "../../constants/analytics"; +import { createAppAsyncThunk } from "../create-app-async-thunk"; + +export const analyticsOverview = createAppAsyncThunk( + `${analytics}/overview`, + analyticsOverviewApi, +); + +export const analyticsTrends = createAppAsyncThunk( + `${analytics}/trends`, + analyticsTrendsApi, +); + +export const analyticsCategoryBreakdown = createAppAsyncThunk( + `${analytics}/categoryBreakdown`, + analyticsCategoryBreakdownApi, +); + +export const analyticsIncomeVsExpenses = createAppAsyncThunk( + `${analytics}/incomeVsExpenses`, + analyticsIncomeVsExpensesApi, +); + +export const analyticsFinancialHealth = createAppAsyncThunk( + `${analytics}/financialHealth`, + analyticsFinancialHealthApi, +); + +export const analyticsYearlySummary = createAppAsyncThunk( + `${analytics}/yearlySummary`, + analyticsYearlySummaryApi, +); diff --git a/src/store/analytics/index.ts b/src/store/analytics/index.ts new file mode 100644 index 0000000..6cc11f1 --- /dev/null +++ b/src/store/analytics/index.ts @@ -0,0 +1,61 @@ +import { createSlice, isAnyOf } from "@reduxjs/toolkit"; +import { analytics, analyticsInitialState } from "../../constants/analytics"; +import { + analyticsOverview, + analyticsTrends, + analyticsCategoryBreakdown, + analyticsFinancialHealth, +} from "./async-thunks"; + +export const analyticsSlice = createSlice({ + name: analytics, + initialState: analyticsInitialState, + reducers: {}, + extraReducers: ({ addCase, addMatcher }) => { + addCase(analyticsOverview.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.overview = action.payload.data; + }); + + addCase(analyticsTrends.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.trends = action.payload.data; + }); + + addCase(analyticsCategoryBreakdown.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.categoryBreakdown = action.payload.data; + }); + + addCase(analyticsFinancialHealth.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.financialHealth = action.payload.data; + }); + + addMatcher( + isAnyOf( + analyticsOverview.pending, + analyticsTrends.pending, + analyticsCategoryBreakdown.pending, + analyticsFinancialHealth.pending, + ), + (draft) => { + draft.isLoading = true; + draft.error = undefined; + }, + ); + + addMatcher( + isAnyOf( + analyticsOverview.rejected, + analyticsTrends.rejected, + analyticsCategoryBreakdown.rejected, + analyticsFinancialHealth.rejected, + ), + (draft, action) => { + draft.isLoading = false; + draft.error = action.error.message || "Произошла ошибка"; + }, + ); + }, +}); diff --git a/src/store/analytics/selectors.ts b/src/store/analytics/selectors.ts new file mode 100644 index 0000000..6b23808 --- /dev/null +++ b/src/store/analytics/selectors.ts @@ -0,0 +1,30 @@ +import type { + AnalyticsOverview, + TrendDataPoint, + CategoryBreakdown, + FinancialHealth, +} from "../../interfaces/analytics"; +import type { RootSelector } from "../types"; + +export const analyticsOverviewSelector: RootSelector< + AnalyticsOverview | null +> = (state) => state.analytics.overview; + +export const analyticsTrendsSelector: RootSelector = ( + state, +) => state.analytics.trends; + +export const analyticsCategoryBreakdownSelector: RootSelector< + CategoryBreakdown[] +> = (state) => state.analytics.categoryBreakdown; + +export const analyticsFinancialHealthSelector: RootSelector< + FinancialHealth | null +> = (state) => state.analytics.financialHealth; + +export const analyticsLoadingSelector: RootSelector = (state) => + state.analytics.isLoading; + +export const analyticsErrorSelector: RootSelector = ( + state, +) => state.analytics.error; diff --git a/src/store/auth/aysnc-thunks.ts b/src/store/auth/aysnc-thunks.ts new file mode 100644 index 0000000..33781e2 --- /dev/null +++ b/src/store/auth/aysnc-thunks.ts @@ -0,0 +1,17 @@ +import { authLoginApi, authReApi, authRegisterApi } from "../../api/auth"; +import { auth } from "../../constants/auth"; +import { createAppAsyncThunk } from "../../store/create-app-async-thunk"; + +export const authLogin = createAppAsyncThunk(`${auth}/authLogin`, authLoginApi); + +export const authRegister = createAppAsyncThunk( + `${auth}/authRegister`, + authRegisterApi, +); + +// export const authLogout = createAppAsyncThunk( +// `${auth}/authLogout`, +// authLogoutApi +// ); + +export const authRe = createAppAsyncThunk(`${auth}/authRe`, authReApi); diff --git a/src/store/auth/index.ts b/src/store/auth/index.ts new file mode 100644 index 0000000..27c9da1 --- /dev/null +++ b/src/store/auth/index.ts @@ -0,0 +1,40 @@ +import { type PayloadAction, createSlice, isAnyOf } from "@reduxjs/toolkit"; +import { auth, authInitialState } from "../../constants/auth"; +import { authLogin, authRe, authRegister } from "./aysnc-thunks"; +import { AuthStage, AuthStatus } from "../../interfaces/auth"; + +export const authSlice = createSlice({ + name: auth, + initialState: authInitialState, + reducers: { + changeAuthStage: (draft, { payload }: PayloadAction) => { + draft.stage = payload; + }, + changeAuthStatus: (draft, { payload }: PayloadAction) => { + draft.authStatus = payload; + }, + }, + + extraReducers: ({ addCase, addMatcher }) => { + addCase(authLogin.rejected, (draft) => { + draft.authStatus = AuthStatus.NotAuthorized; + draft.error = "Неверный логин или пароль"; + }); + + addCase(authRegister.rejected, (draft) => { + draft.authStatus = AuthStatus.NotAuthorized; + draft.error = "Не удалось зарегистрироваться"; + }); + + addCase(authRe.rejected, (draft) => { + draft.authStatus = AuthStatus.NotAuthorized; + }); + + addMatcher( + isAnyOf(authLogin.fulfilled, authRe.fulfilled, authRegister.fulfilled), + (draft) => { + draft.authStatus = AuthStatus.Authorized; + }, + ); + }, +}); diff --git a/src/store/auth/middlewares.ts b/src/store/auth/middlewares.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/store/auth/selectors.ts b/src/store/auth/selectors.ts new file mode 100644 index 0000000..f425152 --- /dev/null +++ b/src/store/auth/selectors.ts @@ -0,0 +1,11 @@ +import type { AuthStage, AuthStatus } from "../../interfaces/auth"; +import { type RootSelector } from "../../store/types"; + +export const authStatusSelector: RootSelector = (state) => + state.auth.authStatus; + +export const authStageSelector: RootSelector = (state) => + state.auth.stage; + +export const authErrorSelector: RootSelector = (state) => + state.auth.error; diff --git a/src/store/budgets/async-thunks.ts b/src/store/budgets/async-thunks.ts new file mode 100644 index 0000000..176d5eb --- /dev/null +++ b/src/store/budgets/async-thunks.ts @@ -0,0 +1,46 @@ +import { + budgetsListApi, + budgetsGetCurrentApi, + budgetsGetByMonthApi, + budgetsCreateApi, + budgetsUpdateApi, + budgetsDeleteApi, + budgetsProgressApi, +} from "../../api/budgets"; +import { budgets } from "../../constants/budgets"; +import { createAppAsyncThunk } from "../create-app-async-thunk"; + +export const budgetsList = createAppAsyncThunk( + `${budgets}/list`, + budgetsListApi, +); + +export const budgetsGetCurrent = createAppAsyncThunk( + `${budgets}/getCurrent`, + budgetsGetCurrentApi, +); + +export const budgetsGetByMonth = createAppAsyncThunk( + `${budgets}/getByMonth`, + budgetsGetByMonthApi, +); + +export const budgetsCreate = createAppAsyncThunk( + `${budgets}/create`, + budgetsCreateApi, +); + +export const budgetsUpdate = createAppAsyncThunk( + `${budgets}/update`, + budgetsUpdateApi, +); + +export const budgetsDelete = createAppAsyncThunk( + `${budgets}/delete`, + budgetsDeleteApi, +); + +export const budgetsProgress = createAppAsyncThunk( + `${budgets}/progress`, + budgetsProgressApi, +); diff --git a/src/store/budgets/index.ts b/src/store/budgets/index.ts new file mode 100644 index 0000000..e55b435 --- /dev/null +++ b/src/store/budgets/index.ts @@ -0,0 +1,95 @@ +import { createSlice, isAnyOf } from "@reduxjs/toolkit"; +import { budgets, budgetsInitialState } from "../../constants/budgets"; +import { + budgetsList, + budgetsGetCurrent, + budgetsGetByMonth, + budgetsCreate, + budgetsUpdate, + budgetsDelete, + budgetsProgress, +} from "./async-thunks"; + +export const budgetsSlice = createSlice({ + name: budgets, + initialState: budgetsInitialState, + reducers: {}, + extraReducers: ({ addCase, addMatcher }) => { + addCase(budgetsList.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.budgets = action.payload.data; + }); + + addCase(budgetsGetCurrent.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.currentBudget = action.payload?.data ?? null; + }); + + addCase(budgetsGetByMonth.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.currentBudget = action.payload.data; + }); + + addCase(budgetsCreate.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.budgets.push(action.payload.data); + draft.currentBudget = action.payload.data; + }); + + addCase(budgetsUpdate.fulfilled, (draft, action) => { + draft.isLoading = false; + const index = draft.budgets.findIndex( + (b) => b.id === action.payload.data.id, + ); + if (index !== -1) { + draft.budgets[index] = action.payload.data; + } + draft.currentBudget = action.payload.data; + }); + + addCase(budgetsDelete.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.budgets = draft.budgets.filter((b) => b.month !== action.meta.arg); + if (draft.currentBudget?.month === action.meta.arg) { + draft.currentBudget = null; + } + }); + + addCase(budgetsProgress.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.progress = action.payload.data; + }); + + addMatcher( + isAnyOf( + budgetsList.pending, + budgetsGetCurrent.pending, + budgetsGetByMonth.pending, + budgetsCreate.pending, + budgetsUpdate.pending, + budgetsDelete.pending, + budgetsProgress.pending, + ), + (draft) => { + draft.isLoading = true; + draft.error = undefined; + }, + ); + + addMatcher( + isAnyOf( + budgetsList.rejected, + budgetsGetCurrent.rejected, + budgetsGetByMonth.rejected, + budgetsCreate.rejected, + budgetsUpdate.rejected, + budgetsDelete.rejected, + budgetsProgress.rejected, + ), + (draft, action) => { + draft.isLoading = false; + draft.error = action.error.message || "Произошла ошибка"; + }, + ); + }, +}); diff --git a/src/store/budgets/selectors.ts b/src/store/budgets/selectors.ts new file mode 100644 index 0000000..dc35875 --- /dev/null +++ b/src/store/budgets/selectors.ts @@ -0,0 +1,18 @@ +import type { Budget, BudgetProgressResponse } from "../../interfaces/budgets"; +import type { RootSelector } from "../types"; + +export const budgetsSelector: RootSelector = (state) => + state.budgets.budgets; + +export const budgetsCurrentSelector: RootSelector = (state) => + state.budgets.currentBudget; + +export const budgetsProgressSelector: RootSelector< + BudgetProgressResponse | null +> = (state) => state.budgets.progress; + +export const budgetsLoadingSelector: RootSelector = (state) => + state.budgets.isLoading; + +export const budgetsErrorSelector: RootSelector = (state) => + state.budgets.error; diff --git a/src/store/categories/async-thunks.ts b/src/store/categories/async-thunks.ts new file mode 100644 index 0000000..39343b3 --- /dev/null +++ b/src/store/categories/async-thunks.ts @@ -0,0 +1,40 @@ +import { + categoriesListApi, + categoriesGetApi, + categoriesGetGroupedApi, + categoriesCreateApi, + categoriesUpdateApi, + categoriesDeleteApi, +} from "../../api/categories"; +import { categories } from "../../constants/categories"; +import { createAppAsyncThunk } from "../create-app-async-thunk"; + +export const categoriesList = createAppAsyncThunk( + `${categories}/list`, + categoriesListApi, +); + +export const categoriesGet = createAppAsyncThunk( + `${categories}/get`, + categoriesGetApi, +); + +export const categoriesGetGrouped = createAppAsyncThunk( + `${categories}/getGrouped`, + categoriesGetGroupedApi, +); + +export const categoriesCreate = createAppAsyncThunk( + `${categories}/create`, + categoriesCreateApi, +); + +export const categoriesUpdate = createAppAsyncThunk( + `${categories}/update`, + categoriesUpdateApi, +); + +export const categoriesDelete = createAppAsyncThunk( + `${categories}/delete`, + categoriesDeleteApi, +); diff --git a/src/store/categories/index.ts b/src/store/categories/index.ts new file mode 100644 index 0000000..9c16e51 --- /dev/null +++ b/src/store/categories/index.ts @@ -0,0 +1,76 @@ +import { createSlice, isAnyOf } from "@reduxjs/toolkit"; +import { categories, categoriesInitialState } from "../../constants/categories"; +import { + categoriesList, + categoriesGetGrouped, + categoriesCreate, + categoriesUpdate, + categoriesDelete, +} from "./async-thunks"; + +export const categoriesSlice = createSlice({ + name: categories, + initialState: categoriesInitialState, + reducers: {}, + extraReducers: ({ addCase, addMatcher }) => { + addCase(categoriesList.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.categories = action.payload.data; + }); + + addCase(categoriesGetGrouped.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.grouped = action.payload.data; + }); + + addCase(categoriesCreate.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.categories.push(action.payload.data); + }); + + addCase(categoriesUpdate.fulfilled, (draft, action) => { + draft.isLoading = false; + const index = draft.categories.findIndex( + (c) => c.id === action.payload.data.id, + ); + if (index !== -1) { + draft.categories[index] = action.payload.data; + } + }); + + addCase(categoriesDelete.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.categories = draft.categories.filter( + (c) => c.id !== action.meta.arg, + ); + }); + + addMatcher( + isAnyOf( + categoriesList.pending, + categoriesGetGrouped.pending, + categoriesCreate.pending, + categoriesUpdate.pending, + categoriesDelete.pending, + ), + (draft) => { + draft.isLoading = true; + draft.error = undefined; + }, + ); + + addMatcher( + isAnyOf( + categoriesList.rejected, + categoriesGetGrouped.rejected, + categoriesCreate.rejected, + categoriesUpdate.rejected, + categoriesDelete.rejected, + ), + (draft, action) => { + draft.isLoading = false; + draft.error = action.error.message || "Произошла ошибка"; + }, + ); + }, +}); diff --git a/src/store/categories/selectors.ts b/src/store/categories/selectors.ts new file mode 100644 index 0000000..ca3eed6 --- /dev/null +++ b/src/store/categories/selectors.ts @@ -0,0 +1,16 @@ +import type { Category, GroupedCategories } from "../../interfaces/categories"; +import type { RootSelector } from "../types"; + +export const categoriesSelector: RootSelector = (state) => + state.categories.categories; + +export const categoriesGroupedSelector: RootSelector< + GroupedCategories | null +> = (state) => state.categories.grouped; + +export const categoriesLoadingSelector: RootSelector = (state) => + state.categories.isLoading; + +export const categoriesErrorSelector: RootSelector = ( + state, +) => state.categories.error; diff --git a/src/store/create-app-async-thunk.ts b/src/store/create-app-async-thunk.ts new file mode 100644 index 0000000..5a1e703 --- /dev/null +++ b/src/store/create-app-async-thunk.ts @@ -0,0 +1,26 @@ +import { + type AsyncThunk, + type AsyncThunkPayloadCreator, + createAsyncThunk, +} from "@reduxjs/toolkit"; +import { type AsyncThunkConfig } from "./types"; + +export const createAppAsyncThunk = ( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator< + Returned, + ThunkArgument, + AsyncThunkConfig + >, +): AsyncThunk => + createAsyncThunk( + typePrefix, + // @ts-expect-error Ошибка типов, не влияет на работу + async (params: ThunkArgument, thunkApi) => { + try { + return await payloadCreator(params, thunkApi); + } catch (error) { + return thunkApi.rejectWithValue(error); + } + }, + ); diff --git a/src/store/create-async-store.ts b/src/store/create-async-store.ts new file mode 100644 index 0000000..61b90b8 --- /dev/null +++ b/src/store/create-async-store.ts @@ -0,0 +1,53 @@ +import { + type ConfigureStoreOptions, + combineReducers, + configureStore, +} from "@reduxjs/toolkit"; +import { listenerMiddleware } from "./listener-middleware"; +import { type ThunkExtraArgument } from "./types"; +import { requestInstance } from "../request-instance"; +import "../store/auth/middlewares"; + +import { loadingSlice } from "./loading"; +import { authSlice } from "./auth"; +import { categoriesSlice } from "./categories"; +import { transactionsSlice } from "./transactions"; +import { budgetsSlice } from "./budgets"; +import { goalsSlice } from "./goals"; +import { recommendationsSlice } from "./recommendations"; +import { analyticsSlice } from "./analytics"; + +const reducers = combineReducers({ + [loadingSlice.name]: loadingSlice.reducer, + [authSlice.name]: authSlice.reducer, + [categoriesSlice.name]: categoriesSlice.reducer, + [transactionsSlice.name]: transactionsSlice.reducer, + [budgetsSlice.name]: budgetsSlice.reducer, + [goalsSlice.name]: goalsSlice.reducer, + [recommendationsSlice.name]: recommendationsSlice.reducer, + [analyticsSlice.name]: analyticsSlice.reducer, +}); + +export const createAsyncStore = ({ + preloadedState, + requestConfig, +}: Pick & ThunkExtraArgument = {}) => { + const store = configureStore({ + preloadedState, + devTools: true, + reducer: reducers, + middleware: (getDefaultMiddleware) => { + const middleware = getDefaultMiddleware({ + thunk: { extraArgument: requestConfig }, + }); + return middleware.concat(listenerMiddleware.middleware); + }, + }); + + requestInstance.interceptors.response.use( + (response) => response, + async (error) => Promise.reject(error), + ); + + return { store }; +}; diff --git a/src/store/goals/async-thunks.ts b/src/store/goals/async-thunks.ts new file mode 100644 index 0000000..6fbf9c9 --- /dev/null +++ b/src/store/goals/async-thunks.ts @@ -0,0 +1,52 @@ +import { + goalsListApi, + goalsGetApi, + goalsCreateApi, + goalsUpdateApi, + goalsDeleteApi, + goalsAddFundsApi, + goalsWithdrawApi, + goalsSummaryApi, + goalsUpcomingApi, +} from "../../api/goals"; +import { goals } from "../../constants/goals"; +import { createAppAsyncThunk } from "../create-app-async-thunk"; + +export const goalsList = createAppAsyncThunk(`${goals}/list`, goalsListApi); + +export const goalsGet = createAppAsyncThunk(`${goals}/get`, goalsGetApi); + +export const goalsCreate = createAppAsyncThunk( + `${goals}/create`, + goalsCreateApi, +); + +export const goalsUpdate = createAppAsyncThunk( + `${goals}/update`, + goalsUpdateApi, +); + +export const goalsDelete = createAppAsyncThunk( + `${goals}/delete`, + goalsDeleteApi, +); + +export const goalsAddFunds = createAppAsyncThunk( + `${goals}/addFunds`, + goalsAddFundsApi, +); + +export const goalsWithdraw = createAppAsyncThunk( + `${goals}/withdraw`, + goalsWithdrawApi, +); + +export const goalsSummary = createAppAsyncThunk( + `${goals}/summary`, + goalsSummaryApi, +); + +export const goalsUpcoming = createAppAsyncThunk( + `${goals}/upcoming`, + goalsUpcomingApi, +); diff --git a/src/store/goals/index.ts b/src/store/goals/index.ts new file mode 100644 index 0000000..5c74056 --- /dev/null +++ b/src/store/goals/index.ts @@ -0,0 +1,108 @@ +import { createSlice, isAnyOf } from "@reduxjs/toolkit"; +import { goals, goalsInitialState } from "../../constants/goals"; +import { + goalsList, + goalsCreate, + goalsUpdate, + goalsDelete, + goalsAddFunds, + goalsWithdraw, + goalsSummary, + goalsUpcoming, +} from "./async-thunks"; + +export const goalsSlice = createSlice({ + name: goals, + initialState: goalsInitialState, + reducers: {}, + extraReducers: ({ addCase, addMatcher }) => { + addCase(goalsList.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.goals = action.payload.data; + }); + + addCase(goalsCreate.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.goals.push(action.payload.data); + }); + + addCase(goalsUpdate.fulfilled, (draft, action) => { + draft.isLoading = false; + const index = draft.goals.findIndex( + (g) => g.id === action.payload.data.id, + ); + if (index !== -1) { + draft.goals[index] = action.payload.data; + } + }); + + addCase(goalsDelete.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.goals = draft.goals.filter((g) => g.id !== action.meta.arg); + }); + + addCase(goalsAddFunds.fulfilled, (draft, action) => { + draft.isLoading = false; + const index = draft.goals.findIndex( + (g) => g.id === action.payload.data.id, + ); + if (index !== -1) { + draft.goals[index] = action.payload.data; + } + }); + + addCase(goalsWithdraw.fulfilled, (draft, action) => { + draft.isLoading = false; + const index = draft.goals.findIndex( + (g) => g.id === action.payload.data.id, + ); + if (index !== -1) { + draft.goals[index] = action.payload.data; + } + }); + + addCase(goalsSummary.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.summary = action.payload.data; + }); + + addCase(goalsUpcoming.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.upcoming = action.payload.data; + }); + + addMatcher( + isAnyOf( + goalsList.pending, + goalsCreate.pending, + goalsUpdate.pending, + goalsDelete.pending, + goalsAddFunds.pending, + goalsWithdraw.pending, + goalsSummary.pending, + goalsUpcoming.pending, + ), + (draft) => { + draft.isLoading = true; + draft.error = undefined; + }, + ); + + addMatcher( + isAnyOf( + goalsList.rejected, + goalsCreate.rejected, + goalsUpdate.rejected, + goalsDelete.rejected, + goalsAddFunds.rejected, + goalsWithdraw.rejected, + goalsSummary.rejected, + goalsUpcoming.rejected, + ), + (draft, action) => { + draft.isLoading = false; + draft.error = action.error.message || "Произошла ошибка"; + }, + ); + }, +}); diff --git a/src/store/goals/selectors.ts b/src/store/goals/selectors.ts new file mode 100644 index 0000000..4b04a7f --- /dev/null +++ b/src/store/goals/selectors.ts @@ -0,0 +1,17 @@ +import type { Goal, GoalsSummary } from "../../interfaces/goals"; +import type { RootSelector } from "../types"; + +export const goalsSelector: RootSelector = (state) => state.goals.goals; + +export const goalsSummarySelector: RootSelector = ( + state, +) => state.goals.summary; + +export const goalsUpcomingSelector: RootSelector = (state) => + state.goals.upcoming; + +export const goalsLoadingSelector: RootSelector = (state) => + state.goals.isLoading; + +export const goalsErrorSelector: RootSelector = (state) => + state.goals.error; diff --git a/src/store/hooks.ts b/src/store/hooks.ts new file mode 100644 index 0000000..d4b079b --- /dev/null +++ b/src/store/hooks.ts @@ -0,0 +1,6 @@ +import { useDispatch, useSelector } from "react-redux"; +import type { TypedUseSelectorHook } from "react-redux"; +import type { RootState, AppDispatch } from "./types"; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..a702417 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,3 @@ +import { createAsyncStore } from "./create-async-store"; + +export const asyncStore = createAsyncStore(); diff --git a/src/store/listener-middleware.ts b/src/store/listener-middleware.ts new file mode 100644 index 0000000..42ecc8a --- /dev/null +++ b/src/store/listener-middleware.ts @@ -0,0 +1,10 @@ +import { createListenerMiddleware } from "@reduxjs/toolkit"; +import type { TypedStartListening } from "@reduxjs/toolkit"; +import type { AppDispatch, RootState } from "./types"; + +export const listenerMiddleware = createListenerMiddleware(); + +export type AppStartListening = TypedStartListening; + +export const startAppListening = + listenerMiddleware.startListening as AppStartListening; diff --git a/src/store/loading/constants.ts b/src/store/loading/constants.ts new file mode 100644 index 0000000..2ae8e97 --- /dev/null +++ b/src/store/loading/constants.ts @@ -0,0 +1,7 @@ +import { loadingEntityAdapter } from "./entity-adapters"; +import type { LoadingState } from "./types"; + +export const loadingDomain = "loading"; + +export const loadingInitialState: LoadingState = + loadingEntityAdapter.getInitialState(); diff --git a/src/store/loading/entity-adapters.ts b/src/store/loading/entity-adapters.ts new file mode 100644 index 0000000..2c825c6 --- /dev/null +++ b/src/store/loading/entity-adapters.ts @@ -0,0 +1,6 @@ +import { createEntityAdapter } from "@reduxjs/toolkit"; +import type { LoadingItem } from "./types"; + +export const loadingEntityAdapter = createEntityAdapter({ + selectId: (loadingItem) => loadingItem.typePrefix, +}); diff --git a/src/store/loading/index.ts b/src/store/loading/index.ts new file mode 100644 index 0000000..4e6caf7 --- /dev/null +++ b/src/store/loading/index.ts @@ -0,0 +1,71 @@ +import { + createSlice, + isFulfilled, + isPending, + isRejected, + type PayloadAction, +} from "@reduxjs/toolkit"; +import { loadingDomain, loadingInitialState } from "./constants"; +import { loadingEntityAdapter } from "./entity-adapters"; +import { LoadingStatus } from "./types"; + +export const getTypePrefixByAction = (action: { + type: string; + meta: { requestStatus: string }; +}): string => + action.type.replace(new RegExp(`/${action.meta.requestStatus}$`), ""); + +export const loadingSlice = createSlice({ + name: loadingDomain, + initialState: loadingInitialState, + reducers: { + reset: (draft, action: PayloadAction) => { + loadingEntityAdapter.removeOne(draft, action.payload); + }, + }, + extraReducers: ({ addMatcher }) => { + addMatcher(isPending, (draft, action) => { + const typePrefix = getTypePrefixByAction(action); + + loadingEntityAdapter.upsertOne(draft, { + typePrefix, + loadingStatus: LoadingStatus.Pending, + error: undefined, + }); + }); + + addMatcher(isFulfilled, (draft, action) => { + const typePrefix = getTypePrefixByAction(action); + + loadingEntityAdapter.upsertOne(draft, { + typePrefix, + loadingStatus: LoadingStatus.Fulfilled, + error: undefined, + }); + }); + + addMatcher(isRejected, (draft, action) => { + const typePrefix = getTypePrefixByAction(action); + + // Type-safe error extraction + let errorMessage: string | undefined; + if (action.payload && typeof action.payload === "object") { + // Handle payload errors (from rejectWithValue) + const payload = action.payload as { + message?: string; + response?: { data?: { message?: string } }; + }; + errorMessage = payload.response?.data?.message || payload.message; + } else { + // Handle standard errors + errorMessage = action.error?.message; + } + + loadingEntityAdapter.upsertOne(draft, { + typePrefix, + loadingStatus: LoadingStatus.Rejected, + error: errorMessage ?? "Ошибка загрузки данных", + }); + }); + }, +}); diff --git a/src/store/loading/selectors.ts b/src/store/loading/selectors.ts new file mode 100644 index 0000000..522a48a --- /dev/null +++ b/src/store/loading/selectors.ts @@ -0,0 +1,27 @@ +import type { RootSelector, RootState } from "../types"; +import { loadingEntityAdapter } from "./entity-adapters"; +import { type LoadingItem, LoadingStatus } from "./types"; +import { createSelector } from "@reduxjs/toolkit"; + +export const loadingEntityAdapterSelectors = + loadingEntityAdapter.getSelectors((state) => state.loading); + +export const loadingSelector = ( + typePrefix: string, +): RootSelector => { + return createSelector( + [(state: RootState) => state.loading], + (loadingState) => { + const item = loadingEntityAdapterSelectors.selectById( + { loading: loadingState } as RootState, + typePrefix, + ); + return ( + item ?? { + typePrefix, + loadingStatus: LoadingStatus.Initial, + } + ); + }, + ); +}; diff --git a/src/store/loading/types.ts b/src/store/loading/types.ts new file mode 100644 index 0000000..8650785 --- /dev/null +++ b/src/store/loading/types.ts @@ -0,0 +1,47 @@ +import type { EntityState, Action } from "@reduxjs/toolkit"; + +export interface IPendingAction extends Action { + meta: { + arg: unknown; + requestId: string; + requestStatus: "pending"; + }; +} + +export interface IFulfilledAction extends Action { + payload: unknown; + meta: { + arg: unknown; + requestId: string; + requestStatus: "fulfilled"; + }; +} + +export interface IRejectedAction extends Action { + error: unknown; + payload?: + | Error + | { message?: string; response?: { data?: { message?: string } } }; + meta: { + arg: unknown; + requestId: string; + requestStatus: "rejected"; + aborted?: boolean; + condition?: boolean; + }; +} + +export enum LoadingStatus { + Initial, + Pending, + Fulfilled, + Rejected, +} + +export interface LoadingItem { + typePrefix: string; + loadingStatus: LoadingStatus; + error?: string; +} + +export type LoadingState = EntityState; diff --git a/src/store/recommendations/async-thunks.ts b/src/store/recommendations/async-thunks.ts new file mode 100644 index 0000000..d33d1b9 --- /dev/null +++ b/src/store/recommendations/async-thunks.ts @@ -0,0 +1,46 @@ +import { + recommendationsListApi, + recommendationsGetApi, + recommendationsGenerateApi, + recommendationsMarkViewedApi, + recommendationsApplyApi, + recommendationsDismissApi, + recommendationsStatsApi, +} from "../../api/recommendations"; +import { recommendations } from "../../constants/recommendations"; +import { createAppAsyncThunk } from "../create-app-async-thunk"; + +export const recommendationsList = createAppAsyncThunk( + `${recommendations}/list`, + recommendationsListApi, +); + +export const recommendationsGet = createAppAsyncThunk( + `${recommendations}/get`, + recommendationsGetApi, +); + +export const recommendationsGenerate = createAppAsyncThunk( + `${recommendations}/generate`, + recommendationsGenerateApi, +); + +export const recommendationsMarkViewed = createAppAsyncThunk( + `${recommendations}/markViewed`, + recommendationsMarkViewedApi, +); + +export const recommendationsApply = createAppAsyncThunk( + `${recommendations}/apply`, + recommendationsApplyApi, +); + +export const recommendationsDismiss = createAppAsyncThunk( + `${recommendations}/dismiss`, + recommendationsDismissApi, +); + +export const recommendationsStats = createAppAsyncThunk( + `${recommendations}/stats`, + recommendationsStatsApi, +); diff --git a/src/store/recommendations/index.ts b/src/store/recommendations/index.ts new file mode 100644 index 0000000..c79f564 --- /dev/null +++ b/src/store/recommendations/index.ts @@ -0,0 +1,95 @@ +import { createSlice, isAnyOf } from "@reduxjs/toolkit"; +import { + recommendations, + recommendationsInitialState, +} from "../../constants/recommendations"; +import { + recommendationsList, + recommendationsGenerate, + recommendationsMarkViewed, + recommendationsApply, + recommendationsDismiss, + recommendationsStats, +} from "./async-thunks"; + +export const recommendationsSlice = createSlice({ + name: recommendations, + initialState: recommendationsInitialState, + reducers: {}, + extraReducers: ({ addCase, addMatcher }) => { + addCase(recommendationsList.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.recommendations = action.payload.data; + }); + + addCase(recommendationsGenerate.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.recommendations = action.payload.data; + }); + + addCase(recommendationsMarkViewed.fulfilled, (draft, action) => { + draft.isLoading = false; + const index = draft.recommendations.findIndex( + (r) => r.id === action.payload.data.id, + ); + if (index !== -1) { + draft.recommendations[index] = action.payload.data; + } + }); + + addCase(recommendationsApply.fulfilled, (draft, action) => { + draft.isLoading = false; + const index = draft.recommendations.findIndex( + (r) => r.id === action.payload.data.id, + ); + if (index !== -1) { + draft.recommendations[index] = action.payload.data; + } + }); + + addCase(recommendationsDismiss.fulfilled, (draft, action) => { + draft.isLoading = false; + const index = draft.recommendations.findIndex( + (r) => r.id === action.payload.data.id, + ); + if (index !== -1) { + draft.recommendations[index] = action.payload.data; + } + }); + + addCase(recommendationsStats.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.stats = action.payload.data; + }); + + addMatcher( + isAnyOf( + recommendationsList.pending, + recommendationsGenerate.pending, + recommendationsMarkViewed.pending, + recommendationsApply.pending, + recommendationsDismiss.pending, + recommendationsStats.pending, + ), + (draft) => { + draft.isLoading = true; + draft.error = undefined; + }, + ); + + addMatcher( + isAnyOf( + recommendationsList.rejected, + recommendationsGenerate.rejected, + recommendationsMarkViewed.rejected, + recommendationsApply.rejected, + recommendationsDismiss.rejected, + recommendationsStats.rejected, + ), + (draft, action) => { + draft.isLoading = false; + draft.error = action.error.message || "Произошла ошибка"; + }, + ); + }, +}); diff --git a/src/store/recommendations/selectors.ts b/src/store/recommendations/selectors.ts new file mode 100644 index 0000000..4b2d172 --- /dev/null +++ b/src/store/recommendations/selectors.ts @@ -0,0 +1,20 @@ +import type { + Recommendation, + RecommendationStats, +} from "../../interfaces/recommendations"; +import type { RootSelector } from "../types"; + +export const recommendationsSelector: RootSelector = ( + state, +) => state.recommendations.recommendations; + +export const recommendationsStatsSelector: RootSelector< + RecommendationStats | null +> = (state) => state.recommendations.stats; + +export const recommendationsLoadingSelector: RootSelector = (state) => + state.recommendations.isLoading; + +export const recommendationsErrorSelector: RootSelector = ( + state, +) => state.recommendations.error; diff --git a/src/store/retry-async-thunk.ts b/src/store/retry-async-thunk.ts new file mode 100644 index 0000000..b5e70bb --- /dev/null +++ b/src/store/retry-async-thunk.ts @@ -0,0 +1,32 @@ +import { + type AsyncThunk, + type AsyncThunkPayloadCreator, + createAsyncThunk, +} from "@reduxjs/toolkit"; +import { isAxiosError } from "axios"; +import type { AsyncThunkConfig } from "./types"; + +let retry = true; +export const createRetryableAsyncThunk = ( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator< + Returned, + ThunkArgument, + AsyncThunkConfig + >, +): AsyncThunk => + createAsyncThunk( + typePrefix, + // @ts-expect-error Ошибка типов, не влияет на работу + async (params: ThunkArgument, thunkApi) => { + try { + return await payloadCreator(params, thunkApi); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 401 && retry) { + retry = false; + return payloadCreator(params, thunkApi); + } + return thunkApi.rejectWithValue(error); + } + }, + ); diff --git a/src/store/transactions/async-thunks.ts b/src/store/transactions/async-thunks.ts new file mode 100644 index 0000000..f193317 --- /dev/null +++ b/src/store/transactions/async-thunks.ts @@ -0,0 +1,52 @@ +import { + transactionsListApi, + transactionsGetApi, + transactionsCreateApi, + transactionsUpdateApi, + transactionsDeleteApi, + transactionsBulkCreateApi, + transactionsSummaryApi, + transactionsByCategoryApi, +} from "../../api/transactions"; +import { transactions } from "../../constants/transactions"; +import { createAppAsyncThunk } from "../create-app-async-thunk"; + +export const transactionsList = createAppAsyncThunk( + `${transactions}/list`, + transactionsListApi, +); + +export const transactionsGet = createAppAsyncThunk( + `${transactions}/get`, + transactionsGetApi, +); + +export const transactionsCreate = createAppAsyncThunk( + `${transactions}/create`, + transactionsCreateApi, +); + +export const transactionsUpdate = createAppAsyncThunk( + `${transactions}/update`, + transactionsUpdateApi, +); + +export const transactionsDelete = createAppAsyncThunk( + `${transactions}/delete`, + transactionsDeleteApi, +); + +export const transactionsBulkCreate = createAppAsyncThunk( + `${transactions}/bulkCreate`, + transactionsBulkCreateApi, +); + +export const transactionsSummary = createAppAsyncThunk( + `${transactions}/summary`, + transactionsSummaryApi, +); + +export const transactionsByCategory = createAppAsyncThunk( + `${transactions}/byCategory`, + transactionsByCategoryApi, +); diff --git a/src/store/transactions/index.ts b/src/store/transactions/index.ts new file mode 100644 index 0000000..7b9b9a3 --- /dev/null +++ b/src/store/transactions/index.ts @@ -0,0 +1,88 @@ +import { createSlice, isAnyOf } from "@reduxjs/toolkit"; +import { + transactions, + transactionsInitialState, +} from "../../constants/transactions"; +import { + transactionsList, + transactionsCreate, + transactionsUpdate, + transactionsDelete, + transactionsSummary, + transactionsByCategory, +} from "./async-thunks"; + +export const transactionsSlice = createSlice({ + name: transactions, + initialState: transactionsInitialState, + reducers: {}, + extraReducers: ({ addCase, addMatcher }) => { + addCase(transactionsList.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.transactions = action.payload.data.data; + draft.meta = action.payload.data.meta; + }); + + addCase(transactionsCreate.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.transactions.unshift(action.payload.data); + }); + + addCase(transactionsUpdate.fulfilled, (draft, action) => { + draft.isLoading = false; + const index = draft.transactions.findIndex( + (t) => t.id === action.payload.data.id, + ); + if (index !== -1) { + draft.transactions[index] = action.payload.data; + } + }); + + addCase(transactionsDelete.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.transactions = draft.transactions.filter( + (t) => t.id !== action.meta.arg, + ); + }); + + addCase(transactionsSummary.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.summary = action.payload.data; + }); + + addCase(transactionsByCategory.fulfilled, (draft, action) => { + draft.isLoading = false; + draft.spendingByCategory = action.payload.data; + }); + + addMatcher( + isAnyOf( + transactionsList.pending, + transactionsCreate.pending, + transactionsUpdate.pending, + transactionsDelete.pending, + transactionsSummary.pending, + transactionsByCategory.pending, + ), + (draft) => { + draft.isLoading = true; + draft.error = undefined; + }, + ); + + addMatcher( + isAnyOf( + transactionsList.rejected, + transactionsCreate.rejected, + transactionsUpdate.rejected, + transactionsDelete.rejected, + transactionsSummary.rejected, + transactionsByCategory.rejected, + ), + (draft, action) => { + draft.isLoading = false; + draft.error = action.error.message || "Произошла ошибка"; + }, + ); + }, +}); diff --git a/src/store/transactions/selectors.ts b/src/store/transactions/selectors.ts new file mode 100644 index 0000000..8bc3c20 --- /dev/null +++ b/src/store/transactions/selectors.ts @@ -0,0 +1,29 @@ +import type { + Transaction, + PaginationMeta, + TransactionSummary, + SpendingByCategory, +} from "../../interfaces/transactions"; +import type { RootSelector } from "../types"; + +export const transactionsSelector: RootSelector = (state) => + state.transactions.transactions; + +export const transactionsMetaSelector: RootSelector = ( + state, +) => state.transactions.meta; + +export const transactionsSummarySelector: RootSelector< + TransactionSummary | null +> = (state) => state.transactions.summary; + +export const transactionsSpendingByCategorySelector: RootSelector< + SpendingByCategory[] +> = (state) => state.transactions.spendingByCategory; + +export const transactionsLoadingSelector: RootSelector = (state) => + state.transactions.isLoading; + +export const transactionsErrorSelector: RootSelector = ( + state, +) => state.transactions.error; diff --git a/src/store/types.ts b/src/store/types.ts new file mode 100644 index 0000000..4f96d04 --- /dev/null +++ b/src/store/types.ts @@ -0,0 +1,29 @@ +import { type AxiosRequestConfig } from "axios"; +import type { asyncStore } from "./index"; + +export type RootStore = typeof asyncStore.store; +export type RootState = ReturnType; +export type RootSelector = (state: RootState) => Data; +export type AppDispatch = RootStore["dispatch"]; + +export enum ApiResponseStatus { + Success, +} + +export interface ApiResponse { + data: Data; + errors?: unknown; + message: string; + status: ApiResponseStatus; +} + +export interface AsyncThunkConfig { + state: RootState; + dispatch: AppDispatch; +} + +export type PartialRootState = Partial; + +export interface ThunkExtraArgument { + requestConfig?: AxiosRequestConfig; +} diff --git a/src/store/use-app-store.ts b/src/store/use-app-store.ts new file mode 100644 index 0000000..b833c4e --- /dev/null +++ b/src/store/use-app-store.ts @@ -0,0 +1,5 @@ +import { useStore } from "react-redux"; + +import { type RootStore } from "./types"; + +export const useAppStore = (): RootStore => useStore() as RootStore; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..c8fd289 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..b7c768c --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..3716779 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], +});