diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..8af0033 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +VITE_MOCK_API_URL=http://localhost:3001 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..785a308 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +coverage +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a10a7d5 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,71 @@ +# Verity — Cadastro (Teste Técnico Front-End) + +Formulário de cadastro em 3 etapas (Dados Pessoais, Informações Residenciais e +Informações Profissionais) com resumo final e exportação em PDF, construído +com React + TypeScript + Vite. + +## Stack + +- **React 19** + **TypeScript** + **Vite** +- **React Hook Form** + **Zod** para validação dos formulários +- **react-router-dom** para navegação entre etapas +- **react-imask** para máscaras de Data de Nascimento, CPF, Telefone e CEP +- **jsPDF** para exportação do resumo em PDF +- **json-server** como serviço mockado para busca de CEP e lista de profissões +- **Vitest** + **Testing Library** para os testes unitários (cobertura ≥ 80%) + +## Como rodar + +Instale as dependências (pnpm): + +```bash +pnpm install +``` + +Suba a aplicação e a API mockada juntas: + +```bash +pnpm dev:all +``` + +Isso inicia o Vite em `http://localhost:5173` e o json-server em +`http://localhost:3001` (dados em `mock/db.json`, com os endpoints +`GET /ceps/:cep` e `GET /professions`). + +Se preferir rodar cada um separadamente: + +```bash +pnpm dev # front-end +pnpm mock:api # API mockada +``` + +## Busca de CEP + +Ao digitar um CEP completo, a aplicação primeiro consulta o json-server +(`/ceps/:cep`). Caso o CEP não exista no mock, ela cai automaticamente para o +ViaCEP (endpoint público dos Correios/ViaCEP) como fallback — cobrindo tanto o +requisito principal quanto o "plus" do desafio. CEPs de exemplo disponíveis no +mock: `01310-100`, `01001-000`, `20040-020`, `30130-010`, `80010-000`, +`90010-000`, `40010-000`, `70040-010`, `60060-170`, `50030-230`. + +## Testes + +```bash +pnpm test # roda a suíte uma vez +pnpm test:watch # modo watch +pnpm test:coverage # com relatório de cobertura (mínimo configurado: 80%) +``` + +## Build + +```bash +pnpm build +pnpm preview +``` + +## Identidade visual + +A paleta (azul/branco) e o wordmark "verity" usados são um placeholder até a +logo oficial da Verity ser fornecida — o componente `Logo` +(`src/components/layout/Logo.tsx`) foi isolado exatamente para facilitar essa +troca depois. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..358e583 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Verity | Cadastro + + +
+ + + diff --git a/frontend/mock/db.json b/frontend/mock/db.json new file mode 100644 index 0000000..9193d88 --- /dev/null +++ b/frontend/mock/db.json @@ -0,0 +1,86 @@ +{ + "ceps": [ + { + "id": "01310100", + "endereco": "Avenida Paulista", + "bairro": "Bela Vista", + "cidade": "São Paulo", + "estado": "SP" + }, + { + "id": "01001000", + "endereco": "Praça da Sé", + "bairro": "Sé", + "cidade": "São Paulo", + "estado": "SP" + }, + { + "id": "20040020", + "endereco": "Avenida Rio Branco", + "bairro": "Centro", + "cidade": "Rio de Janeiro", + "estado": "RJ" + }, + { + "id": "30130010", + "endereco": "Avenida Afonso Pena", + "bairro": "Centro", + "cidade": "Belo Horizonte", + "estado": "MG" + }, + { + "id": "80010000", + "endereco": "Rua XV de Novembro", + "bairro": "Centro", + "cidade": "Curitiba", + "estado": "PR" + }, + { + "id": "90010000", + "endereco": "Rua dos Andradas", + "bairro": "Centro Histórico", + "cidade": "Porto Alegre", + "estado": "RS" + }, + { + "id": "40010000", + "endereco": "Avenida Sete de Setembro", + "bairro": "Centro", + "cidade": "Salvador", + "estado": "BA" + }, + { + "id": "70040010", + "endereco": "Praça dos Três Poderes", + "bairro": "Zona Cívico-Administrativa", + "cidade": "Brasília", + "estado": "DF" + }, + { + "id": "60060170", + "endereco": "Avenida Beira Mar", + "bairro": "Praia de Iracema", + "cidade": "Fortaleza", + "estado": "CE" + }, + { + "id": "50030230", + "endereco": "Avenida Guararapes", + "bairro": "Santo Antônio", + "cidade": "Recife", + "estado": "PE" + } + ], + "professions": [ + { "id": 1, "name": "Desenvolvedor(a) de Software" }, + { "id": 2, "name": "Analista de Sistemas" }, + { "id": 3, "name": "Designer UX/UI" }, + { "id": 4, "name": "Gerente de Projetos" }, + { "id": 5, "name": "Analista Financeiro" }, + { "id": 6, "name": "Advogado(a)" }, + { "id": 7, "name": "Médico(a)" }, + { "id": 8, "name": "Professor(a)" }, + { "id": 9, "name": "Contador(a)" }, + { "id": 10, "name": "Engenheiro(a) Civil" } + ] +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e679a51 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,46 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:all": "run-p dev mock:api", + "mock:api": "json-server --port 3001 mock/db.json", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@hookform/resolvers": "^5.4.0", + "axios": "^1.18.1", + "jspdf": "^4.2.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-hook-form": "^7.81.0", + "react-imask": "^7.6.1", + "react-router-dom": "^7.18.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "@vitest/coverage-v8": "^4.1.10", + "jsdom": "^29.1.1", + "json-server": "1.0.0-beta.15", + "npm-run-all2": "^9.0.2", + "oxlint": "^1.71.0", + "postcss-custom-media": "^12.0.1", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.10" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..f6e1bdd --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,2789 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.4.0(react-hook-form@7.81.0(react@19.2.7)) + axios: + specifier: ^1.18.1 + version: 1.18.1 + jspdf: + specifier: ^4.2.1 + version: 4.2.1 + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + react-hook-form: + specifier: ^7.81.0 + version: 7.81.0(react@19.2.7) + react-imask: + specifier: ^7.6.1 + version: 7.6.1(react@19.2.7) + react-router-dom: + specifier: ^7.18.1 + version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^24.13.2 + version: 24.13.3 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.4(@types/node@24.13.3)) + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + json-server: + specifier: 1.0.0-beta.15 + version: 1.0.0-beta.15 + npm-run-all2: + specifier: ^9.0.2 + version: 9.0.2 + oxlint: + specifier: ^1.71.0 + version: 1.73.0 + postcss-custom-media: + specifier: ^12.0.1 + version: 12.0.1(postcss@8.5.18) + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.1.1 + version: 8.1.4(@types/node@24.13.3) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime-corejs3@7.29.7': + resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/cascade-layer-name-parser@3.0.0': + resolution: {integrity: sha512-/3iksyevwRfSJx5yH0RkcrcYXwuhMQx3Juqf40t97PeEy2/Mz2TItZ/z/216qpe4GgOyFBP8MKIwVvytzHmfIQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@hookform/resolvers@5.4.0': + resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@oxlint/binding-android-arm-eabi@1.73.0': + resolution: {integrity: sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.73.0': + resolution: {integrity: sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.73.0': + resolution: {integrity: sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.73.0': + resolution: {integrity: sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.73.0': + resolution: {integrity: sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.73.0': + resolution: {integrity: sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.73.0': + resolution: {integrity: sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.73.0': + resolution: {integrity: sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.73.0': + resolution: {integrity: sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.73.0': + resolution: {integrity: sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.73.0': + resolution: {integrity: sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.73.0': + resolution: {integrity: sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.73.0': + resolution: {integrity: sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.73.0': + resolution: {integrity: sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.73.0': + resolution: {integrity: sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.73.0': + resolution: {integrity: sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.73.0': + resolution: {integrity: sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.73.0': + resolution: {integrity: sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.73.0': + resolution: {integrity: sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tinyhttp/accepts@2.3.0': + resolution: {integrity: sha512-hdKkMGAUqnagpWO1R8rVBYqbu4sWQ2Fo682gkJmO0nl54DPvnzxx81b2WZtV3VwB7EdLfUoasj2BAkyTcyZ5aw==} + engines: {node: '>=14.13.1'} + + '@tinyhttp/app@3.0.7': + resolution: {integrity: sha512-btit/gSWisksJ19crNLct1mwZvX+/AYwh/H1x8SB/VGDmNFkSsdRDZaa54wW1Eq3bM7mCIr7+l7Oh3tAi/C+Bw==} + engines: {node: '>=16.10.0'} + + '@tinyhttp/content-disposition@2.2.4': + resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==} + engines: {node: '>=12.17.0'} + + '@tinyhttp/content-type@0.1.4': + resolution: {integrity: sha512-dl6f3SHIJPYbhsW1oXdrqOmLSQF/Ctlv3JnNfXAE22kIP7FosqJHxkz/qj2gv465prG8ODKH5KEyhBkvwrueKQ==} + engines: {node: '>=12.4'} + + '@tinyhttp/cookie-signature@2.1.1': + resolution: {integrity: sha512-VDsSMY5OJfQJIAtUgeQYhqMPSZptehFSfvEEtxr+4nldPA8IImlp3QVcOVuK985g4AFR4Hl1sCbWCXoqBnVWnw==} + engines: {node: '>=12.20.0'} + + '@tinyhttp/cookie@2.1.1': + resolution: {integrity: sha512-h/kL9jY0e0Dvad+/QU3efKZww0aTvZJslaHj3JTPmIPC9Oan9+kYqmh3M6L5JUQRuTJYFK2nzgL2iJtH2S+6dA==} + engines: {node: '>=12.20.0'} + + '@tinyhttp/cors@2.0.1': + resolution: {integrity: sha512-qrmo6WJuaiCzKWagv2yA/kw6hIISfF/hOqPWwmI6w0o8apeTMmRN3DoCFvQ/wNVuWVdU5J4KU7OX8aaSOEq51A==} + engines: {node: '>=12.20 || 14.x || >=16'} + + '@tinyhttp/encode-url@2.1.1': + resolution: {integrity: sha512-AhY+JqdZ56qV77tzrBm0qThXORbsVjs/IOPgGCS7x/wWnsa/Bx30zDUU/jPAUcSzNOzt860x9fhdGpzdqbUeUw==} + engines: {node: '>=12.20.0'} + + '@tinyhttp/etag@2.1.2': + resolution: {integrity: sha512-j80fPKimGqdmMh6962y+BtQsnYPVCzZfJw0HXjyH70VaJBHLKGF+iYhcKqzI3yef6QBNa8DKIPsbEYpuwApXTw==} + engines: {node: '>=12.20.0'} + + '@tinyhttp/forwarded@2.1.2': + resolution: {integrity: sha512-9H/eulJ68ElY/+zYpTpNhZ7vxGV+cnwaR6+oQSm7bVgZMyuQfgROW/qvZuhmgDTIxnGMXst+Ba4ij6w6Krcs3w==} + engines: {node: '>=12.20.0'} + + '@tinyhttp/logger@2.1.0': + resolution: {integrity: sha512-Ma1fJ9CwUbn9r61/4HW6+nflsVoslpOnCrfQ6UeZq7GGIgwLzofms3HoSVG7M+AyRMJpxlfcDdbH5oFVroDMKA==} + engines: {node: '>=14.18 || >=16.20'} + + '@tinyhttp/proxy-addr@3.0.1': + resolution: {integrity: sha512-vP0JVsy9ZMIldsaP/QHdMF+sb3B6wn7e2QXRdqpX/Cqz1ie35Am29DK88DeVmiwdTQle3FtYaVNtU3RgTGYZ+w==} + engines: {node: '>=16.10.0'} + + '@tinyhttp/req@2.2.8': + resolution: {integrity: sha512-HCsceFNgMpssUsnRao16iJyyfdWRwKlhL7OMTPUEjZsZGREnBzpjlrPHA31G5xNNzR7XOWVXDXGyrGgSpcwGSA==} + engines: {node: '>=14.13.1'} + + '@tinyhttp/res@2.2.11': + resolution: {integrity: sha512-t7GJzjqpG2svJ11RYvqaYU+xTV9MsEr0usbTIAWa5B8d7IIzpMC6AlT2K6wCIu1XGUYRpZY7qMM5cMgluvsfeg==} + engines: {node: '>=14.13.1'} + + '@tinyhttp/router@2.2.5': + resolution: {integrity: sha512-HI9Mpo9+IVpCzx/36okjJvtvifBSh3Ufhl9n1vylAbNLEykceJiMBkr06+W0qqRlo8TiZeUtg2XinEJu+GFcRA==} + engines: {node: '>=14.0.0'} + + '@tinyhttp/send@2.2.5': + resolution: {integrity: sha512-XhBwziPOCydOJzb9rVw0xuKX6HmMA0gXKHHqec7V97jU6JtSCCIvwW2FBMh/XrG9S5W8DRETroPohASrIwf7Uw==} + engines: {node: '>=14.13.1'} + + '@tinyhttp/type-is@2.2.5': + resolution: {integrity: sha512-BCPEB+NV8v/9lzEE9GbfRPAKVsyayp84m6SSWn70j8yFkPBXeuVeq004pwVrjW1CRdmAZz9ZSH147pqqzAdr5g==} + engines: {node: '>=14.13.1'} + + '@tinyhttp/url@2.1.1': + resolution: {integrity: sha512-POJeq2GQ5jI7Zrdmj22JqOijB5/GeX+LEX7DUdml1hUnGbJOTWDx7zf2b5cCERj7RoXL67zTgyzVblBJC+NJWg==} + engines: {node: '>=12.20.0'} + + '@tinyhttp/vary@0.1.3': + resolution: {integrity: sha512-SoL83sQXAGiHN1jm2VwLUWQSQeDAAl1ywOm6T0b0Cg1CZhVsjoiZadmjhxF6FHCCY7OHHVaLnTgSMxTPIDLxMg==} + engines: {node: '>=12.20'} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js-pure@3.49.0: + resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==} + + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + + dot-prop@10.1.0: + resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} + engines: {node: '>=20'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eta@4.6.0: + resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} + engines: {node: '>=20'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + header-range-parser@1.1.5: + resolution: {integrity: sha512-n5JOx67HBL0MGqtu6NFoEYWb+xDYAOgBI5dBkyMDff1xHbhGnjCMglj1aiMNPHps6HwXO+2i5jbPU/zJSk7etQ==} + engines: {node: '>=12.22.0'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + + http-status-emojis@2.2.0: + resolution: {integrity: sha512-ompKtgwpx8ff0hsbpIB7oE4ax1LXoHmftsHHStMELX56ivG3GhofTX8ZHWlUaFKfGjcGjw6G3rPk7dJRXMmbbg==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + imask@7.6.1: + resolution: {integrity: sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg==} + engines: {npm: '>=4.0.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflection@3.0.2: + resolution: {integrity: sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==} + engines: {node: '>=18.0.0'} + + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + json-parse-even-better-errors@6.0.0: + resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + json-server@1.0.0-beta.15: + resolution: {integrity: sha512-I5UB/OWHLGoQW9IVld2yzZFYYiGYNBn2OYRlGbkfj5xDidT26yKYkV7Sr093zJtPh9zbofaLgyT89Ov5U0RRBQ==} + engines: {node: '>=22.12.0'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lowdb@7.0.1: + resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} + engines: {node: '>=18'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + milliparsec@5.1.1: + resolution: {integrity: sha512-jkEDaSWZp4/Q3vprqdqukBqUEyNNqC1pwTjZ5cp9YkaR1wv5fvTCd8VFsecbw7i8DNBGjzhJ83MDoPZlcTaPQg==} + engines: {node: '>=18.13 || >=19.20 || >=20'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + npm-normalize-package-bin@6.0.0: + resolution: {integrity: sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-run-all2@9.0.2: + resolution: {integrity: sha512-+dd4SO2jAlLE06OzmJKzIe6QvvjXezcbmobnh8usR0a8BzQCABTdqTXqVPji0ICOhSQpIIrkGd7IzNl5iDaRSA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0, npm: '>= 10'} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + oxlint@1.73.0: + resolution: {integrity: sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.24.0' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pidtree@1.0.0: + resolution: {integrity: sha512-avfAvjB9Dd0wdj3rjJX//yS+G79OO0KrS5pJHFJENjYGX6N4SMgEDBBI/yFy0lloOYSaC6XQxzpOAMPfSYFV/Q==} + engines: {node: '>=18'} + hasBin: true + + postcss-custom-media@12.0.1: + resolution: {integrity: sha512-66syE14+VeqkUf0rRX0bvbTCbNRJF132jD+ceo8th1dap2YJEAqpdh5uG98CE3IbgHT7m9XM0GIlOazNWqQdeA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss@8.5.18: + resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-hook-form@7.81.0: + resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-imask@7.6.1: + resolution: {integrity: sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA==} + engines: {npm: '>=4.0.0'} + peerDependencies: + react: '>=0.14.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + read-package-json-fast@6.0.0: + resolution: {integrity: sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regexparam@2.0.2: + resolution: {integrity: sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sort-on@7.0.0: + resolution: {integrity: sha512-e+4RRxt7jsWdGPp4H5PKOER/ELYlemNB1plvW686Qi3j4WVaCjCpro2zaTD7Cn0VtBImq/hg3x1JfovMNXXfJQ==} + engines: {node: '>=20'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + steno@4.0.2: + resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} + engines: {node: '>=18'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.8: + resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} + + tldts@7.4.8: + resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} + hasBin: true + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + + vite@8.1.4: + resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.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 + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@7.0.0: + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime-corejs3@7.29.7': + dependencies: + core-js-pure: 3.49.0 + + '@babel/runtime@7.29.7': {} + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/cascade-layer-name-parser@3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@exodus/bytes@1.15.1': {} + + '@hookform/resolvers@5.4.0(react-hook-form@7.81.0(react@19.2.7))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.81.0(react@19.2.7) + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@oxlint/binding-android-arm-eabi@1.73.0': + optional: true + + '@oxlint/binding-android-arm64@1.73.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.73.0': + optional: true + + '@oxlint/binding-darwin-x64@1.73.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.73.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.73.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.73.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.73.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.73.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.73.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.73.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.73.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.73.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.73.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.73.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.73.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.73.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.73.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.73.0': + optional: true + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tinyhttp/accepts@2.3.0': + dependencies: + mime: 4.1.0 + + '@tinyhttp/app@3.0.7': + dependencies: + '@tinyhttp/accepts': 2.3.0 + '@tinyhttp/cookie': 2.1.1 + '@tinyhttp/proxy-addr': 3.0.1 + '@tinyhttp/req': 2.2.8 + '@tinyhttp/res': 2.2.11 + '@tinyhttp/router': 2.2.5 + regexparam: 2.0.2 + + '@tinyhttp/content-disposition@2.2.4': {} + + '@tinyhttp/content-type@0.1.4': {} + + '@tinyhttp/cookie-signature@2.1.1': {} + + '@tinyhttp/cookie@2.1.1': {} + + '@tinyhttp/cors@2.0.1': + dependencies: + '@tinyhttp/vary': 0.1.3 + + '@tinyhttp/encode-url@2.1.1': {} + + '@tinyhttp/etag@2.1.2': {} + + '@tinyhttp/forwarded@2.1.2': {} + + '@tinyhttp/logger@2.1.0': + dependencies: + colorette: 2.0.20 + dayjs: 1.11.21 + http-status-emojis: 2.2.0 + + '@tinyhttp/proxy-addr@3.0.1': + dependencies: + '@tinyhttp/forwarded': 2.1.2 + + '@tinyhttp/req@2.2.8': + dependencies: + '@tinyhttp/accepts': 2.3.0 + '@tinyhttp/type-is': 2.2.5 + '@tinyhttp/url': 2.1.1 + header-range-parser: 1.1.5 + + '@tinyhttp/res@2.2.11': + dependencies: + '@tinyhttp/content-disposition': 2.2.4 + '@tinyhttp/cookie': 2.1.1 + '@tinyhttp/cookie-signature': 2.1.1 + '@tinyhttp/encode-url': 2.1.1 + '@tinyhttp/req': 2.2.8 + '@tinyhttp/send': 2.2.5 + '@tinyhttp/vary': 0.1.3 + mime: 4.1.0 + + '@tinyhttp/router@2.2.5': + dependencies: + regexparam: 2.0.2 + + '@tinyhttp/send@2.2.5': + dependencies: + '@tinyhttp/content-type': 0.1.4 + '@tinyhttp/etag': 2.1.2 + mime: 4.1.0 + + '@tinyhttp/type-is@2.2.5': + dependencies: + '@tinyhttp/content-type': 0.1.4 + mime: 4.1.0 + + '@tinyhttp/url@2.1.1': {} + + '@tinyhttp/vary@0.1.3': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/pako@2.0.4': {} + + '@types/raf@3.4.3': + optional: true + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@24.13.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.4(@types/node@24.13.3) + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3)) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@24.13.3))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.4(@types/node@24.13.3) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + asynckit@0.4.0: {} + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + base64-arraybuffer@1.0.2: + optional: true + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.29.7 + '@types/raf': 3.4.3 + core-js: 3.49.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + + chai@6.2.2: {} + + chalk@5.6.2: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + core-js-pure@3.49.0: {} + + core-js@3.49.0: + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + optional: true + + dot-prop@10.1.0: + dependencies: + type-fest: 5.8.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + entities@8.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eta@4.6.0: {} + + expect-type@1.4.0: {} + + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.2.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fflate@0.8.3: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + header-range-parser@1.1.5: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + + http-status-emojis@2.2.0: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + imask@7.6.1: + dependencies: + '@babel/runtime-corejs3': 7.29.7 + + indent-string@4.0.0: {} + + inflection@3.0.2: {} + + iobuffer@5.4.0: {} + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + isexe@4.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + json-parse-even-better-errors@6.0.0: {} + + json-server@1.0.0-beta.15: + dependencies: + '@tinyhttp/app': 3.0.7 + '@tinyhttp/cors': 2.0.1 + '@tinyhttp/logger': 2.1.0 + chalk: 5.6.2 + chokidar: 5.0.0 + dot-prop: 10.1.0 + eta: 4.6.0 + inflection: 3.0.2 + json5: 2.2.3 + lowdb: 7.0.1 + milliparsec: 5.1.1 + sirv: 3.0.2 + sort-on: 7.0.0 + + json5@2.2.3: {} + + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.7 + fast-png: 6.4.0 + fflate: 0.8.3 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 3.4.12 + html2canvas: 1.4.1 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lowdb@7.0.1: + dependencies: + steno: 4.0.2 + + lru-cache@11.5.2: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + math-intrinsics@1.1.0: {} + + mdn-data@2.27.1: {} + + memorystream@0.3.1: {} + + milliparsec@5.1.1: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@4.1.0: {} + + min-indent@1.0.1: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + npm-normalize-package-bin@6.0.0: {} + + npm-run-all2@9.0.2: + dependencies: + ansi-styles: 6.2.3 + cross-spawn: 7.0.6 + memorystream: 0.3.1 + picomatch: 4.0.5 + pidtree: 1.0.0 + read-package-json-fast: 6.0.0 + shell-quote: 1.10.0 + which: 7.0.0 + + object-assign@4.1.1: {} + + obug@2.1.3: {} + + oxlint@1.73.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.73.0 + '@oxlint/binding-android-arm64': 1.73.0 + '@oxlint/binding-darwin-arm64': 1.73.0 + '@oxlint/binding-darwin-x64': 1.73.0 + '@oxlint/binding-freebsd-x64': 1.73.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.73.0 + '@oxlint/binding-linux-arm-musleabihf': 1.73.0 + '@oxlint/binding-linux-arm64-gnu': 1.73.0 + '@oxlint/binding-linux-arm64-musl': 1.73.0 + '@oxlint/binding-linux-ppc64-gnu': 1.73.0 + '@oxlint/binding-linux-riscv64-gnu': 1.73.0 + '@oxlint/binding-linux-riscv64-musl': 1.73.0 + '@oxlint/binding-linux-s390x-gnu': 1.73.0 + '@oxlint/binding-linux-x64-gnu': 1.73.0 + '@oxlint/binding-linux-x64-musl': 1.73.0 + '@oxlint/binding-openharmony-arm64': 1.73.0 + '@oxlint/binding-win32-arm64-msvc': 1.73.0 + '@oxlint/binding-win32-ia32-msvc': 1.73.0 + '@oxlint/binding-win32-x64-msvc': 1.73.0 + + pako@2.2.0: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + performance-now@2.1.0: + optional: true + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pidtree@1.0.0: {} + + postcss-custom-media@12.0.1(postcss@8.5.18): + dependencies: + '@csstools/cascade-layer-name-parser': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + postcss: 8.5.18 + + postcss@8.5.18: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-hook-form@7.81.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-imask@7.6.1(react@19.2.7): + dependencies: + imask: 7.6.1 + prop-types: 15.8.1 + react: 19.2.7 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + + read-package-json-fast@6.0.0: + dependencies: + json-parse-even-better-errors: 6.0.0 + npm-normalize-package-bin: 6.0.0 + + readdirp@5.0.0: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + regenerator-runtime@0.13.11: + optional: true + + regexparam@2.0.2: {} + + require-from-string@2.0.2: {} + + rgbcolor@1.0.1: + optional: true + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.10.0: {} + + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sort-on@7.0.0: + dependencies: + dot-prop: 10.1.0 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + stackblur-canvas@2.7.0: + optional: true + + std-env@4.2.0: {} + + steno@4.0.2: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + svg-pathdata@6.0.3: + optional: true + + symbol-tree@3.2.4: {} + + tagged-tag@1.0.0: {} + + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + tldts-core@7.4.8: {} + + tldts@7.4.8: + dependencies: + tldts-core: 7.4.8 + + totalist@3.0.1: {} + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.8 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: + optional: true + + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + undici@7.28.0: {} + + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + + vite@8.1.4(@types/node@24.13.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.18 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@24.13.3)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.4(@types/node@24.13.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@7.0.0: + dependencies: + isexe: 4.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + zod@4.4.3: {} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..68b6296 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,5 @@ +import postcssCustomMedia from 'postcss-custom-media' + +export default { + plugins: [postcssCustomMedia()], +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..0e2eae1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/logo.avif b/frontend/public/logo.avif new file mode 100644 index 0000000..9d99f29 Binary files /dev/null and b/frontend/public/logo.avif differ diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..882f45e --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,69 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import App from './App' +import { fetchAddressByCep, fetchProfessions } from './lib/api' +import { generateSummaryPdf } from './lib/pdf' + +vi.mock('./lib/api', () => ({ + fetchAddressByCep: vi.fn(), + fetchProfessions: vi.fn(), +})) +vi.mock('./lib/pdf', () => ({ generateSummaryPdf: vi.fn() })) + +beforeEach(() => { + vi.mocked(fetchAddressByCep).mockReset() + vi.mocked(fetchProfessions).mockReset() + vi.mocked(generateSummaryPdf).mockReset() + window.localStorage.clear() + window.history.pushState({}, '', '/') +}) + +describe('App', () => { + it('completes the full wizard flow end-to-end and exports the PDF', async () => { + vi.mocked(fetchAddressByCep).mockResolvedValue({ + endereco: 'Avenida Paulista', + bairro: 'Bela Vista', + cidade: 'São Paulo', + estado: 'SP', + }) + vi.mocked(fetchProfessions).mockResolvedValue([{ id: 1, name: 'Desenvolvedor(a) de Software' }]) + + const user = userEvent.setup() + render() + + expect(await screen.findByRole('heading', { name: 'Dados Pessoais' })).toBeInTheDocument() + + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza') + await user.type(screen.getByLabelText('Data de nascimento'), '15051990') + await user.type(screen.getByLabelText('CPF'), '52998224725') + await user.type(screen.getByLabelText('Telefone'), '11987654321') + await user.type(screen.getByLabelText('E-mail'), 'maria@example.com') + await user.click(screen.getByText('Próximo')) + + expect(await screen.findByText('Informações Residenciais')).toBeInTheDocument() + await user.type(screen.getByLabelText('CEP'), '01310100') + await screen.findByDisplayValue('Avenida Paulista') + await user.click(screen.getByText('Próximo')) + + expect(await screen.findByText('Informações Profissionais')).toBeInTheDocument() + await screen.findByRole('option', { name: 'Desenvolvedor(a) de Software' }) + await user.type(screen.getByLabelText('Empresa'), 'Verity Tecnologia') + await user.selectOptions(screen.getByLabelText('Profissão'), 'Desenvolvedor(a) de Software') + await user.type(screen.getByLabelText('Salário'), '850000') + await user.selectOptions(screen.getByLabelText('Tempo de empresa'), 'De 2 a 5 anos') + await user.click(screen.getByText('Ver resumo')) + + expect(await screen.findByText('Resumo do Cadastro')).toBeInTheDocument() + expect(screen.getByText('Maria Souza')).toBeInTheDocument() + + await user.click(screen.getByText('Exportar PDF')) + expect(generateSummaryPdf).toHaveBeenCalledOnce() + }) + + it('redirects unknown routes to step 1', async () => { + window.history.pushState({}, '', '/rota-invalida') + render() + expect(await screen.findByRole('heading', { name: 'Dados Pessoais' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..7ba0cbb --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,28 @@ +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { Layout } from './components/layout/Layout' +import { WizardFormProvider } from './context/WizardFormProvider' +import { Step1PersonalData } from './pages/Step1PersonalData' +import { Step2Address } from './pages/Step2Address' +import { Step3Professional } from './pages/Step3Professional' +import { SummaryPage } from './pages/SummaryPage' + +function App() { + return ( + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} + +export default App diff --git a/frontend/src/components/form/CurrencyField.test.tsx b/frontend/src/components/form/CurrencyField.test.tsx new file mode 100644 index 0000000..d2c431b --- /dev/null +++ b/frontend/src/components/form/CurrencyField.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithForm } from '../../test/renderWithForm' +import { CurrencyField } from './CurrencyField' + +describe('CurrencyField', () => { + it('formats typed digits as BRL currency', async () => { + const user = userEvent.setup() + renderWithForm() + + const input = screen.getByLabelText('Salário') + await user.type(input, '850050') + + expect(input).toHaveValue('R$ 8.500,50') + }) + + it('removes the last typed digit on backspace', async () => { + const user = userEvent.setup() + renderWithForm() + + const input = screen.getByLabelText('Salário') + await user.type(input, '1000') + expect(input).toHaveValue('R$ 10,00') + + await user.type(input, '{backspace}') + + expect(input).toHaveValue('R$ 1,00') + }) + + it('clears back to empty once every digit is removed', async () => { + const user = userEvent.setup() + renderWithForm() + + const input = screen.getByLabelText('Salário') + await user.type(input, '5') + expect(input).toHaveValue('R$ 0,05') + + await user.type(input, '{backspace}') + + expect(input).toHaveValue('') + }) +}) diff --git a/frontend/src/components/form/CurrencyField.tsx b/frontend/src/components/form/CurrencyField.tsx new file mode 100644 index 0000000..2206696 --- /dev/null +++ b/frontend/src/components/form/CurrencyField.tsx @@ -0,0 +1,56 @@ +import { Controller, useFormContext } from 'react-hook-form' +import type { FormValues } from '../../schemas/formSchema' +import { onlyDigits } from '../../lib/masks' + +const NON_BREAKING_SPACE = ' ' + +function formatBRLFromDigits(digits: string): string { + const numeric = Number(digits || '0') / 100 + return numeric + .toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) + .replaceAll(NON_BREAKING_SPACE, ' ') +} + +interface CurrencyFieldProps { + name: keyof FormValues + label: string + fullWidth?: boolean +} + +export function CurrencyField({ name, label, fullWidth }: Readonly) { + const { + control, + formState: { errors }, + } = useFormContext() + const error = errors[name]?.message as string | undefined + + return ( +
+ + ( + { + const digits = onlyDigits(event.target.value).replace(/^0+/, '').slice(0, 12) + field.onChange(digits ? formatBRLFromDigits(digits) : '') + }} + onBlur={field.onBlur} + /> + )} + /> + {error && ( + + {error} + + )} +
+ ) +} diff --git a/frontend/src/components/form/MaskedTextField.test.tsx b/frontend/src/components/form/MaskedTextField.test.tsx new file mode 100644 index 0000000..0625150 --- /dev/null +++ b/frontend/src/components/form/MaskedTextField.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithForm } from '../../test/renderWithForm' +import { MaskedTextField } from './MaskedTextField' +import { CPF_MASK, PHONE_MASK } from '../../constants/masks' + +describe('MaskedTextField', () => { + it('applies a fixed pattern mask as the user types', async () => { + const user = userEvent.setup() + renderWithForm() + + const input = screen.getByLabelText('CPF') + await user.type(input, '52998224725') + + expect(input).toHaveValue('529.982.247-25') + }) + + it('picks the right mask variant for a dynamic pattern', async () => { + const user = userEvent.setup() + renderWithForm() + + const input = screen.getByLabelText('Telefone') + await user.type(input, '11987654321') + + expect(input).toHaveValue('(11) 98765-4321') + }) +}) diff --git a/frontend/src/components/form/MaskedTextField.tsx b/frontend/src/components/form/MaskedTextField.tsx new file mode 100644 index 0000000..50dec08 --- /dev/null +++ b/frontend/src/components/form/MaskedTextField.tsx @@ -0,0 +1,60 @@ +import { Controller, useFormContext } from 'react-hook-form' +import { IMaskInput } from 'react-imask' +import type { FormValues } from '../../schemas/formSchema' + +interface MaskedTextFieldProps { + name: keyof FormValues + label: string + maskOptions: Record + placeholder?: string + inputMode?: 'text' | 'numeric' | 'decimal' + hint?: string + fullWidth?: boolean +} + +export function MaskedTextField({ + name, + label, + maskOptions, + placeholder, + inputMode, + hint, + fullWidth, +}: Readonly) { + const { + control, + formState: { errors }, + } = useFormContext() + const error = errors[name]?.message as string | undefined + + return ( +
+ + ( + field.onChange(value)} + onBlur={field.onBlur} + /> + )} + /> + {error && ( + + {error} + + )} + {!error && hint && {hint}} +
+ ) +} diff --git a/frontend/src/components/form/SelectField.test.tsx b/frontend/src/components/form/SelectField.test.tsx new file mode 100644 index 0000000..3393ff8 --- /dev/null +++ b/frontend/src/components/form/SelectField.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithForm } from '../../test/renderWithForm' +import { SelectField } from './SelectField' + +const options = [ + { value: 'SP', label: 'São Paulo' }, + { value: 'RJ', label: 'Rio de Janeiro' }, +] + +describe('SelectField', () => { + it('renders the placeholder plus every option', () => { + renderWithForm() + + expect(screen.getByRole('option', { name: 'UF' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'São Paulo' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Rio de Janeiro' })).toBeInTheDocument() + }) + + it('lets the user pick an option', async () => { + const user = userEvent.setup() + renderWithForm() + + await user.selectOptions(screen.getByLabelText('Estado'), 'RJ') + + expect(screen.getByLabelText('Estado')).toHaveValue('RJ') + }) + + it('can be disabled while options are loading', () => { + renderWithForm() + expect(screen.getByLabelText('Profissão')).toBeDisabled() + }) +}) diff --git a/frontend/src/components/form/SelectField.tsx b/frontend/src/components/form/SelectField.tsx new file mode 100644 index 0000000..3bc11c6 --- /dev/null +++ b/frontend/src/components/form/SelectField.tsx @@ -0,0 +1,48 @@ +import { useFormContext } from 'react-hook-form' +import type { FormValues } from '../../schemas/formSchema' +import type { SelectOption } from '../../constants/options' + +interface SelectFieldProps { + name: keyof FormValues + label: string + options: SelectOption[] + placeholder?: string + disabled?: boolean + fullWidth?: boolean +} + +export function SelectField({ name, label, options, placeholder, disabled, fullWidth }: Readonly) { + const { + register, + formState: { errors }, + } = useFormContext() + const error = errors[name]?.message as string | undefined + + return ( +
+ + + {error && ( + + {error} + + )} +
+ ) +} diff --git a/frontend/src/components/form/TextField.test.tsx b/frontend/src/components/form/TextField.test.tsx new file mode 100644 index 0000000..23bd939 --- /dev/null +++ b/frontend/src/components/form/TextField.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithForm } from '../../test/renderWithForm' +import { TextField } from './TextField' + +describe('TextField', () => { + it('renders a label wired to the input', () => { + renderWithForm() + expect(screen.getByLabelText('Nome completo')).toBeInTheDocument() + }) + + it('lets the user type into the field', async () => { + const user = userEvent.setup() + renderWithForm() + + const input = screen.getByLabelText('Nome completo') + await user.type(input, 'Maria Souza') + + expect(input).toHaveValue('Maria Souza') + }) +}) diff --git a/frontend/src/components/form/TextField.tsx b/frontend/src/components/form/TextField.tsx new file mode 100644 index 0000000..fc345a3 --- /dev/null +++ b/frontend/src/components/form/TextField.tsx @@ -0,0 +1,49 @@ +import { useFormContext } from 'react-hook-form' +import type { FormValues } from '../../schemas/formSchema' + +interface TextFieldProps { + name: keyof FormValues + label: string + placeholder?: string + type?: string + autoComplete?: string + fullWidth?: boolean + readOnly?: boolean +} + +export function TextField({ + name, + label, + placeholder, + type = 'text', + autoComplete, + fullWidth, + readOnly, +}: Readonly) { + const { + register, + formState: { errors }, + } = useFormContext() + const error = errors[name]?.message as string | undefined + + return ( +
+ + + {error && ( + + {error} + + )} +
+ ) +} diff --git a/frontend/src/components/layout/Layout.test.tsx b/frontend/src/components/layout/Layout.test.tsx new file mode 100644 index 0000000..c29adb2 --- /dev/null +++ b/frontend/src/components/layout/Layout.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { Layout } from './Layout' + +describe('Layout', () => { + it('renders the logo, step indicator and page content', () => { + render( + + +

conteúdo da etapa

+
+
, + ) + + expect(screen.getByAltText('Verity')).toBeInTheDocument() + expect(screen.getByText('Dados Pessoais')).toBeInTheDocument() + expect(screen.getByText('conteúdo da etapa')).toBeInTheDocument() + expect(screen.getByText(/Verity\. Todos os direitos reservados\./)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..273636c --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' +import { Logo } from './Logo' +import { StepIndicator } from '../wizard/StepIndicator' + +export function Layout({ children }: { children: ReactNode }) { + return ( +
+
+ +
+ +
+
{children}
+
+
+

© {new Date().getFullYear()} Verity. Todos os direitos reservados.

+
+
+ ) +} diff --git a/frontend/src/components/layout/Logo.test.tsx b/frontend/src/components/layout/Logo.test.tsx new file mode 100644 index 0000000..17737b3 --- /dev/null +++ b/frontend/src/components/layout/Logo.test.tsx @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Logo } from './Logo' + +describe('Logo', () => { + it('renders the Verity logo image', () => { + render() + const image = screen.getByAltText('Verity') + expect(image).toBeInTheDocument() + expect(image).toHaveAttribute('src', '/logo.avif') + }) +}) diff --git a/frontend/src/components/layout/Logo.tsx b/frontend/src/components/layout/Logo.tsx new file mode 100644 index 0000000..e2196dd --- /dev/null +++ b/frontend/src/components/layout/Logo.tsx @@ -0,0 +1,7 @@ +export function Logo() { + return ( +
+ Verity +
+ ) +} diff --git a/frontend/src/components/wizard/StepIndicator.test.tsx b/frontend/src/components/wizard/StepIndicator.test.tsx new file mode 100644 index 0000000..f9c9cfa --- /dev/null +++ b/frontend/src/components/wizard/StepIndicator.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { StepIndicator } from './StepIndicator' + +describe('StepIndicator', () => { + it('marks the current step as active and earlier steps as done', () => { + render( + + + , + ) + + expect(screen.getByText('Endereço').closest('li')).toHaveClass('step-active') + expect(screen.getByText('Dados Pessoais').closest('li')).toHaveClass('step-done') + expect(screen.getByText('Profissional').closest('li')).toHaveClass('step-pending') + }) + + it('renders every step label', () => { + render( + + + , + ) + + expect(screen.getByText('Resumo').closest('li')).toHaveClass('step-active') + }) +}) diff --git a/frontend/src/components/wizard/StepIndicator.tsx b/frontend/src/components/wizard/StepIndicator.tsx new file mode 100644 index 0000000..5f30497 --- /dev/null +++ b/frontend/src/components/wizard/StepIndicator.tsx @@ -0,0 +1,27 @@ +import { useLocation } from 'react-router-dom' + +const STEPS = [ + { path: '/etapa-1', label: 'Dados Pessoais' }, + { path: '/etapa-2', label: 'Endereço' }, + { path: '/etapa-3', label: 'Profissional' }, + { path: '/resumo', label: 'Resumo' }, +] + +export function StepIndicator() { + const { pathname } = useLocation() + const currentIndex = STEPS.findIndex((step) => step.path === pathname) + + return ( +
    + {STEPS.map((step, index) => { + const status = index < currentIndex ? 'done' : index === currentIndex ? 'active' : 'pending' + return ( +
  1. + {status === 'done' ? '✓' : index + 1} + {step.label} +
  2. + ) + })} +
+ ) +} diff --git a/frontend/src/components/wizard/SummarySection.test.tsx b/frontend/src/components/wizard/SummarySection.test.tsx new file mode 100644 index 0000000..1a5e473 --- /dev/null +++ b/frontend/src/components/wizard/SummarySection.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SummarySection } from './SummarySection' + +describe('SummarySection', () => { + it('renders every item label and value', () => { + render( + {}} + items={[ + { label: 'Nome completo', value: 'Maria Souza' }, + { label: 'E-mail', value: '' }, + ]} + />, + ) + + expect(screen.getByText('Dados Pessoais')).toBeInTheDocument() + expect(screen.getByText('Nome completo')).toBeInTheDocument() + expect(screen.getByText('Maria Souza')).toBeInTheDocument() + expect(screen.getByText('—')).toBeInTheDocument() + }) + + it('calls onEdit when the edit button is clicked', async () => { + const user = userEvent.setup() + const onEdit = vi.fn() + render() + + await user.click(screen.getByText('Editar')) + + expect(onEdit).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/wizard/SummarySection.tsx b/frontend/src/components/wizard/SummarySection.tsx new file mode 100644 index 0000000..a765e3c --- /dev/null +++ b/frontend/src/components/wizard/SummarySection.tsx @@ -0,0 +1,31 @@ +interface SummaryItem { + label: string + value: string +} + +interface SummarySectionProps { + title: string + items: SummaryItem[] + onEdit: () => void +} + +export function SummarySection({ title, items, onEdit }: Readonly) { + return ( +
+
+

{title}

+ +
+
+ {items.map((item) => ( +
+
{item.label}
+
{item.value || '—'}
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/wizard/WizardActions.test.tsx b/frontend/src/components/wizard/WizardActions.test.tsx new file mode 100644 index 0000000..3d7af68 --- /dev/null +++ b/frontend/src/components/wizard/WizardActions.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { WizardActions } from './WizardActions' + +describe('WizardActions', () => { + it('omits the back button when onBack is not provided', () => { + render( {}} />) + expect(screen.queryByText('Voltar')).not.toBeInTheDocument() + expect(screen.getByText('Próximo')).toBeInTheDocument() + }) + + it('calls the provided callbacks', async () => { + const user = userEvent.setup() + const onNext = vi.fn() + const onBack = vi.fn() + render() + + await user.click(screen.getByText('Voltar')) + await user.click(screen.getByText('Ver resumo')) + + expect(onBack).toHaveBeenCalledOnce() + expect(onNext).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/wizard/WizardActions.tsx b/frontend/src/components/wizard/WizardActions.tsx new file mode 100644 index 0000000..6f7af0b --- /dev/null +++ b/frontend/src/components/wizard/WizardActions.tsx @@ -0,0 +1,21 @@ +interface WizardActionsProps { + onBack?: () => void + onNext: () => void + nextLabel?: string + backLabel?: string +} + +export function WizardActions({ onBack, onNext, nextLabel = 'Próximo', backLabel = 'Voltar' }: Readonly) { + return ( +
+ {onBack && ( + + )} + +
+ ) +} diff --git a/frontend/src/components/wizard/WizardStepShell.test.tsx b/frontend/src/components/wizard/WizardStepShell.test.tsx new file mode 100644 index 0000000..3928fd1 --- /dev/null +++ b/frontend/src/components/wizard/WizardStepShell.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { WizardStepShell } from './WizardStepShell' + +describe('WizardStepShell', () => { + it('renders the title, optional description and children', () => { + render( + +

conteúdo

+
, + ) + + expect(screen.getByRole('heading', { name: 'Dados Pessoais' })).toBeInTheDocument() + expect(screen.getByText('Preencha seus dados')).toBeInTheDocument() + expect(screen.getByText('conteúdo')).toBeInTheDocument() + }) + + it('omits the description when not provided', () => { + render( + +

conteúdo

+
, + ) + + expect(screen.getByRole('heading', { name: 'Resumo' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/wizard/WizardStepShell.tsx b/frontend/src/components/wizard/WizardStepShell.tsx new file mode 100644 index 0000000..d8504c8 --- /dev/null +++ b/frontend/src/components/wizard/WizardStepShell.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' + +interface WizardStepShellProps { + title: string + description?: string + children: ReactNode +} + +export function WizardStepShell({ title, description, children }: Readonly) { + return ( +
+

{title}

+ {description &&

{description}

} + {children} +
+ ) +} diff --git a/frontend/src/constants/breakpoints.css b/frontend/src/constants/breakpoints.css new file mode 100644 index 0000000..36d0003 --- /dev/null +++ b/frontend/src/constants/breakpoints.css @@ -0,0 +1,2 @@ +@custom-media --breakpoint-tablet (min-width: 40rem); +@custom-media --breakpoint-mobile (max-width: 30rem); diff --git a/frontend/src/constants/colors.css b/frontend/src/constants/colors.css new file mode 100644 index 0000000..bf70e28 --- /dev/null +++ b/frontend/src/constants/colors.css @@ -0,0 +1,18 @@ +:root { + --verity-blue-900: #082b63; + --verity-blue-700: #0d47a1; + --verity-blue-600: #1257b8; + --verity-blue-500: #1e6fd9; + --verity-blue-100: #e8f1fc; + --verity-blue-50: #f4f8fe; + + --color-white: #ffffff; + --color-gray-900: #1a1f29; + --color-gray-600: #5b6472; + --color-gray-400: #8b93a1; + --color-gray-200: #e2e6ec; + --color-gray-100: #f1f3f7; + + --color-error: #c62828; + --color-success: #1e8e3e; +} diff --git a/frontend/src/constants/masks.ts b/frontend/src/constants/masks.ts new file mode 100644 index 0000000..d904a11 --- /dev/null +++ b/frontend/src/constants/masks.ts @@ -0,0 +1,9 @@ +export const CPF_MASK = { mask: '000.000.000-00' } + +export const DATE_MASK = { mask: '00/00/0000' } + +export const CEP_MASK = { mask: '00000-000' } + +export const PHONE_MASK = { + mask: [{ mask: '(00) 0000-0000' }, { mask: '(00) 00000-0000' }], +} diff --git a/frontend/src/constants/options.test.ts b/frontend/src/constants/options.test.ts new file mode 100644 index 0000000..5c1ba0a --- /dev/null +++ b/frontend/src/constants/options.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { getOptionLabel, TEMPO_EMPRESA_OPTIONS, UF_OPTIONS } from './options' + +describe('getOptionLabel', () => { + it('returns the matching label', () => { + expect(getOptionLabel(UF_OPTIONS, 'SP')).toBe('São Paulo') + expect(getOptionLabel(TEMPO_EMPRESA_OPTIONS, '2-5-anos')).toBe('De 2 a 5 anos') + }) + + it('falls back to the raw value when there is no match', () => { + expect(getOptionLabel(UF_OPTIONS, 'ZZ')).toBe('ZZ') + }) +}) diff --git a/frontend/src/constants/options.ts b/frontend/src/constants/options.ts new file mode 100644 index 0000000..79682b1 --- /dev/null +++ b/frontend/src/constants/options.ts @@ -0,0 +1,46 @@ +export interface SelectOption { + value: string + label: string +} + +export const UF_OPTIONS: SelectOption[] = [ + { value: 'AC', label: 'Acre' }, + { value: 'AL', label: 'Alagoas' }, + { value: 'AP', label: 'Amapá' }, + { value: 'AM', label: 'Amazonas' }, + { value: 'BA', label: 'Bahia' }, + { value: 'CE', label: 'Ceará' }, + { value: 'DF', label: 'Distrito Federal' }, + { value: 'ES', label: 'Espírito Santo' }, + { value: 'GO', label: 'Goiás' }, + { value: 'MA', label: 'Maranhão' }, + { value: 'MT', label: 'Mato Grosso' }, + { value: 'MS', label: 'Mato Grosso do Sul' }, + { value: 'MG', label: 'Minas Gerais' }, + { value: 'PA', label: 'Pará' }, + { value: 'PB', label: 'Paraíba' }, + { value: 'PR', label: 'Paraná' }, + { value: 'PE', label: 'Pernambuco' }, + { value: 'PI', label: 'Piauí' }, + { value: 'RJ', label: 'Rio de Janeiro' }, + { value: 'RN', label: 'Rio Grande do Norte' }, + { value: 'RS', label: 'Rio Grande do Sul' }, + { value: 'RO', label: 'Rondônia' }, + { value: 'RR', label: 'Roraima' }, + { value: 'SC', label: 'Santa Catarina' }, + { value: 'SP', label: 'São Paulo' }, + { value: 'SE', label: 'Sergipe' }, + { value: 'TO', label: 'Tocantins' }, +] + +export const TEMPO_EMPRESA_OPTIONS: SelectOption[] = [ + { value: 'menos-6-meses', label: 'Menos de 6 meses' }, + { value: '6-meses-1-ano', label: 'De 6 meses a 1 ano' }, + { value: '1-2-anos', label: 'De 1 a 2 anos' }, + { value: '2-5-anos', label: 'De 2 a 5 anos' }, + { value: 'mais-5-anos', label: 'Mais de 5 anos' }, +] + +export function getOptionLabel(options: SelectOption[], value: string): string { + return options.find((option) => option.value === value)?.label ?? value +} diff --git a/frontend/src/constants/spacing.css b/frontend/src/constants/spacing.css new file mode 100644 index 0000000..8fd562f --- /dev/null +++ b/frontend/src/constants/spacing.css @@ -0,0 +1,14 @@ +:root { + --space-0: 0.125rem; + --space-1: 0.25rem; + --space-2: 0.375rem; + --space-3: 0.5rem; + --space-4: 0.625rem; + --space-5: 0.75rem; + --space-6: 1rem; + --space-7: 1.125rem; + --space-8: 1.25rem; + --space-9: 1.5rem; + --space-10: 2rem; + --space-11: 3rem; +} diff --git a/frontend/src/context/WizardFormProvider.test.tsx b/frontend/src/context/WizardFormProvider.test.tsx new file mode 100644 index 0000000..cb266ae --- /dev/null +++ b/frontend/src/context/WizardFormProvider.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useFormContext } from 'react-hook-form' +import { WizardFormProvider } from './WizardFormProvider' +import type { FormValues } from '../schemas/formSchema' +import { persistFormValues } from '../lib/storage' + +function FullNameProbe() { + const { register, watch } = useFormContext() + return ( +
+ + valor: {watch('fullName')} +
+ ) +} + +describe('WizardFormProvider', () => { + it('starts from the default empty values when nothing is stored', () => { + render( + + + , + ) + + expect(screen.getByText('valor:')).toBeInTheDocument() + }) + + it('hydrates from previously persisted values', () => { + persistFormValues({ fullName: 'Maria Souza' }) + + render( + + + , + ) + + expect(screen.getByLabelText('Nome completo')).toHaveValue('Maria Souza') + }) + + it('persists changes to localStorage as the user types', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.type(screen.getByLabelText('Nome completo'), 'Ana') + + expect(JSON.parse(window.localStorage.getItem('verity:cadastro-wizard') ?? '{}').fullName).toBe('Ana') + }) +}) diff --git a/frontend/src/context/WizardFormProvider.tsx b/frontend/src/context/WizardFormProvider.tsx new file mode 100644 index 0000000..2a24c89 --- /dev/null +++ b/frontend/src/context/WizardFormProvider.tsx @@ -0,0 +1,26 @@ +import { useEffect, type ReactNode } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { defaultFormValues, formSchema, type FormValues } from '../schemas/formSchema' +import { loadStoredFormValues, persistFormValues } from '../lib/storage' + +export function WizardFormProvider({ children }: { children: ReactNode }) { + const methods = useForm({ + resolver: zodResolver(formSchema), + mode: 'onSubmit', + reValidateMode: 'onChange', + defaultValues: { + ...defaultFormValues, + ...loadStoredFormValues(), + }, + }) + + useEffect(() => { + const subscription = methods.watch((values) => { + persistFormValues(values) + }) + return () => subscription.unsubscribe() + }, [methods]) + + return {children} +} diff --git a/frontend/src/hooks/useRequireStepData.test.tsx b/frontend/src/hooks/useRequireStepData.test.tsx new file mode 100644 index 0000000..ecedb4b --- /dev/null +++ b/frontend/src/hooks/useRequireStepData.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { Route, Routes } from 'react-router-dom' +import { renderWithForm } from '../test/renderWithForm' +import { useRequireStepData } from './useRequireStepData' + +function GuardedStep() { + useRequireStepData('fullName', '/etapa-1') + return

conteúdo protegido

+} + +describe('useRequireStepData', () => { + it('redirects when the required field is empty', () => { + renderWithForm( + + } /> + etapa 1

} /> +
, + { initialPath: '/etapa-2' }, + ) + + expect(screen.getByText('etapa 1')).toBeInTheDocument() + expect(screen.queryByText('conteúdo protegido')).not.toBeInTheDocument() + }) + + it('renders normally when the required field is already filled', () => { + renderWithForm( + + } /> + etapa 1

} /> +
, + { initialPath: '/etapa-2', initialValues: { fullName: 'Maria Souza' } }, + ) + + expect(screen.getByText('conteúdo protegido')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/hooks/useRequireStepData.ts b/frontend/src/hooks/useRequireStepData.ts new file mode 100644 index 0000000..69a7457 --- /dev/null +++ b/frontend/src/hooks/useRequireStepData.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useFormContext } from 'react-hook-form' +import type { FormValues } from '../schemas/formSchema' + +export function useRequireStepData(requiredField: keyof FormValues, redirectTo: string) { + const { getValues } = useFormContext() + const navigate = useNavigate() + + useEffect(() => { + if (!getValues(requiredField)) { + navigate(redirectTo, { replace: true }) + } + }, []) +} diff --git a/frontend/src/hooks/useStepNavigation.test.tsx b/frontend/src/hooks/useStepNavigation.test.tsx new file mode 100644 index 0000000..1d402b3 --- /dev/null +++ b/frontend/src/hooks/useStepNavigation.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Route, Routes } from 'react-router-dom' +import { renderWithForm } from '../test/renderWithForm' +import { useStepNavigation } from './useStepNavigation' +import { TextField } from '../components/form/TextField' +import type { FormValues } from '../schemas/formSchema' + +function TestStep({ fields, nextPath }: { fields: (keyof FormValues)[]; nextPath: string }) { + const { goNext, goBack } = useStepNavigation(fields) + return ( +
+ + + +
+ ) +} + +describe('useStepNavigation', () => { + it('does not navigate when the required fields are invalid', async () => { + const user = userEvent.setup() + renderWithForm( + + } /> + chegou na etapa 2

} /> +
, + { initialPath: '/etapa-1' }, + ) + + await user.click(screen.getByText('avançar')) + + expect(screen.queryByText('chegou na etapa 2')).not.toBeInTheDocument() + }) + + it('navigates once the required fields are valid', async () => { + const user = userEvent.setup() + renderWithForm( + + } /> + chegou na etapa 2

} /> +
, + { initialPath: '/etapa-1' }, + ) + + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza') + await user.click(screen.getByText('avançar')) + + expect(await screen.findByText('chegou na etapa 2')).toBeInTheDocument() + }) + + it('goBack always navigates regardless of validity', async () => { + const user = userEvent.setup() + renderWithForm( + + etapa 1

} /> + } /> +
, + { initialPath: '/etapa-2' }, + ) + + await user.click(screen.getByText('voltar')) + + expect(await screen.findByText('etapa 1')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/hooks/useStepNavigation.ts b/frontend/src/hooks/useStepNavigation.ts new file mode 100644 index 0000000..9584ed2 --- /dev/null +++ b/frontend/src/hooks/useStepNavigation.ts @@ -0,0 +1,21 @@ +import { useNavigate } from 'react-router-dom' +import { useFormContext } from 'react-hook-form' +import type { FormValues } from '../schemas/formSchema' + +export function useStepNavigation(fields: (keyof FormValues)[]) { + const { trigger } = useFormContext() + const navigate = useNavigate() + + async function goNext(path: string) { + const isValid = await trigger(fields) + if (isValid) { + navigate(path) + } + } + + function goBack(path: string) { + navigate(path) + } + + return { goNext, goBack } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..393ee2a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,342 @@ +@import './constants/spacing.css'; +@import './constants/colors.css'; +@import './constants/breakpoints.css'; + +:root { + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + + --shadow-card: 0 1px 3px rgba(8, 43, 99, 0.08), 0 8px 24px rgba(8, 43, 99, 0.06); + + color-scheme: light; + font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif; + color: var(--color-gray-900); + background-color: var(--verity-blue-50); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; +} + +body { + margin: 0; + background-color: var(--verity-blue-50); +} + +h1, +h2, +h3, +p { + margin: 0; +} + +button { + font-family: inherit; +} + +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-header { + background-color: var(--color-white); + border-bottom: 1px solid var(--color-gray-200); + padding: var(--space-6) var(--space-9); + display: flex; + align-items: center; +} + +.app-main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-9) var(--space-6) var(--space-11); +} + +.app-footer { + text-align: center; + padding: var(--space-6); + color: var(--color-gray-400); + font-size: 12px; +} + +.logo-mark { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.logo-image { + height: 36px; + width: auto; + display: block; +} + +.step-indicator { + list-style: none; + display: flex; + justify-content: center; + gap: var(--space-3); + margin: var(--space-9) auto 0; + padding: 0 var(--space-6); + max-width: 720px; + width: 100%; +} + +.step { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + text-align: center; +} + +.step-index { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + background-color: var(--color-gray-200); + color: var(--color-gray-600); +} + +.step-label { + font-size: 12px; + color: var(--color-gray-600); +} + +.step-active .step-index { + background-color: var(--verity-blue-600); + color: var(--color-white); +} + +.step-active .step-label { + color: var(--verity-blue-700); + font-weight: 600; +} + +.step-done .step-index { + background-color: var(--verity-blue-100); + color: var(--verity-blue-600); + border: 2px solid var(--verity-blue-500); +} + +.card { + width: 100%; + max-width: 720px; + background-color: var(--color-white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: var(--space-10); + margin-top: var(--space-9); +} + +.step-title { + font-size: 22px; + font-weight: 700; + color: var(--verity-blue-900); +} + +.step-description { + margin-top: var(--space-2); + color: var(--color-gray-600); + font-size: 14px; +} + +.field-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-7); + margin-top: var(--space-9); +} + +@media (--breakpoint-tablet) { + .field-grid { + grid-template-columns: 1fr 1fr; + } + + .field-full { + grid-column: 1 / -1; + } +} + +.field { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.field label { + font-size: 13px; + font-weight: 600; + color: var(--color-gray-900); +} + +.field input, +.field select { + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-sm); + padding: var(--space-4) var(--space-5); + font-size: 14px; + background-color: var(--color-white); + color: var(--color-gray-900); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.field input:focus, +.field select:focus { + outline: none; + border-color: var(--verity-blue-500); + box-shadow: 0 0 0 3px var(--verity-blue-100); +} + +.field input[aria-invalid='true'], +.field select[aria-invalid='true'] { + border-color: var(--color-error); +} + +.field-error { + font-size: 12px; + color: var(--color-error); +} + +.field-hint { + font-size: 12px; + color: var(--color-gray-400); +} + +.field-hint.loading { + color: var(--verity-blue-600); +} + +.field-hint.error { + color: var(--color-error); +} + +.wizard-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-5); + margin-top: var(--space-10); +} + +.btn { + border: none; + border-radius: var(--radius-sm); + padding: var(--space-5) var(--space-9); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease, transform 0.05s ease; +} + +.btn:active { + transform: translateY(1px); +} + +.btn-primary { + background-color: var(--verity-blue-600); + color: var(--color-white); +} + +.btn-primary:hover { + background-color: var(--verity-blue-700); +} + +.btn-secondary { + background-color: var(--color-white); + color: var(--verity-blue-600); + border: 1px solid var(--verity-blue-500); +} + +.btn-secondary:hover { + background-color: var(--verity-blue-50); +} + +.btn-link { + background: none; + border: none; + color: var(--verity-blue-600); + font-weight: 600; + font-size: 13px; + cursor: pointer; + padding: 0; +} + +.btn-link:hover { + text-decoration: underline; +} + +.summary-section { + margin-top: var(--space-8); + padding: var(--space-7); + background-color: var(--verity-blue-50); + border-radius: var(--radius-md); +} + +.summary-section-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.summary-section-header h3 { + color: var(--verity-blue-900); + font-size: 15px; +} + +.summary-list { + margin: var(--space-5) 0 0; + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); +} + +@media (--breakpoint-tablet) { + .summary-list { + grid-template-columns: 1fr 1fr; + } +} + +.summary-row dt { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-gray-400); +} + +.summary-row dd { + margin: var(--space-0) 0 0; + font-size: 14px; + color: var(--color-gray-900); + font-weight: 500; +} + +@media (--breakpoint-mobile) { + .card { + padding: var(--space-8); + } + + .wizard-actions { + flex-direction: column-reverse; + } + + .btn { + width: 100%; + } +} diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts new file mode 100644 index 0000000..835ad78 --- /dev/null +++ b/frontend/src/lib/api.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { instanceGet, topLevelGet } = vi.hoisted(() => ({ + instanceGet: vi.fn(), + topLevelGet: vi.fn(), +})) + +vi.mock('axios', () => ({ + default: { + create: vi.fn(() => ({ get: instanceGet })), + get: topLevelGet, + }, +})) + +import { fetchAddressByCep, fetchProfessions } from './api' + +beforeEach(() => { + instanceGet.mockReset() + topLevelGet.mockReset() +}) + +describe('fetchAddressByCep', () => { + it('returns the address from the mocked json-server when found', async () => { + instanceGet.mockResolvedValueOnce({ + data: { endereco: 'Avenida Paulista', bairro: 'Bela Vista', cidade: 'São Paulo', estado: 'SP' }, + }) + + const result = await fetchAddressByCep('01310100') + + expect(result).toEqual({ endereco: 'Avenida Paulista', bairro: 'Bela Vista', cidade: 'São Paulo', estado: 'SP' }) + expect(instanceGet).toHaveBeenCalledWith('/ceps/01310100') + expect(topLevelGet).not.toHaveBeenCalled() + }) + + it('falls back to ViaCEP when the CEP is not in the mock db', async () => { + instanceGet.mockRejectedValueOnce(new Error('Request failed with status code 404')) + topLevelGet.mockResolvedValueOnce({ + data: { logradouro: 'Rua das Flores', bairro: 'Centro', localidade: 'Rio de Janeiro', uf: 'RJ' }, + }) + + const result = await fetchAddressByCep('20000000') + + expect(result).toEqual({ endereco: 'Rua das Flores', bairro: 'Centro', cidade: 'Rio de Janeiro', estado: 'RJ' }) + }) + + it('returns null when ViaCEP reports the CEP does not exist', async () => { + instanceGet.mockRejectedValueOnce(new Error('Request failed with status code 404')) + topLevelGet.mockResolvedValueOnce({ data: { erro: true } }) + + const result = await fetchAddressByCep('00000000') + + expect(result).toBeNull() + }) + + it('returns null when both the mock and ViaCEP fail', async () => { + instanceGet.mockRejectedValueOnce(new Error('network error')) + topLevelGet.mockRejectedValueOnce(new Error('network error')) + + const result = await fetchAddressByCep('00000000') + + expect(result).toBeNull() + }) +}) + +describe('fetchProfessions', () => { + it('returns the list of professions from the mocked API', async () => { + instanceGet.mockResolvedValueOnce({ data: [{ id: 1, name: 'Engenheiro(a) Civil' }] }) + + const result = await fetchProfessions() + + expect(result).toEqual([{ id: 1, name: 'Engenheiro(a) Civil' }]) + expect(instanceGet).toHaveBeenCalledWith('/professions') + }) +}) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..51e3e3c --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,60 @@ +import axios from 'axios' + +export const mockApi = axios.create({ + baseURL: import.meta.env.VITE_MOCK_API_URL ?? 'http://localhost:3001', +}) + +export interface CepResult { + endereco: string + bairro: string + cidade: string + estado: string +} + +interface ViaCepResponse { + logradouro: string + bairro: string + localidade: string + uf: string + erro?: boolean +} + +async function fetchCepFromMock(cep: string): Promise { + try { + const { data } = await mockApi.get(`/ceps/${cep}`) + return data + } catch { + return null + } +} + +async function fetchCepFromViaCep(cep: string): Promise { + try { + const { data } = await axios.get(`https://viacep.com.br/ws/${cep}/json/`) + if (data.erro) return null + return { + endereco: data.logradouro, + bairro: data.bairro, + cidade: data.localidade, + estado: data.uf, + } + } catch { + return null + } +} + +export async function fetchAddressByCep(cep: string): Promise { + const mockResult = await fetchCepFromMock(cep) + if (mockResult) return mockResult + return fetchCepFromViaCep(cep) +} + +export interface Profession { + id: number | string + name: string +} + +export async function fetchProfessions(): Promise { + const { data } = await mockApi.get('/professions') + return data +} diff --git a/frontend/src/lib/cpf.test.ts b/frontend/src/lib/cpf.test.ts new file mode 100644 index 0000000..4bcf52a --- /dev/null +++ b/frontend/src/lib/cpf.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { isValidCPF, onlyDigits } from './cpf' + +describe('onlyDigits', () => { + it('strips every non-digit character', () => { + expect(onlyDigits('529.982.247-25')).toBe('52998224725') + expect(onlyDigits('(11) 98765-4321')).toBe('11987654321') + }) +}) + +describe('isValidCPF', () => { + it('accepts a valid formatted CPF', () => { + expect(isValidCPF('529.982.247-25')).toBe(true) + }) + + it('accepts a valid unformatted CPF', () => { + expect(isValidCPF('52998224725')).toBe(true) + }) + + it('rejects a CPF with a wrong check digit', () => { + expect(isValidCPF('529.982.247-26')).toBe(false) + }) + + it('rejects a CPF made of the same repeated digit', () => { + expect(isValidCPF('111.111.111-11')).toBe(false) + expect(isValidCPF('000.000.000-00')).toBe(false) + }) + + it('rejects a CPF with the wrong length', () => { + expect(isValidCPF('1234567890')).toBe(false) + expect(isValidCPF('123456789012')).toBe(false) + }) + + it('rejects an empty value', () => { + expect(isValidCPF('')).toBe(false) + }) +}) diff --git a/frontend/src/lib/cpf.ts b/frontend/src/lib/cpf.ts new file mode 100644 index 0000000..5e06579 --- /dev/null +++ b/frontend/src/lib/cpf.ts @@ -0,0 +1,26 @@ +export function onlyDigits(value: string): string { + return value.replace(/\D/g, '') +} + +function calcCheckDigit(digits: number[], length: number): number { + let sum = 0 + for (let i = 0; i < length; i++) { + sum += digits[i] * (length + 1 - i) + } + const remainder = (sum * 10) % 11 + return remainder === 10 ? 0 : remainder +} + +export function isValidCPF(rawValue: string): boolean { + const cpf = onlyDigits(rawValue) + + if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) { + return false + } + + const digits = cpf.split('').map(Number) + const firstCheckDigit = calcCheckDigit(digits, 9) + const secondCheckDigit = calcCheckDigit(digits, 10) + + return firstCheckDigit === digits[9] && secondCheckDigit === digits[10] +} diff --git a/frontend/src/lib/masks.test.ts b/frontend/src/lib/masks.test.ts new file mode 100644 index 0000000..7d6252a --- /dev/null +++ b/frontend/src/lib/masks.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { isValidBirthDate, onlyDigits, parseCurrencyToNumber } from './masks' + +describe('onlyDigits', () => { + it('removes non-numeric characters', () => { + expect(onlyDigits('01310-100')).toBe('01310100') + }) +}) + +describe('isValidBirthDate', () => { + it('accepts a real past date in dd/mm/yyyy format', () => { + expect(isValidBirthDate('15/05/1990')).toBe(true) + }) + + it('rejects an incomplete date', () => { + expect(isValidBirthDate('15/05/19')).toBe(false) + expect(isValidBirthDate('')).toBe(false) + }) + + it('rejects a calendar-impossible date', () => { + expect(isValidBirthDate('31/02/2020')).toBe(false) + expect(isValidBirthDate('32/01/2020')).toBe(false) + expect(isValidBirthDate('01/13/2020')).toBe(false) + }) + + it('rejects a date in the future', () => { + const future = new Date() + future.setFullYear(future.getFullYear() + 1) + const day = String(future.getDate()).padStart(2, '0') + const month = String(future.getMonth() + 1).padStart(2, '0') + expect(isValidBirthDate(`${day}/${month}/${future.getFullYear()}`)).toBe(false) + }) + + it('rejects unreasonable years', () => { + expect(isValidBirthDate('01/01/1800')).toBe(false) + }) +}) + +describe('parseCurrencyToNumber', () => { + it('parses a BRL formatted string into a number', () => { + expect(parseCurrencyToNumber('R$ 8.500,50')).toBe(8500.5) + expect(parseCurrencyToNumber('R$ 0,00')).toBe(0) + expect(parseCurrencyToNumber('R$ 1.234.567,89')).toBe(1234567.89) + }) + + it('returns 0 for an unparsable value', () => { + expect(parseCurrencyToNumber('')).toBe(0) + expect(parseCurrencyToNumber('abc')).toBe(0) + }) +}) diff --git a/frontend/src/lib/masks.ts b/frontend/src/lib/masks.ts new file mode 100644 index 0000000..12494bc --- /dev/null +++ b/frontend/src/lib/masks.ts @@ -0,0 +1,31 @@ +export function onlyDigits(value: string): string { + return value.replace(/\D/g, '') +} + +export function isValidBirthDate(value: string): boolean { + const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(value) + if (!match) return false + + const day = Number(match[1]) + const month = Number(match[2]) + const year = Number(match[3]) + const date = new Date(year, month - 1, day) + + const isRealCalendarDate = + date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day + + if (!isRealCalendarDate) return false + + const today = new Date() + today.setHours(0, 0, 0, 0) + + return date <= today && year >= 1900 +} + +export function parseCurrencyToNumber(value: string): number { + const normalized = value.replace(/[^\d,-]/g, '').replace(',', '.') + const parsed = Number.parseFloat(normalized) + return Number.isNaN(parsed) ? 0 : parsed +} diff --git a/frontend/src/lib/pdf.test.ts b/frontend/src/lib/pdf.test.ts new file mode 100644 index 0000000..83c152a --- /dev/null +++ b/frontend/src/lib/pdf.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FormValues } from '../schemas/formSchema' + +const { docMock, MockJsPDF } = vi.hoisted(() => { + const docMock = { + internal: { + pageSize: { getWidth: () => 210, getHeight: () => 297 }, + }, + setFillColor: vi.fn(), + rect: vi.fn(), + setTextColor: vi.fn(), + setFont: vi.fn(), + setFontSize: vi.fn(), + text: vi.fn(), + setDrawColor: vi.fn(), + line: vi.fn(), + save: vi.fn(), + } + class MockJsPDF { + constructor() { + return docMock + } + } + return { docMock, MockJsPDF } +}) + +vi.mock('jspdf', () => ({ jsPDF: MockJsPDF })) + +const { generateSummaryPdf } = await import('./pdf') + +const values: FormValues = { + fullName: 'Maria da Silva Souza', + birthDate: '15/05/1990', + cpf: '529.982.247-25', + phone: '(11) 98765-4321', + email: 'maria@example.com', + cep: '01310-100', + endereco: 'Avenida Paulista', + bairro: 'Bela Vista', + cidade: 'São Paulo', + estado: 'SP', + empresa: 'Verity Tecnologia', + profissao: 'Desenvolvedor(a) de Software', + salario: 'R$ 8.500,50', + tempoDeEmpresa: '2-5-anos', +} + +describe('generateSummaryPdf', () => { + it('draws the branded header and every field, then saves the file', () => { + generateSummaryPdf(values) + + expect(docMock.text).toHaveBeenCalledWith('verity', expect.any(Number), expect.any(Number)) + expect(docMock.text).toHaveBeenCalledWith('Maria da Silva Souza', expect.any(Number), expect.any(Number)) + expect(docMock.text).toHaveBeenCalledWith('São Paulo', expect.any(Number), expect.any(Number)) + expect(docMock.save).toHaveBeenCalledWith('verity-cadastro-maria-da-silva-souza.pdf') + }) + + it('falls back to a generic filename when the name is blank', () => { + generateSummaryPdf({ ...values, fullName: '' }) + expect(docMock.save).toHaveBeenCalledWith('verity-cadastro-cadastro.pdf') + }) +}) diff --git a/frontend/src/lib/pdf.ts b/frontend/src/lib/pdf.ts new file mode 100644 index 0000000..be8dc85 --- /dev/null +++ b/frontend/src/lib/pdf.ts @@ -0,0 +1,95 @@ +import { jsPDF } from 'jspdf' +import type { FormValues } from '../schemas/formSchema' +import { getOptionLabel, TEMPO_EMPRESA_OPTIONS, UF_OPTIONS } from '../constants/options' + +const VERITY_BLUE: [number, number, number] = [13, 71, 161] +const VERITY_BLUE_DARK: [number, number, number] = [8, 43, 99] +const TEXT_GRAY: [number, number, number] = [60, 68, 82] +const MARGIN_X = 16 + +interface Section { + title: string + rows: [string, string][] +} + +function buildSections(data: FormValues): Section[] { + return [ + { + title: 'Dados Pessoais', + rows: [ + ['Nome completo', data.fullName], + ['Data de nascimento', data.birthDate], + ['CPF', data.cpf], + ['Telefone', data.phone], + ['E-mail', data.email], + ], + }, + { + title: 'Informações Residenciais', + rows: [ + ['CEP', data.cep], + ['Endereço', data.endereco], + ['Bairro', data.bairro], + ['Cidade', data.cidade], + ['Estado', getOptionLabel(UF_OPTIONS, data.estado)], + ], + }, + { + title: 'Informações Profissionais', + rows: [ + ['Empresa', data.empresa], + ['Profissão', data.profissao], + ['Salário', data.salario], + ['Tempo de empresa', getOptionLabel(TEMPO_EMPRESA_OPTIONS, data.tempoDeEmpresa)], + ], + }, + ] +} + +export function generateSummaryPdf(data: FormValues): void { + const doc = new jsPDF() + const pageWidth = doc.internal.pageSize.getWidth() + + doc.setFillColor(...VERITY_BLUE) + doc.rect(0, 0, pageWidth, 30, 'F') + doc.setTextColor(255, 255, 255) + doc.setFont('helvetica', 'bold') + doc.setFontSize(18) + doc.text('verity', MARGIN_X, 19) + doc.setFontSize(11) + doc.setFont('helvetica', 'normal') + doc.text('Resumo do Cadastro', pageWidth - MARGIN_X, 19, { align: 'right' }) + + let cursorY = 44 + + for (const section of buildSections(data)) { + doc.setTextColor(...VERITY_BLUE_DARK) + doc.setFont('helvetica', 'bold') + doc.setFontSize(13) + doc.text(section.title, MARGIN_X, cursorY) + cursorY += 3 + doc.setDrawColor(...VERITY_BLUE) + doc.line(MARGIN_X, cursorY, pageWidth - MARGIN_X, cursorY) + cursorY += 8 + + doc.setFontSize(11) + for (const [label, value] of section.rows) { + doc.setFont('helvetica', 'bold') + doc.setTextColor(...TEXT_GRAY) + doc.text(`${label}:`, MARGIN_X, cursorY) + doc.setFont('helvetica', 'normal') + doc.text(value || '—', MARGIN_X + 55, cursorY) + cursorY += 7 + } + + cursorY += 6 + } + + const generatedAt = new Date().toLocaleString('pt-BR') + doc.setFontSize(9) + doc.setTextColor(...TEXT_GRAY) + doc.text(`Gerado em ${generatedAt}`, MARGIN_X, doc.internal.pageSize.getHeight() - 10) + + const fileSuffix = data.fullName.trim().toLowerCase().replace(/\s+/g, '-') || 'cadastro' + doc.save(`verity-cadastro-${fileSuffix}.pdf`) +} diff --git a/frontend/src/lib/storage.test.ts b/frontend/src/lib/storage.test.ts new file mode 100644 index 0000000..ab74160 --- /dev/null +++ b/frontend/src/lib/storage.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { clearStoredFormValues, loadStoredFormValues, persistFormValues } from './storage' + +interface Sample { + fullName: string +} + +afterEach(() => { + window.localStorage.clear() + vi.restoreAllMocks() +}) + +describe('persistFormValues / loadStoredFormValues', () => { + it('round-trips values through localStorage', () => { + persistFormValues({ fullName: 'Maria Souza' }) + expect(loadStoredFormValues()).toEqual({ fullName: 'Maria Souza' }) + }) + + it('returns undefined when nothing was stored', () => { + expect(loadStoredFormValues()).toBeUndefined() + }) + + it('returns undefined when stored value is not valid JSON', () => { + window.localStorage.setItem('verity:cadastro-wizard', '{not-json') + expect(loadStoredFormValues()).toBeUndefined() + }) + + it('silently ignores a localStorage write failure', () => { + vi.spyOn(window.localStorage.__proto__, 'setItem').mockImplementation(() => { + throw new Error('quota exceeded') + }) + expect(() => persistFormValues({ fullName: 'Maria' })).not.toThrow() + }) +}) + +describe('clearStoredFormValues', () => { + it('removes the stored value', () => { + persistFormValues({ fullName: 'Maria Souza' }) + clearStoredFormValues() + expect(loadStoredFormValues()).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..6c5c777 --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -0,0 +1,21 @@ +const STORAGE_KEY = 'verity:cadastro-wizard' + +export function loadStoredFormValues(): Partial | undefined { + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return undefined + return JSON.parse(raw) as Partial + } catch { + return undefined + } +} + +export function persistFormValues(values: unknown): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(values)) + } catch {} +} + +export function clearStoredFormValues(): void { + window.localStorage.removeItem(STORAGE_KEY) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/Step1PersonalData.test.tsx b/frontend/src/pages/Step1PersonalData.test.tsx new file mode 100644 index 0000000..5154b70 --- /dev/null +++ b/frontend/src/pages/Step1PersonalData.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Route, Routes } from 'react-router-dom' +import { renderWithForm } from '../test/renderWithForm' +import { Step1PersonalData } from './Step1PersonalData' + +function renderStep1() { + return renderWithForm( + + } /> + Informações Residenciais

} /> +
, + { initialPath: '/etapa-1' }, + ) +} + +describe('Step1PersonalData', () => { + it('renders every personal data field', () => { + renderStep1() + expect(screen.getByLabelText('Nome completo')).toBeInTheDocument() + expect(screen.getByLabelText('Data de nascimento')).toBeInTheDocument() + expect(screen.getByLabelText('CPF')).toBeInTheDocument() + expect(screen.getByLabelText('Telefone')).toBeInTheDocument() + expect(screen.getByLabelText('E-mail')).toBeInTheDocument() + }) + + it('blocks navigation and shows errors when the form is invalid', async () => { + const user = userEvent.setup() + renderStep1() + + await user.click(screen.getByText('Próximo')) + + expect(await screen.findByText('Informe o nome completo')).toBeInTheDocument() + expect(screen.queryByText('Informações Residenciais')).not.toBeInTheDocument() + }) + + it('navigates to step 2 once all fields are valid', async () => { + const user = userEvent.setup() + renderStep1() + + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza') + await user.type(screen.getByLabelText('Data de nascimento'), '15051990') + await user.type(screen.getByLabelText('CPF'), '52998224725') + await user.type(screen.getByLabelText('Telefone'), '11987654321') + await user.type(screen.getByLabelText('E-mail'), 'maria@example.com') + await user.click(screen.getByText('Próximo')) + + expect(await screen.findByText('Informações Residenciais')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/Step1PersonalData.tsx b/frontend/src/pages/Step1PersonalData.tsx new file mode 100644 index 0000000..d99d8e4 --- /dev/null +++ b/frontend/src/pages/Step1PersonalData.tsx @@ -0,0 +1,36 @@ +import { TextField } from '../components/form/TextField' +import { MaskedTextField } from '../components/form/MaskedTextField' +import { WizardActions } from '../components/wizard/WizardActions' +import { WizardStepShell } from '../components/wizard/WizardStepShell' +import { useStepNavigation } from '../hooks/useStepNavigation' +import { STEP1_FIELDS } from '../schemas/formSchema' +import { CPF_MASK, DATE_MASK, PHONE_MASK } from '../constants/masks' + +export function Step1PersonalData() { + const { goNext } = useStepNavigation(STEP1_FIELDS) + + return ( + +
+ + + + + +
+ goNext('/etapa-2')} /> +
+ ) +} diff --git a/frontend/src/pages/Step2Address.test.tsx b/frontend/src/pages/Step2Address.test.tsx new file mode 100644 index 0000000..6e31378 --- /dev/null +++ b/frontend/src/pages/Step2Address.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Route, Routes } from 'react-router-dom' +import { renderWithForm } from '../test/renderWithForm' +import { Step2Address } from './Step2Address' +import { fetchAddressByCep } from '../lib/api' + +vi.mock('../lib/api', () => ({ + fetchAddressByCep: vi.fn(), +})) + +function renderStep2(initialValues = { fullName: 'Maria Souza' }) { + return renderWithForm( + + Dados Pessoais

} /> + } /> + Informações Profissionais

} /> +
, + { initialPath: '/etapa-2', initialValues }, + ) +} + +beforeEach(() => { + vi.mocked(fetchAddressByCep).mockReset() +}) + +describe('Step2Address', () => { + it('auto-fills the address fields once the CEP lookup resolves', async () => { + vi.mocked(fetchAddressByCep).mockResolvedValueOnce({ + endereco: 'Avenida Paulista', + bairro: 'Bela Vista', + cidade: 'São Paulo', + estado: 'SP', + }) + const user = userEvent.setup() + renderStep2() + + await user.type(screen.getByLabelText('CEP'), '01310100') + + expect(await screen.findByDisplayValue('Avenida Paulista')).toBeInTheDocument() + expect(screen.getByDisplayValue('Bela Vista')).toBeInTheDocument() + expect(screen.getByLabelText('Cidade')).toHaveValue('São Paulo') + expect(screen.getByLabelText('Estado')).toHaveValue('SP') + }) + + it('shows a not-found hint when no service has the CEP', async () => { + vi.mocked(fetchAddressByCep).mockResolvedValueOnce(null) + const user = userEvent.setup() + renderStep2() + + await user.type(screen.getByLabelText('CEP'), '99999999') + + expect(await screen.findByText('CEP não encontrado, preencha manualmente.')).toBeInTheDocument() + }) + + it('navigates back to step 1', async () => { + const user = userEvent.setup() + renderStep2() + + await user.click(screen.getByText('Voltar')) + + expect(await screen.findByText('Dados Pessoais')).toBeInTheDocument() + }) + + it('navigates to step 3 once the address is valid', async () => { + vi.mocked(fetchAddressByCep).mockResolvedValueOnce({ + endereco: 'Avenida Paulista', + bairro: 'Bela Vista', + cidade: 'São Paulo', + estado: 'SP', + }) + const user = userEvent.setup() + renderStep2() + + await user.type(screen.getByLabelText('CEP'), '01310100') + await screen.findByDisplayValue('Avenida Paulista') + await user.click(screen.getByText('Próximo')) + + expect(await screen.findByText('Informações Profissionais')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/Step2Address.tsx b/frontend/src/pages/Step2Address.tsx new file mode 100644 index 0000000..a4b9bd4 --- /dev/null +++ b/frontend/src/pages/Step2Address.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react' +import { useFormContext, useWatch } from 'react-hook-form' +import { TextField } from '../components/form/TextField' +import { SelectField } from '../components/form/SelectField' +import { MaskedTextField } from '../components/form/MaskedTextField' +import { WizardActions } from '../components/wizard/WizardActions' +import { WizardStepShell } from '../components/wizard/WizardStepShell' +import { useStepNavigation } from '../hooks/useStepNavigation' +import { useRequireStepData } from '../hooks/useRequireStepData' +import { STEP2_FIELDS, type FormValues } from '../schemas/formSchema' +import { CEP_MASK } from '../constants/masks' +import { UF_OPTIONS } from '../constants/options' +import { fetchAddressByCep } from '../lib/api' +import { onlyDigits } from '../lib/masks' + +type CepStatus = 'idle' | 'loading' | 'success' | 'not-found' + +const CEP_STATUS_HINTS: Partial> = { + loading: 'Buscando endereço...', + 'not-found': 'CEP não encontrado, preencha manualmente.', +} + +export function Step2Address() { + useRequireStepData('fullName', '/etapa-1') + const { control, setValue } = useFormContext() + const { goNext, goBack } = useStepNavigation(STEP2_FIELDS) + const [cepStatus, setCepStatus] = useState('idle') + const cep = useWatch({ control, name: 'cep' }) + + useEffect(() => { + const digits = onlyDigits(cep ?? '') + if (digits.length !== 8) { + setCepStatus('idle') + return + } + + let cancelled = false + setCepStatus('loading') + + fetchAddressByCep(digits).then((result) => { + if (cancelled) return + if (result) { + setValue('endereco', result.endereco, { shouldValidate: true }) + setValue('bairro', result.bairro, { shouldValidate: true }) + setValue('cidade', result.cidade, { shouldValidate: true }) + setValue('estado', result.estado.toUpperCase(), { shouldValidate: true }) + setCepStatus('success') + } else { + setCepStatus('not-found') + } + }) + + return () => { + cancelled = true + } + }, [cep, setValue]) + + return ( + +
+ + + + + +
+ goBack('/etapa-1')} onNext={() => goNext('/etapa-3')} /> +
+ ) +} diff --git a/frontend/src/pages/Step3Professional.test.tsx b/frontend/src/pages/Step3Professional.test.tsx new file mode 100644 index 0000000..fc38c15 --- /dev/null +++ b/frontend/src/pages/Step3Professional.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Route, Routes } from 'react-router-dom' +import { renderWithForm } from '../test/renderWithForm' +import { Step3Professional } from './Step3Professional' +import { fetchProfessions } from '../lib/api' + +vi.mock('../lib/api', () => ({ + fetchProfessions: vi.fn(), +})) + +function renderStep3() { + return renderWithForm( + + Informações Residenciais

} /> + } /> + Resumo do Cadastro

} /> +
, + { initialPath: '/etapa-3', initialValues: { endereco: 'Avenida Paulista' } }, + ) +} + +beforeEach(() => { + vi.mocked(fetchProfessions).mockReset() +}) + +describe('Step3Professional', () => { + it('disables the profession select while loading, then populates it', async () => { + vi.mocked(fetchProfessions).mockResolvedValueOnce([ + { id: 1, name: 'Desenvolvedor(a) de Software' }, + { id: 2, name: 'Analista de Sistemas' }, + ]) + renderStep3() + + expect(screen.getByLabelText('Profissão')).toBeDisabled() + + await screen.findByRole('option', { name: 'Desenvolvedor(a) de Software' }) + expect(screen.getByLabelText('Profissão')).not.toBeDisabled() + expect(screen.getByRole('option', { name: 'Analista de Sistemas' })).toBeInTheDocument() + }) + + it('formats the salary field as currency', async () => { + vi.mocked(fetchProfessions).mockResolvedValueOnce([]) + const user = userEvent.setup() + renderStep3() + + await user.type(screen.getByLabelText('Salário'), '500000') + expect(screen.getByLabelText('Salário')).toHaveValue('R$ 5.000,00') + }) + + it('navigates to the summary once the step is valid', async () => { + vi.mocked(fetchProfessions).mockResolvedValueOnce([{ id: 1, name: 'Analista de Sistemas' }]) + const user = userEvent.setup() + renderStep3() + + await screen.findByRole('option', { name: 'Analista de Sistemas' }) + await user.type(screen.getByLabelText('Empresa'), 'Verity Tecnologia') + await user.selectOptions(screen.getByLabelText('Profissão'), 'Analista de Sistemas') + await user.type(screen.getByLabelText('Salário'), '500000') + await user.selectOptions(screen.getByLabelText('Tempo de empresa'), 'De 2 a 5 anos') + await user.click(screen.getByText('Ver resumo')) + + expect(await screen.findByText('Resumo do Cadastro')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/Step3Professional.tsx b/frontend/src/pages/Step3Professional.tsx new file mode 100644 index 0000000..396f475 --- /dev/null +++ b/frontend/src/pages/Step3Professional.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import { TextField } from '../components/form/TextField' +import { SelectField } from '../components/form/SelectField' +import { CurrencyField } from '../components/form/CurrencyField' +import { WizardActions } from '../components/wizard/WizardActions' +import { WizardStepShell } from '../components/wizard/WizardStepShell' +import { useStepNavigation } from '../hooks/useStepNavigation' +import { useRequireStepData } from '../hooks/useRequireStepData' +import { STEP3_FIELDS } from '../schemas/formSchema' +import { TEMPO_EMPRESA_OPTIONS, type SelectOption } from '../constants/options' +import { fetchProfessions } from '../lib/api' + +export function Step3Professional() { + useRequireStepData('endereco', '/etapa-2') + const { goNext, goBack } = useStepNavigation(STEP3_FIELDS) + const [professionOptions, setProfessionOptions] = useState([]) + const [loadingProfessions, setLoadingProfessions] = useState(true) + + useEffect(() => { + let cancelled = false + + fetchProfessions() + .then((professions) => { + if (cancelled) return + setProfessionOptions(professions.map((p) => ({ value: p.name, label: p.name }))) + }) + .catch(() => { + if (!cancelled) setProfessionOptions([]) + }) + .finally(() => { + if (!cancelled) setLoadingProfessions(false) + }) + + return () => { + cancelled = true + } + }, []) + + return ( + +
+ + + + +
+ goBack('/etapa-2')} onNext={() => goNext('/resumo')} nextLabel="Ver resumo" /> +
+ ) +} diff --git a/frontend/src/pages/SummaryPage.test.tsx b/frontend/src/pages/SummaryPage.test.tsx new file mode 100644 index 0000000..7214876 --- /dev/null +++ b/frontend/src/pages/SummaryPage.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Route, Routes } from 'react-router-dom' +import { renderWithForm } from '../test/renderWithForm' +import { SummaryPage } from './SummaryPage' +import { generateSummaryPdf } from '../lib/pdf' +import { clearStoredFormValues } from '../lib/storage' +import type { FormValues } from '../schemas/formSchema' + +vi.mock('../lib/pdf', () => ({ generateSummaryPdf: vi.fn() })) +vi.mock('../lib/storage', () => ({ clearStoredFormValues: vi.fn(), persistFormValues: vi.fn() })) + +const filledValues: Partial = { + fullName: 'Maria da Silva Souza', + birthDate: '15/05/1990', + cpf: '529.982.247-25', + phone: '(11) 98765-4321', + email: 'maria@example.com', + cep: '01310-100', + endereco: 'Avenida Paulista', + bairro: 'Bela Vista', + cidade: 'São Paulo', + estado: 'SP', + empresa: 'Verity Tecnologia', + profissao: 'Desenvolvedor(a) de Software', + salario: 'R$ 8.500,50', + tempoDeEmpresa: '2-5-anos', +} + +function renderSummary() { + return renderWithForm( + + Dados Pessoais

} /> + Informações Profissionais

} /> + } /> +
, + { initialPath: '/resumo', initialValues: filledValues }, + ) +} + +beforeEach(() => { + vi.mocked(generateSummaryPdf).mockReset() + vi.mocked(clearStoredFormValues).mockReset() +}) + +describe('SummaryPage', () => { + it('renders every filled value grouped by section, translating select values to labels', () => { + renderSummary() + + expect(screen.getByText('Maria da Silva Souza')).toBeInTheDocument() + expect(screen.getByText('529.982.247-25')).toBeInTheDocument() + expect(screen.getByText('R$ 8.500,50')).toBeInTheDocument() + expect(screen.getAllByText('São Paulo')).toHaveLength(2) + expect(screen.getByText('De 2 a 5 anos')).toBeInTheDocument() + }) + + it('exports the PDF with the current form values', async () => { + const user = userEvent.setup() + renderSummary() + + await user.click(screen.getByText('Exportar PDF')) + + expect(generateSummaryPdf).toHaveBeenCalledWith(expect.objectContaining({ fullName: 'Maria da Silva Souza' })) + }) + + it('navigates to the matching step when Editar is clicked', async () => { + const user = userEvent.setup() + renderSummary() + + await user.click(screen.getAllByText('Editar')[0]) + + expect(await screen.findByText('Dados Pessoais')).toBeInTheDocument() + }) + + it('clears the stored data and restarts on "Novo cadastro"', async () => { + const user = userEvent.setup() + renderSummary() + + await user.click(screen.getByText('Novo cadastro')) + + expect(clearStoredFormValues).toHaveBeenCalledOnce() + expect(await screen.findByText('Dados Pessoais')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/SummaryPage.tsx b/frontend/src/pages/SummaryPage.tsx new file mode 100644 index 0000000..2370824 --- /dev/null +++ b/frontend/src/pages/SummaryPage.tsx @@ -0,0 +1,71 @@ +import { useNavigate } from 'react-router-dom' +import { useFormContext } from 'react-hook-form' +import { WizardStepShell } from '../components/wizard/WizardStepShell' +import { SummarySection } from '../components/wizard/SummarySection' +import { useRequireStepData } from '../hooks/useRequireStepData' +import { defaultFormValues, type FormValues } from '../schemas/formSchema' +import { getOptionLabel, TEMPO_EMPRESA_OPTIONS, UF_OPTIONS } from '../constants/options' +import { generateSummaryPdf } from '../lib/pdf' +import { clearStoredFormValues } from '../lib/storage' + +export function SummaryPage() { + useRequireStepData('profissao', '/etapa-3') + const { getValues, reset } = useFormContext() + const navigate = useNavigate() + const values = getValues() + + function handleExport() { + generateSummaryPdf(values) + } + + function handleNewRegistration() { + clearStoredFormValues() + reset(defaultFormValues) + navigate('/etapa-1') + } + + return ( + + navigate('/etapa-1')} + items={[ + { label: 'Nome completo', value: values.fullName }, + { label: 'Data de nascimento', value: values.birthDate }, + { label: 'CPF', value: values.cpf }, + { label: 'Telefone', value: values.phone }, + { label: 'E-mail', value: values.email }, + ]} + /> + navigate('/etapa-2')} + items={[ + { label: 'CEP', value: values.cep }, + { label: 'Endereço', value: values.endereco }, + { label: 'Bairro', value: values.bairro }, + { label: 'Cidade', value: values.cidade }, + { label: 'Estado', value: getOptionLabel(UF_OPTIONS, values.estado) }, + ]} + /> + navigate('/etapa-3')} + items={[ + { label: 'Empresa', value: values.empresa }, + { label: 'Profissão', value: values.profissao }, + { label: 'Salário', value: values.salario }, + { label: 'Tempo de empresa', value: getOptionLabel(TEMPO_EMPRESA_OPTIONS, values.tempoDeEmpresa) }, + ]} + /> +
+ + +
+
+ ) +} diff --git a/frontend/src/schemas/formSchema.test.ts b/frontend/src/schemas/formSchema.test.ts new file mode 100644 index 0000000..a6a002a --- /dev/null +++ b/frontend/src/schemas/formSchema.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { addressSchema, defaultFormValues, formSchema, personalDataSchema, professionalSchema } from './formSchema' + +const validValues = { + fullName: 'Maria da Silva Souza', + birthDate: '15/05/1990', + cpf: '529.982.247-25', + phone: '(11) 98765-4321', + email: 'maria@example.com', + cep: '01310-100', + endereco: 'Avenida Paulista', + bairro: 'Bela Vista', + cidade: 'São Paulo', + estado: 'SP', + empresa: 'Verity Tecnologia', + profissao: 'Desenvolvedor(a) de Software', + salario: 'R$ 8.500,50', + tempoDeEmpresa: '2-5-anos', +} + +describe('formSchema', () => { + it('accepts a fully valid payload', () => { + expect(formSchema.safeParse(validValues).success).toBe(true) + }) + + it('rejects the untouched default values', () => { + expect(formSchema.safeParse(defaultFormValues).success).toBe(false) + }) +}) + +describe('personalDataSchema', () => { + it('requires at least two words for the full name', () => { + const result = personalDataSchema.safeParse({ ...validValues, fullName: 'Maria' }) + expect(result.success).toBe(false) + }) + + it('rejects an invalid CPF', () => { + const result = personalDataSchema.safeParse({ ...validValues, cpf: '111.111.111-11' }) + expect(result.success).toBe(false) + }) + + it('rejects a malformed phone number', () => { + const result = personalDataSchema.safeParse({ ...validValues, phone: '11987654321' }) + expect(result.success).toBe(false) + }) + + it('accepts an 8-digit landline phone number', () => { + const result = personalDataSchema.safeParse({ ...validValues, phone: '(11) 3456-7890' }) + expect(result.success).toBe(true) + }) + + it('rejects an invalid email', () => { + const result = personalDataSchema.safeParse({ ...validValues, email: 'not-an-email' }) + expect(result.success).toBe(false) + }) + + it('rejects an impossible birth date', () => { + const result = personalDataSchema.safeParse({ ...validValues, birthDate: '31/02/2020' }) + expect(result.success).toBe(false) + }) +}) + +describe('addressSchema', () => { + it('rejects a malformed CEP', () => { + const result = addressSchema.safeParse({ ...validValues, cep: '01310100' }) + expect(result.success).toBe(false) + }) + + it('rejects an unknown UF', () => { + const result = addressSchema.safeParse({ ...validValues, estado: 'XX' }) + expect(result.success).toBe(false) + }) + + it('rejects an empty bairro', () => { + const result = addressSchema.safeParse({ ...validValues, bairro: '' }) + expect(result.success).toBe(false) + }) +}) + +describe('professionalSchema', () => { + it('rejects a zero salary', () => { + const result = professionalSchema.safeParse({ ...validValues, salario: 'R$ 0,00' }) + expect(result.success).toBe(false) + }) + + it('rejects a missing profession', () => { + const result = professionalSchema.safeParse({ ...validValues, profissao: '' }) + expect(result.success).toBe(false) + }) + + it('rejects a missing tempoDeEmpresa', () => { + const result = professionalSchema.safeParse({ ...validValues, tempoDeEmpresa: '' }) + expect(result.success).toBe(false) + }) +}) diff --git a/frontend/src/schemas/formSchema.ts b/frontend/src/schemas/formSchema.ts new file mode 100644 index 0000000..b49f098 --- /dev/null +++ b/frontend/src/schemas/formSchema.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' +import { isValidCPF } from '../lib/cpf' +import { isValidBirthDate, parseCurrencyToNumber } from '../lib/masks' +import { UF_OPTIONS } from '../constants/options' + +const PHONE_REGEX = /^\(\d{2}\) \d{4,5}-\d{4}$/ +const CEP_REGEX = /^\d{5}-\d{3}$/ +const UF_CODES = UF_OPTIONS.map((option) => option.value) + +export const personalDataSchema = z.object({ + fullName: z + .string() + .trim() + .min(1, 'Informe o nome completo') + .refine((value) => value.trim().split(/\s+/).length >= 2, { + message: 'Informe nome e sobrenome', + }), + birthDate: z + .string() + .min(1, 'Informe a data de nascimento') + .refine(isValidBirthDate, { message: 'Data de nascimento inválida' }), + cpf: z + .string() + .min(1, 'Informe o CPF') + .refine(isValidCPF, { message: 'CPF inválido' }), + phone: z + .string() + .min(1, 'Informe o telefone') + .regex(PHONE_REGEX, 'Telefone inválido'), + email: z.string().min(1, 'Informe o e-mail').email('E-mail inválido'), +}) + +export const addressSchema = z.object({ + cep: z.string().min(1, 'Informe o CEP').regex(CEP_REGEX, 'CEP inválido'), + endereco: z.string().min(1, 'Informe o endereço'), + bairro: z.string().min(1, 'Informe o bairro'), + cidade: z.string().min(1, 'Informe a cidade'), + estado: z + .string() + .min(1, 'Informe o estado') + .refine((value) => UF_CODES.includes(value.toUpperCase()), { + message: 'Estado inválido', + }), +}) + +export const professionalSchema = z.object({ + empresa: z.string().min(1, 'Informe a empresa'), + profissao: z.string().min(1, 'Selecione uma profissão'), + salario: z + .string() + .min(1, 'Informe o salário') + .refine((value) => parseCurrencyToNumber(value) > 0, { + message: 'Informe um salário válido', + }), + tempoDeEmpresa: z.string().min(1, 'Selecione o tempo de empresa'), +}) + +export const formSchema = personalDataSchema.merge(addressSchema).merge(professionalSchema) + +export type FormValues = z.infer + +export const STEP1_FIELDS = Object.keys(personalDataSchema.shape) as (keyof FormValues)[] +export const STEP2_FIELDS = Object.keys(addressSchema.shape) as (keyof FormValues)[] +export const STEP3_FIELDS = Object.keys(professionalSchema.shape) as (keyof FormValues)[] + +export const defaultFormValues: FormValues = { + fullName: '', + birthDate: '', + cpf: '', + phone: '', + email: '', + cep: '', + endereco: '', + bairro: '', + cidade: '', + estado: '', + empresa: '', + profissao: '', + salario: '', + tempoDeEmpresa: '', +} diff --git a/frontend/src/test/renderWithForm.tsx b/frontend/src/test/renderWithForm.tsx new file mode 100644 index 0000000..aa64c30 --- /dev/null +++ b/frontend/src/test/renderWithForm.tsx @@ -0,0 +1,36 @@ +import type { ReactElement, ReactNode } from 'react' +import { render } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { FormProvider, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { defaultFormValues, formSchema, type FormValues } from '../schemas/formSchema' + +function FormWrapper({ + children, + initialValues, +}: { + children: ReactNode + initialValues?: Partial +}) { + const methods = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { ...defaultFormValues, ...initialValues }, + }) + + return {children} +} + +interface RenderWithFormOptions { + initialValues?: Partial + initialPath?: string +} + +export function renderWithForm(ui: ReactElement, options: RenderWithFormOptions = {}) { + const { initialValues, initialPath = '/' } = options + + return render( + + {ui} + , + ) +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..1555cbe --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,8 @@ +import '@testing-library/jest-dom' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() + window.localStorage.clear() +}) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..5f23a90 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,31 @@ +/// +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + css: true, + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/main.tsx', + 'src/vite-env.d.ts', + 'src/test/**', + 'src/**/*.d.ts', + ], + thresholds: { + lines: 80, + statements: 80, + functions: 80, + branches: 80, + }, + }, + }, +})