Reescrita dos geradores: biblioteca IGenerator, minimal API e novo frontend
- GeradoresService: cada tipo passa a ser um IGenerator (NIF, NIPC, NISS, CC, passaporte, IBAN, NIB, cartões, referência MB, pessoa, telefone, código postal, email, matrícula, EAN, UUID) registado em GeneratorRegistry; remove DAL/EF - GeradoresWS: minimal APIs em /api/v1 com OpenAPI + Scalar e health check - UnitTest: testes de ida e volta, valores conhecidos, detecção e API - geradoresfe: UI construída a partir dos metadados da API, validador livre, exportação CSV/JSON/TXT, nginx com API_UPSTREAM configurável - Docker, docker-compose e Jenkinsfile actualizados Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,2 @@
|
||||
node_modules
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
VITE_API_URL=https://localhost:7266/
|
||||
@@ -1,26 +1,21 @@
|
||||
# ---------- BUILD ----------
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Variável de ambiente correta para Vite
|
||||
ENV VITE_API_URL=http://localhost:5050/
|
||||
RUN npm run build
|
||||
|
||||
# ---------- SERVE ----------
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copia os ficheiros do build para a pasta do nginx
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# O nginx substitui ${API_UPSTREAM} no arranque (envsubst em /etc/nginx/templates).
|
||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
# Configuração mínima do nginx (opcional: para SPA fallback)
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# Endereço interno da API para onde /api, /docs e /openapi são encaminhados.
|
||||
ENV API_UPSTREAM=http://geradoresws:8080
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -1,24 +1,14 @@
|
||||
<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/0.5.83-alpha">
|
||||
<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/0.5.83-alpha">
|
||||
<PropertyGroup>
|
||||
<StartupCommand>set BROWSER=none&&npm start</StartupCommand>
|
||||
<StartupCommand>npm run dev</StartupCommand>
|
||||
<JavaScriptTestRoot>src\</JavaScriptTestRoot>
|
||||
<JavaScriptTestFramework>Jest</JavaScriptTestFramework>
|
||||
<JavaScriptTestFramework>Vitest</JavaScriptTestFramework>
|
||||
<!-- Command to run on project build -->
|
||||
<BuildCommand>
|
||||
</BuildCommand>
|
||||
<!-- Command to create an optimized build of the project that's ready for publishing -->
|
||||
<ProductionBuildCommand>npm run build</ProductionBuildCommand>
|
||||
<!-- Folder where production build objects will be placed -->
|
||||
<BuildOutputFolder>$(MSBuildProjectDirectory)\build</BuildOutputFolder>
|
||||
<BuildOutputFolder>$(MSBuildProjectDirectory)\dist</BuildOutputFolder>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\.dockerignore">
|
||||
<DependentUpon>$(DockerDefaultDockerfile)</DependentUpon>
|
||||
<Link>.dockerignore</Link>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="src\Api\" />
|
||||
<Folder Include="src\types\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="pt-PT">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logotipo.png" />
|
||||
<link rel="icon" type="image/png" href="/src/assets/logotipo32x32.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Factory Id</title>
|
||||
<meta name="description" content="Gere e valide NIF, NISS, Cartão de Cidadão, IBAN, cartões, matrículas e outros dados portugueses para testes." />
|
||||
<title>Factory Id — Geradores e validadores</title>
|
||||
<script>
|
||||
// Aplica o tema antes do primeiro render para evitar o "flash" de tema claro.
|
||||
try {
|
||||
const t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches))
|
||||
document.documentElement.classList.add("dark");
|
||||
} catch {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# Optional: gzip
|
||||
gzip on;
|
||||
gzip_types text/plain application/javascript text/css application/json image/svg+xml;
|
||||
}
|
||||
33
geradoresfe/nginx.conf.template
Normal file
33
geradoresfe/nginx.conf.template
Normal file
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# API no mesmo domínio: sem CORS e sem URL da API embutido no build.
|
||||
location ~ ^/(api|docs|openapi)(/|$) {
|
||||
proxy_pass ${API_UPSTREAM};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Ficheiros com hash no nome: cache longa.
|
||||
# (add_header num location anula os do server, por isso os cabeçalhos de segurança repetem-se.)
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
}
|
||||
4573
geradoresfe/package-lock.json
generated
Normal file
4573
geradoresfe/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,39 @@
|
||||
{
|
||||
"name": "geradoresfe",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"axios": "^1.10.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-router": "^7.6.3",
|
||||
"react-router-dom": "^7.6.3",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"swagger-typescript-api": "^13.2.7",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4"
|
||||
"vite": "^7.0.4",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('@tailwindcss/postcss'),
|
||||
require('autoprefixer'),
|
||||
],
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
class GeradorService {
|
||||
static async GenerateNIF(type: string | null): Promise<any[]> {
|
||||
try {
|
||||
debugger;
|
||||
const response: AxiosResponse = await axios.get(API_URL + 'Generate/GenerateNIF',
|
||||
{
|
||||
params: {
|
||||
type: type
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error : any) {
|
||||
|
||||
toast.error('Error fetching NIF:' + error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async ValidateNIF(nif: string | null): Promise<any[]> {
|
||||
try {
|
||||
const response: AxiosResponse = await axios.get(API_URL + 'Generate/ValidateNIF',
|
||||
{
|
||||
params: {
|
||||
nif: nif
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
toast.error('Error fetching NIF:' + error);
|
||||
|
||||
console.error('Error fetching NIF:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async GenerateNISS(): Promise<any[]> {
|
||||
try {
|
||||
|
||||
const response: AxiosResponse = await axios.get(API_URL + 'Generate/GenerateNISS');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching NIF:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async GenerateCC(): Promise<any[]> {
|
||||
try {
|
||||
|
||||
const response: AxiosResponse = await axios.get(API_URL + 'Generate/GenerateCC');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching NIF:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default GeradorService;
|
||||
@@ -1,34 +1,41 @@
|
||||
import { Routes, Route, BrowserRouter as Router } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import Home from './pages/Home';
|
||||
import { Layout } from './layout/Layout';
|
||||
import { useTheme } from './context/ThemeContext';
|
||||
import NIF from './pages/NIF';
|
||||
import CC from './pages/CC';
|
||||
import NISS from './pages/NISS';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { Layout } from "./layout/Layout";
|
||||
import { useTheme } from "./context/ThemeContext";
|
||||
import { GeneratorsProvider } from "./lib/useGenerators";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import GeneratorPage from "./pages/GeneratorPage";
|
||||
import ValidatorPage from "./pages/ValidatorPage";
|
||||
import NotFoundPage from "./pages/NotFoundPage";
|
||||
|
||||
function App() {
|
||||
export default function App() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/NIF" element={<NIF />} />
|
||||
<Route path="/NISS" element={<NISS />} />
|
||||
<Route path="/CC" element={<CC />} />
|
||||
</Routes>
|
||||
<Toaster position="top-right"
|
||||
<BrowserRouter>
|
||||
<GeneratorsProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/gerador/:id" element={<GeneratorPage />} />
|
||||
<Route path="/validar" element={<ValidatorPage />} />
|
||||
{/* Rotas antigas */}
|
||||
<Route path="/NIF" element={<Navigate to="/gerador/nif" replace />} />
|
||||
<Route path="/NISS" element={<Navigate to="/gerador/niss" replace />} />
|
||||
<Route path="/CC" element={<Navigate to="/gerador/cc" replace />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: theme === 'dark' ? '#1f2937' : '#fff',
|
||||
color: theme === 'dark' ? '#fff' : '#000',
|
||||
background: theme === "dark" ? "#27272a" : "#fff",
|
||||
color: theme === "dark" ? "#fff" : "#18181b",
|
||||
},
|
||||
}} />
|
||||
</Layout>
|
||||
</Router>
|
||||
}}
|
||||
/>
|
||||
</GeneratorsProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
14
geradoresfe/src/components/ChecksumBadge.tsx
Normal file
14
geradoresfe/src/components/ChecksumBadge.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ShieldCheckIcon } from "lucide-react";
|
||||
import type { Generator } from "../lib/api";
|
||||
import { Badge } from "./ui/Badge";
|
||||
|
||||
export function ChecksumBadge({ generator }: { generator: Generator }) {
|
||||
if (!generator.canValidate) return null;
|
||||
return generator.hasChecksum ? (
|
||||
<Badge tone="green" className="shrink-0">
|
||||
<ShieldCheckIcon className="h-3 w-3" /> Controlo
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="shrink-0">Formato</Badge>
|
||||
);
|
||||
}
|
||||
57
geradoresfe/src/components/GeneratorCard.tsx
Normal file
57
geradoresfe/src/components/GeneratorCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ArrowRightIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { api, defaultOptions, type Generator } from "../lib/api";
|
||||
import { OptionsForm } from "./OptionsForm";
|
||||
import { Button } from "./ui/Button";
|
||||
import { ResultDisplay } from "./ui/ResultDisplay";
|
||||
import { ChecksumBadge } from "./ChecksumBadge";
|
||||
|
||||
/** Cartão de geração rápida (1 valor) usado na página inicial. */
|
||||
export function GeneratorCard({ generator }: { generator: Generator }) {
|
||||
const [options, setOptions] = useState(() => defaultOptions(generator));
|
||||
const [value, setValue] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const generate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [item] = await api.generate(generator.id, options);
|
||||
setValue(item.value);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<header>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-lg font-bold text-blue-700 dark:text-blue-400">{generator.name}</h3>
|
||||
<ChecksumBadge generator={generator} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">{generator.description}</p>
|
||||
</header>
|
||||
|
||||
<OptionsForm generator={generator} values={options} onChange={(k, v) => setOptions((o) => ({ ...o, [k]: v }))} />
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
<ResultDisplay value={value} label={generator.name} />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={generate} loading={loading} icon={<RefreshCwIcon className="h-4 w-4" />} className="flex-1">
|
||||
Gerar
|
||||
</Button>
|
||||
<Link
|
||||
to={`/gerador/${generator.id}`}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Em lote <ArrowRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
// src/components/Geradores/GeradorCard.tsx
|
||||
import { useCallback, useState } from "react";
|
||||
import { RefreshCwIcon, CopyIcon } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { Select } from "../ui/Select";
|
||||
import { ResultDisplay } from "../ui/ResultDisplay";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
export type Parametro =
|
||||
| { nome: string; tipo: "text" | "number" | "password"; placeholder?: string; default?: string }
|
||||
| { nome: string; tipo: "select"; options: { label: string; value: string }[]; default?: string };
|
||||
|
||||
export type GeradorCardProps = {
|
||||
readonly titulo: string;
|
||||
readonly descricao?: string;
|
||||
readonly parametros?: Parametro[];
|
||||
readonly onGerar: (parametros?: Record<string, string>) => string | Promise<string>;
|
||||
readonly onValidar?: (valor: string) => boolean | Promise<boolean>;
|
||||
};
|
||||
|
||||
export default function GeradorCard({
|
||||
titulo,
|
||||
descricao,
|
||||
parametros = [],
|
||||
onGerar,
|
||||
onValidar,
|
||||
}: GeradorCardProps) {
|
||||
// Inicializa paramInputs com defaults vindos de `parametros`
|
||||
const initialParamInputs = parametros.reduce<Record<string, string>>((acc, p) => {
|
||||
debugger;
|
||||
if (p.default) {
|
||||
acc[p.nome] = p.default;
|
||||
} else {
|
||||
acc[p.nome] = "";
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const [resultado, setResultado] = useState("");
|
||||
const [paramInputs, setParamInputs] = useState<Record<string, string>>(initialParamInputs);
|
||||
const [validado, setValidado] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleInputChange = useCallback((nome: string, valor: string) => {
|
||||
setParamInputs((prev) => ({ ...prev, [nome]: valor }));
|
||||
}, []);
|
||||
|
||||
const handleGerar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setValidado(null);
|
||||
try {
|
||||
const gerado = await onGerar(paramInputs);
|
||||
setResultado(typeof gerado === "string" ? gerado : String(gerado ?? ""));
|
||||
toast.success(`${titulo} gerado com sucesso.`, { duration: 2000 });
|
||||
} catch (err) {
|
||||
console.error("Erro ao gerar:", err);
|
||||
toast.error("Falha ao gerar. Tente novamente.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onGerar, paramInputs, titulo]);
|
||||
|
||||
const handleValidar = useCallback(async () => {
|
||||
if (!onValidar) return;
|
||||
if (!resultado) {
|
||||
toast.error("Nenhum valor para validar.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const isValid = await onValidar(resultado);
|
||||
setValidado(Boolean(isValid));
|
||||
if (isValid) {
|
||||
toast.success("Válido", { duration: 1500 });
|
||||
} else {
|
||||
toast.error("Inválido", { duration: 1500 });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Erro ao validar:", err);
|
||||
toast.error("Falha na validação. Tente novamente.");
|
||||
setValidado(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onValidar, resultado]);
|
||||
|
||||
const handleCopiar = useCallback(async () => {
|
||||
if (!resultado) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(resultado);
|
||||
toast.success(`${titulo} copiado!`, { duration: 2000 });
|
||||
} catch (err) {
|
||||
console.error("Erro ao copiar:", err);
|
||||
toast.error("Não foi possível copiar para a área de transferência.");
|
||||
}
|
||||
}, [resultado, titulo]);
|
||||
|
||||
const handleResultadoChange = useCallback((valor: string) => {
|
||||
setResultado(valor);
|
||||
setValidado(null); // reset ao editar
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-md rounded-2xl border border-gray-200 bg-white dark:bg-zinc-900 p-6 shadow-lg hover:shadow-xl transition-shadow duration-300"
|
||||
aria-busy={loading}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-bold text-blue-700 dark:text-blue-400">{titulo}</h3>
|
||||
{descricao && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{descricao}</p>}
|
||||
</div>
|
||||
|
||||
{parametros.length > 0 && (
|
||||
<div className="space-y-4 mb-6">
|
||||
{parametros.map((param) => {
|
||||
if (param.tipo === "select") {
|
||||
return (
|
||||
<Select
|
||||
key={param.nome}
|
||||
label={param.nome}
|
||||
options={param.options}
|
||||
value={paramInputs[param.nome] ?? ""}
|
||||
onChange={(val) => handleInputChange(param.nome, val)}
|
||||
placeholder={`Selecionar ${param.nome}`}
|
||||
aria-label={param.nome}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={param.nome}
|
||||
type={param.tipo}
|
||||
placeholder={param.placeholder || param.nome}
|
||||
value={paramInputs[param.nome] ?? ""}
|
||||
onChange={(e) => handleInputChange(param.nome, e.target.value)}
|
||||
aria-label={param.nome}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex items-center gap-2 relative">
|
||||
<ResultDisplay
|
||||
value={resultado}
|
||||
validated={validado}
|
||||
onChange={handleResultadoChange}
|
||||
aria-label={`${titulo} resultado`}
|
||||
/>
|
||||
|
||||
{resultado && (
|
||||
<Button
|
||||
onClick={handleCopiar}
|
||||
icon={<CopyIcon className="w-4 h-4" />}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs h-auto"
|
||||
title="Copiar"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-x-2">
|
||||
<Button
|
||||
onClick={handleGerar}
|
||||
icon={<RefreshCwIcon className="w-4 h-4" />}
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
title={`Gerar ${titulo}`}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "A gerar..." : "Gerar"}
|
||||
</Button>
|
||||
|
||||
{onValidar && (
|
||||
<Button
|
||||
onClick={handleValidar}
|
||||
variant={validado === true ? "primary" : "danger"}
|
||||
className="flex-1"
|
||||
title={`Validar ${titulo}`}
|
||||
disabled={loading || !resultado}
|
||||
>
|
||||
{loading ? "Verificando..." : "Validar"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import GeradorService from "../../../Api/GeradorApi.tsx";
|
||||
import GeradorCard from "../GeradorCard.tsx";
|
||||
|
||||
const GenerateCC = () => {
|
||||
|
||||
const handleGenerateCC = async () => {
|
||||
const cc = await GeradorService.GenerateCC()
|
||||
return cc;
|
||||
};
|
||||
|
||||
const handleValidateCC = async (valor: string) => {
|
||||
// Lógica real de validação
|
||||
//const bol = await GeradorService.ValidateCC(valor);
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6">
|
||||
<GeradorCard
|
||||
titulo="Cartão Cidadão"
|
||||
onGerar={handleGenerateCC}
|
||||
onValidar={handleValidateCC}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateCC;
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import GeradorCard, { type Parametro } from "../GeradorCard";
|
||||
import { EnumToOptions, getEnumKeyByValue } from "../../../library/utils";
|
||||
import { NIFType } from "../../../service/api";
|
||||
import GeradorService from "../../../Api/GeradorApi";
|
||||
|
||||
const NifTypes = EnumToOptions(NIFType).map((opt) => ({
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
}));
|
||||
|
||||
const GenerateNIF = () => {
|
||||
const handleGenerateNIF = async (params?: Record<string, string>) => {
|
||||
const tipoSelecionado = params?.["Tipo de NIF"] ?? String(NIFType.PessoaSingular2);
|
||||
const nif = String(await GeradorService.GenerateNIF(tipoSelecionado));
|
||||
return nif;
|
||||
};
|
||||
|
||||
const handleValidateNIF = async (valor: string) => {
|
||||
const bol = Boolean(await GeradorService.ValidateNIF(valor));
|
||||
return bol;
|
||||
};
|
||||
|
||||
const parametros: Parametro[] = [
|
||||
{
|
||||
nome: "Tipo de NIF",
|
||||
tipo: "select",
|
||||
options: NifTypes,
|
||||
default: getEnumKeyByValue(NIFType, NIFType.PessoaSingular2)
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6">
|
||||
<GeradorCard
|
||||
titulo="NIF"
|
||||
parametros={parametros}
|
||||
onGerar={handleGenerateNIF}
|
||||
onValidar={handleValidateNIF}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateNIF;
|
||||
@@ -1,28 +0,0 @@
|
||||
import GeradorService from "../../../Api/GeradorApi";
|
||||
import GeradorCard from "../GeradorCard.tsx";
|
||||
|
||||
const GenerateNISS = () => {
|
||||
|
||||
const handleGenerateNISS = async () => {
|
||||
const niss = await GeradorService.GenerateNISS()
|
||||
return niss;
|
||||
};
|
||||
|
||||
const handleValidateNISS = async (valor: string) => {
|
||||
// Lógica real de validação
|
||||
const bol = await GeradorService.ValidateNISS(valor);
|
||||
return bol;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6">
|
||||
<GeradorCard
|
||||
titulo="NISS"
|
||||
onGerar={handleGenerateNISS}
|
||||
onValidar={handleValidateNISS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateNISS;
|
||||
26
geradoresfe/src/components/LoadState.tsx
Normal file
26
geradoresfe/src/components/LoadState.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2, ServerCrashIcon } from "lucide-react";
|
||||
import { useGenerators } from "../lib/useGenerators";
|
||||
|
||||
/** Mostra loading/erro enquanto a lista de geradores não está disponível. */
|
||||
export function LoadState({ children }: { children: ReactNode }) {
|
||||
const { loading, error } = useGenerators();
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex justify-center py-16 text-gray-500">
|
||||
<Loader2 className="h-8 w-8 animate-spin" aria-label="A carregar" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div className="mx-auto mt-12 flex max-w-md flex-col items-center gap-2 rounded-2xl border border-red-200 bg-red-50 p-6 text-center text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200">
|
||||
<ServerCrashIcon className="h-8 w-8" />
|
||||
<p className="font-semibold">Não foi possível carregar os geradores.</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// components/Logo.tsx
|
||||
import logo from '../assets/Logotipo.png';
|
||||
|
||||
export const Logo = () => (
|
||||
<img src={logo} alt="FactoryId logo" className="h-8 w-8" />
|
||||
);
|
||||
25
geradoresfe/src/components/OptionsForm.tsx
Normal file
25
geradoresfe/src/components/OptionsForm.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Generator } from "../lib/api";
|
||||
import { Select } from "./ui/Select";
|
||||
|
||||
type OptionsFormProps = {
|
||||
generator: Generator;
|
||||
values: Record<string, string>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
};
|
||||
|
||||
export function OptionsForm({ generator, values, onChange }: OptionsFormProps) {
|
||||
if (generator.options.length === 0) return null;
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{generator.options.map((option) => (
|
||||
<Select
|
||||
key={option.key}
|
||||
label={option.label}
|
||||
options={option.choices}
|
||||
value={values[option.key] ?? option.defaultValue}
|
||||
onChange={(v) => onChange(option.key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
geradoresfe/src/components/ValidationResultView.tsx
Normal file
39
geradoresfe/src/components/ValidationResultView.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { AlertTriangleIcon, CheckCircle2Icon, XCircleIcon } from "lucide-react";
|
||||
import type { ValidationResult } from "../lib/api";
|
||||
|
||||
type Props = {
|
||||
result: ValidationResult;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function ValidationResultView({ result, title }: Props) {
|
||||
const tone = result.isValid
|
||||
? "border-green-300 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950/50 dark:text-green-200"
|
||||
: result.formatMatch
|
||||
? "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-200"
|
||||
: "border-red-300 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950/50 dark:text-red-200";
|
||||
|
||||
const Icon = result.isValid ? CheckCircle2Icon : result.formatMatch ? AlertTriangleIcon : XCircleIcon;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${tone}`} role="status">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && <p className="font-semibold">{title}</p>}
|
||||
<p className={title ? "text-sm" : "font-medium"}>{result.message}</p>
|
||||
{result.normalized && (
|
||||
<p className="mt-1 break-all font-mono text-sm opacity-80">{result.normalized}</p>
|
||||
)}
|
||||
{result.details && result.details.length > 0 && (
|
||||
<ul className="mt-2 list-inside list-disc space-y-0.5 text-sm opacity-80">
|
||||
{result.details.map((d) => (
|
||||
<li key={d}>{d}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
geradoresfe/src/components/Validator.tsx
Normal file
84
geradoresfe/src/components/Validator.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { SearchCheckIcon } from "lucide-react";
|
||||
import { api, type DetectMatch } from "../lib/api";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Input } from "./ui/Input";
|
||||
import { ValidationResultView } from "./ValidationResultView";
|
||||
|
||||
/**
|
||||
* Validador livre: o utilizador cola um valor qualquer e a API identifica o tipo
|
||||
* (NIF, IBAN, CC, …) e se é válido, incluindo "quase" correspondências com o dígito errado.
|
||||
*/
|
||||
export function Validator({ autoFocus = false }: { autoFocus?: boolean }) {
|
||||
const [value, setValue] = useState("");
|
||||
const [matches, setMatches] = useState<DetectMatch[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!value.trim()) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setMatches(await api.detect(value));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setMatches(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const valid = matches?.filter((m) => m.result.isValid) ?? [];
|
||||
const nearMisses = matches?.filter((m) => !m.result.isValid) ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setMatches(null);
|
||||
}}
|
||||
placeholder="Cole um NIF, NISS, CC, IBAN, cartão, matrícula, EAN…"
|
||||
aria-label="Valor a validar"
|
||||
autoFocus={autoFocus}
|
||||
className="py-3 font-mono text-base"
|
||||
/>
|
||||
<Button type="submit" loading={loading} icon={<SearchCheckIcon className="h-4 w-4" />} className="py-3 sm:w-40">
|
||||
Validar
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
|
||||
{matches && matches.length === 0 && (
|
||||
<ValidationResultView
|
||||
result={{ isValid: false, formatMatch: false, message: "Não foi reconhecido como nenhum dos tipos suportados." }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{valid.map((m) => (
|
||||
<ValidationResultView key={m.generatorId} title={m.generatorName} result={m.result} />
|
||||
))}
|
||||
|
||||
{valid.length === 0 && nearMisses.map((m) => (
|
||||
<ValidationResultView key={m.generatorId} title={`Parece um ${m.generatorName}, mas…`} result={m.result} />
|
||||
))}
|
||||
|
||||
{valid.length > 0 && nearMisses.length > 0 && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Também tem o formato de: {nearMisses.map((m) => (
|
||||
<Link key={m.generatorId} to={`/gerador/${m.generatorId}`} className="mr-2 underline">
|
||||
{m.generatorName}
|
||||
</Link>
|
||||
))}
|
||||
(mas não é válido como tal).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
geradoresfe/src/components/ui/Badge.tsx
Normal file
18
geradoresfe/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const tones = {
|
||||
neutral: "bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-gray-300",
|
||||
blue: "bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300",
|
||||
green: "bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300",
|
||||
red: "bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300",
|
||||
amber: "bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",
|
||||
};
|
||||
|
||||
export function Badge({ tone = "neutral", children, className }: { tone?: keyof typeof tones; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<span className={twMerge("inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium", tones[tone], className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +1,36 @@
|
||||
// components/ui/Button.tsx
|
||||
import type { ReactNode } from "react";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
icon?: ReactNode;
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
fullWidth?: boolean;
|
||||
rounded?: boolean | "md" | "lg" | "xl" | "2xl" | "full";
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
icon?: ReactNode;
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
icon,
|
||||
variant = "primary",
|
||||
children,
|
||||
className = "",
|
||||
type = "button",
|
||||
loading = false,
|
||||
loadingText,
|
||||
disabled,
|
||||
fullWidth = false,
|
||||
rounded = "2xl",
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const base = twMerge(
|
||||
"inline-flex items-center justify-center gap-2 px-5 py-2.5 font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md",
|
||||
fullWidth && "w-full",
|
||||
typeof rounded === "string" ? `rounded-${rounded}` : rounded === true ? "rounded-md" : "rounded-2xl"
|
||||
);
|
||||
|
||||
const variants = {
|
||||
primary:
|
||||
"bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-600 dark:hover:bg-blue-500",
|
||||
const variants = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500 dark:hover:bg-blue-500",
|
||||
secondary:
|
||||
"bg-zinc-100 text-zinc-900 hover:bg-zinc-200 focus:ring-zinc-400 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600",
|
||||
ghost:
|
||||
"bg-transparent text-zinc-700 hover:bg-zinc-100 focus:ring-zinc-300 dark:text-zinc-200 dark:hover:bg-zinc-800",
|
||||
danger:
|
||||
"bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 dark:bg-red-600 dark:hover:bg-red-500",
|
||||
};
|
||||
"bg-zinc-100 text-zinc-900 hover:bg-zinc-200 focus-visible:ring-zinc-400 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600",
|
||||
ghost: "bg-transparent text-zinc-700 hover:bg-zinc-100 focus-visible:ring-zinc-300 dark:text-zinc-200 dark:hover:bg-zinc-800",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500 dark:hover:bg-red-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={twMerge(base, variants[variant], className)}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin w-4 h-4" />
|
||||
{loadingText && <span>{loadingText}</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{icon && <span className="flex items-center">{icon}</span>}
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
export function Button({ icon, variant = "primary", children, className, type = "button", loading = false, disabled, ...rest }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={twMerge(
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-offset-zinc-900",
|
||||
variants[variant],
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function ButtonGroup({ children }: { children: ReactNode }) {
|
||||
return <div className="flex flex-wrap w-full gap-x-2">{children}</div>;
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
import { SunIcon, MoonIcon } from "@heroicons/react/24/outline";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
export function DarkModeToggle() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const handleToggle = () => {
|
||||
console.log("Toggle clicado, tema antes:", theme);
|
||||
toggleTheme();
|
||||
};
|
||||
|
||||
console.log("Render DarkModeToggle, theme =", theme);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="p-2 text-gray-700 hover:text-blue-500 dark:text-gray-300"
|
||||
aria-label="Alternar modo escuro"
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="rounded-md p-2 text-gray-700 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400"
|
||||
aria-label={theme === "dark" ? "Mudar para tema claro" : "Mudar para tema escuro"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<SunIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<MoonIcon className="w-6 h-6" />
|
||||
)}
|
||||
{theme === "dark" ? <SunIcon className="h-5 w-5" /> : <MoonIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from "react";
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export function Input({ className = "", ...props }: InputProps) {
|
||||
export function Input({ className, ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={
|
||||
"w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 " +
|
||||
className
|
||||
}
|
||||
className={twMerge(
|
||||
"w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-gray-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import { CheckIcon, XIcon } from "lucide-react";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { copyToClipboard } from "../../lib/clipboard";
|
||||
|
||||
type ResultDisplayProps = {
|
||||
value: string;
|
||||
validated: boolean | null;
|
||||
onChange?: (value: string) => void;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function ResultDisplay({ value, validated, onChange }: ResultDisplayProps) {
|
||||
const baseClasses = "flex items-center gap-2 px-3 py-2 border rounded-md text-sm w-full";
|
||||
const colors =
|
||||
validated === true
|
||||
? "border-green-500 text-green-700 bg-green-50 dark:text-green-400 dark:bg-green-950 dark:border-green-700"
|
||||
: validated === false
|
||||
? "border-red-500 text-red-700 bg-red-50 dark:text-red-400 dark:bg-red-950 dark:border-red-700"
|
||||
: "border-gray-300 text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-zinc-800 dark:border-gray-600";
|
||||
|
||||
export function ResultDisplay({ value, label }: ResultDisplayProps) {
|
||||
return (
|
||||
<div className={baseClasses + " " + colors}>
|
||||
<input
|
||||
type="text"
|
||||
readOnly={!onChange}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
className="flex-1 bg-transparent outline-none font-mono truncate"
|
||||
/>
|
||||
{validated === true && <CheckIcon className="w-5 h-5" />}
|
||||
{validated === false && <XIcon className="w-5 h-5" />}
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 dark:border-zinc-600 dark:bg-zinc-800">
|
||||
<output aria-label={`${label} gerado`} className="min-h-5 flex-1 truncate font-mono text-sm text-gray-900 dark:text-gray-100">
|
||||
{value || <span className="text-gray-400">—</span>}
|
||||
</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(value, label)}
|
||||
disabled={!value}
|
||||
className="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-blue-600 disabled:opacity-30 dark:hover:bg-zinc-700"
|
||||
title="Copiar"
|
||||
aria-label="Copiar"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
// components/ui/Select.tsx
|
||||
import React from "react";
|
||||
import { useId } from "react";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
type Option = { label: string; value: string };
|
||||
|
||||
interface SelectProps {
|
||||
label?: string;
|
||||
options: Option[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const Select = ({ label, options, value, onChange, placeholder }: SelectProps) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{label && <label className="mb-1 font-medium text-gray-700 dark:text-gray-300">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="border border-gray-300 rounded-md px-3 py-2 bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 transition"
|
||||
>
|
||||
<option value="" disabled>{placeholder || "Selecionar..."}</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
type SelectProps = {
|
||||
label?: string;
|
||||
options: Option[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function Select({ label, options, value, onChange }: SelectProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 transition focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-gray-100"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
|
||||
type SidebarContextType = {
|
||||
isExpanded: boolean;
|
||||
isMobileOpen: boolean;
|
||||
isHovered: boolean;
|
||||
activeItem: string | null;
|
||||
openSubmenu: string | null;
|
||||
toggleSidebar: () => void;
|
||||
toggleMobileSidebar: () => void;
|
||||
setIsHovered: (isHovered: boolean) => void;
|
||||
setActiveItem: (item: string | null) => void;
|
||||
toggleSubmenu: (item: string) => void;
|
||||
};
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||
|
||||
export const useSidebar = () => {
|
||||
const context = useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [activeItem, setActiveItem] = useState<string | null>(null);
|
||||
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const mobile = window.innerWidth < 768;
|
||||
setIsMobile(mobile);
|
||||
if (!mobile) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
const toggleMobileSidebar = () => {
|
||||
setIsMobileOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const toggleSubmenu = (item: string) => {
|
||||
setOpenSubmenu((prev) => (prev === item ? null : item));
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
isExpanded: isMobile ? false : isExpanded,
|
||||
isMobileOpen,
|
||||
isHovered,
|
||||
activeItem,
|
||||
openSubmenu,
|
||||
toggleSidebar,
|
||||
toggleMobileSidebar,
|
||||
setIsHovered,
|
||||
setActiveItem,
|
||||
toggleSubmenu,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
@@ -9,46 +9,36 @@ type ThemeContextType = {
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [theme, setTheme] = useState<Theme | null>(null); // null = tema ainda não carregado
|
||||
function initialTheme(): Theme {
|
||||
try {
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
} catch {
|
||||
// localStorage indisponível (modo privado, etc.)
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(initialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = localStorage.getItem("theme") as Theme | null;
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const initialTheme = storedTheme ?? (prefersDark ? "dark" : "light");
|
||||
setTheme(initialTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === null) return; // ainda a carregar
|
||||
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
try {
|
||||
localStorage.setItem("theme", theme);
|
||||
} catch {
|
||||
// ignorar
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => {
|
||||
const nextTheme = prev === "dark" ? "light" : "dark";
|
||||
console.log("ToggleTheme:", prev, "->", nextTheme);
|
||||
return nextTheme;
|
||||
});
|
||||
//setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
};
|
||||
const toggleTheme = () => setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
|
||||
// Enquanto tema não carregou, não renderiza nada
|
||||
if (theme === null) return null;
|
||||
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = (): ThemeContextType => {
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useTheme(): ThemeContextType {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return context;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
const useGoBack = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.state && window.history.state.idx > 0) {
|
||||
navigate(-1); // Go back to the previous page
|
||||
} else {
|
||||
navigate("/"); // Redirect to home if no history exists
|
||||
}
|
||||
};
|
||||
|
||||
return goBack;
|
||||
};
|
||||
|
||||
export default useGoBack;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export const useModal = (initialState: boolean = false) => {
|
||||
const [isOpen, setIsOpen] = useState(initialState);
|
||||
|
||||
const openModal = useCallback(() => setIsOpen(true), []);
|
||||
const closeModal = useCallback(() => setIsOpen(false), []);
|
||||
const toggleModal = useCallback(() => setIsOpen((prev) => !prev), []);
|
||||
|
||||
return { isOpen, openModal, closeModal, toggleModal };
|
||||
};
|
||||
@@ -1,3 +1,11 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -1,67 +1,38 @@
|
||||
import { Link } from "react-router";
|
||||
import { docsUrl } from "../lib/api";
|
||||
|
||||
export default function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-100 dark:bg-zinc-900 text-gray-700 dark:text-gray-300 mt-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Marca e descrição */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800 dark:text-white">FactoryiD</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Geradores inteligentes para profissionais modernos. Soluções simples, rápidas e acessíveis.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navegação principal */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Navegação</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>
|
||||
<Link to="/" className="hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
|
||||
Início
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/home" className="hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
|
||||
Geradores
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/contacto" className="hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
|
||||
Contacto
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Outros links */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Outros</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>
|
||||
<Link to="/termos" className="hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
|
||||
Termos de Utilização
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/privacidade" className="hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<footer className="mt-20 border-t border-gray-200 bg-gray-50 text-gray-700 dark:border-zinc-800 dark:bg-zinc-950 dark:text-gray-300">
|
||||
<div className="mx-auto grid max-w-7xl gap-8 px-4 py-12 sm:px-6 md:grid-cols-3 lg:px-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800 dark:text-white">Factory Id</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Geradores e validadores de dados portugueses para testes de software.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Separador */}
|
||||
<hr className="my-8 border-gray-300 dark:border-gray-700" />
|
||||
|
||||
{/* Copyright */}
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
© {year} FactoryiD. Todos os direitos reservados.
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-100">Navegação</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li><Link to="/" className="hover:text-blue-600 dark:hover:text-blue-400">Geradores</Link></li>
|
||||
<li><Link to="/validar" className="hover:text-blue-600 dark:hover:text-blue-400">Validador</Link></li>
|
||||
<li><a href={docsUrl} target="_blank" rel="noreferrer" className="hover:text-blue-600 dark:hover:text-blue-400">Documentação da API</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-100">Aviso</h3>
|
||||
<p>
|
||||
Todos os dados são fictícios e gerados aleatoriamente. Destinam-se exclusivamente a testes;
|
||||
qualquer coincidência com dados reais é acidental.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 py-6 text-center text-sm text-gray-500 dark:border-zinc-800 dark:text-gray-400">
|
||||
© {year} Factory Id
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,172 +1,105 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router";
|
||||
import { Disclosure, Menu, Transition } from "@headlessui/react";
|
||||
import { Bars3Icon, XMarkIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { Link, NavLink } from "react-router";
|
||||
import {
|
||||
Disclosure, DisclosureButton, DisclosurePanel, Menu, MenuButton, MenuItem, MenuItems,
|
||||
} from "@headlessui/react";
|
||||
import { ChevronDownIcon, MenuIcon, XIcon } from "lucide-react";
|
||||
import Logo from "../assets/logotipo32x32.png";
|
||||
import { DarkModeToggle } from "../components/ui/DarkModeToggle";
|
||||
import { useGenerators } from "../lib/useGenerators";
|
||||
import { docsUrl } from "../lib/api";
|
||||
|
||||
const navItems = [
|
||||
{ name: "Início", path: "/" },
|
||||
{
|
||||
name: "Geradores",
|
||||
subItems: [
|
||||
{ name: "Gerador de NIFs", path: "/NIF", },
|
||||
{ name: "Gerador de NISS", path: "/NISS" },
|
||||
{ name: "Gerador de CC", path: "/CC" },
|
||||
],
|
||||
},
|
||||
{ name: "Contacto", path: "/contacto" },
|
||||
];
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium transition-colors ${isActive
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-700 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400"}`;
|
||||
|
||||
function Header() {
|
||||
const location = useLocation();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
export default function Header() {
|
||||
const { byCategory } = useGenerators();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setIsScrolled(window.scrollY > 10);
|
||||
window.addEventListener("scroll", onScroll);
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onScroll = () => setIsScrolled(window.scrollY > 10);
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const navLinkClass = (isActive: boolean) =>
|
||||
`px-3 py-2 rounded-md text-sm font-medium transition ${isActive
|
||||
? "text-blue-600 font-semibold"
|
||||
: "text-gray-700 hover:text-blue-500 dark:text-gray-300 dark:hover:text-blue-400"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<Disclosure as="nav">
|
||||
{({ open }) => (
|
||||
<header
|
||||
className={`fixed top-0 w-full z-50 bg-white dark:bg-zinc-900 shadow transition-all duration-300 ${
|
||||
isScrolled ? "py-2 shadow-md" : "py-4"
|
||||
}`}
|
||||
return (
|
||||
<Disclosure
|
||||
as="header"
|
||||
className={`sticky top-0 z-50 border-b bg-white/90 backdrop-blur transition-all dark:bg-zinc-900/90 ${isScrolled ? "border-gray-200 shadow-sm dark:border-zinc-800" : "border-transparent"}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between items-center">
|
||||
{/* LOGO */}
|
||||
<Link to="/" className="flex items-center space-x-2">
|
||||
<img src={Logo} alt="Logo" className="h-8 w-8" />
|
||||
<span className="font-bold text-lg text-gray-800 dark:text-white">
|
||||
Factory Id
|
||||
</span>
|
||||
</Link>
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src={Logo} alt="" className="h-8 w-8" />
|
||||
<span className="text-lg font-bold text-gray-800 dark:text-white">Factory Id</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<div className="hidden md:flex space-x-6 items-center">
|
||||
{navItems.map((item) =>
|
||||
item.subItems ? (
|
||||
<Menu as="div" className="relative" key={item.name}>
|
||||
<Menu.Button className="flex items-center gap-1 text-gray-700 hover:text-blue-500 dark:text-gray-300 dark:hover:text-blue-400 text-sm font-medium">
|
||||
{item.name}
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute mt-2 w-56 rounded-md bg-white dark:bg-zinc-800 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
|
||||
<div className="py-1">
|
||||
{item.subItems.map((sub) => (
|
||||
<Menu.Item key={sub.name}>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
to={sub.path}
|
||||
className={`block px-4 py-2 text-sm ${
|
||||
active
|
||||
? "bg-gray-100 dark:bg-zinc-700 text-blue-600"
|
||||
: "text-gray-700 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{sub.name}
|
||||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
) : (
|
||||
<NavLink
|
||||
to={item.path}
|
||||
key={item.name}
|
||||
className={({ isActive }) => navLinkClass(isActive)}
|
||||
>
|
||||
{item.name}
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
<nav className="hidden items-center gap-2 md:flex" aria-label="Principal">
|
||||
<NavLink to="/" end className={linkClass}>Início</NavLink>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden">
|
||||
<Disclosure.Button className="text-gray-700 hover:text-blue-500 dark:text-gray-300">
|
||||
{open ? (
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Panel */}
|
||||
<Disclosure.Panel className="md:hidden bg-white dark:bg-zinc-900 shadow-inner">
|
||||
<div className="px-4 pt-2 pb-4 space-y-1">
|
||||
{navItems.map((item) =>
|
||||
item.subItems ? (
|
||||
<div key={item.name}>
|
||||
<span className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||||
{item.name}
|
||||
</span>
|
||||
<div className="pl-4 space-y-1">
|
||||
{item.subItems.map((sub) => (
|
||||
<NavLink
|
||||
to={sub.path}
|
||||
key={sub.name}
|
||||
className={({ isActive }) =>
|
||||
`block text-sm px-3 py-1 rounded ${
|
||||
isActive
|
||||
? "text-blue-600 font-semibold"
|
||||
: "text-gray-700 dark:text-gray-300"
|
||||
}`
|
||||
}
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400">
|
||||
Geradores <ChevronDownIcon className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
anchor="bottom start"
|
||||
className="z-50 mt-2 w-[36rem] origin-top-left rounded-xl bg-white p-4 shadow-lg ring-1 ring-black/5 transition duration-100 ease-out focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0 dark:bg-zinc-800 dark:ring-white/10"
|
||||
>
|
||||
{sub.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NavLink
|
||||
to={item.path}
|
||||
key={item.name}
|
||||
className={({ isActive }) =>
|
||||
`block text-sm px-3 py-2 rounded-md ${
|
||||
isActive
|
||||
? "text-blue-600 font-semibold"
|
||||
: "text-gray-700 dark:text-gray-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</header>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||
{byCategory.map(([category, generators]) => (
|
||||
<div key={category}>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-400">{category}</p>
|
||||
{generators.map((g) => (
|
||||
<MenuItem key={g.id}>
|
||||
<Link
|
||||
to={`/gerador/${g.id}`}
|
||||
className="block rounded-md px-2 py-1.5 text-sm text-gray-700 data-[focus]:bg-blue-50 data-[focus]:text-blue-700 dark:text-gray-200 dark:data-[focus]:bg-zinc-700"
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
|
||||
export default Header;
|
||||
<NavLink to="/validar" className={linkClass}>Validador</NavLink>
|
||||
<a href={docsUrl} target="_blank" rel="noreferrer" className={linkClass({ isActive: false })}>API</a>
|
||||
<DarkModeToggle />
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-1 md:hidden">
|
||||
<DarkModeToggle />
|
||||
<DisclosureButton className="group rounded-md p-2 text-gray-700 dark:text-gray-300" aria-label="Menu">
|
||||
<MenuIcon className="h-6 w-6 group-data-[open]:hidden" />
|
||||
<XIcon className="hidden h-6 w-6 group-data-[open]:block" />
|
||||
</DisclosureButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosurePanel className="border-t border-gray-200 px-4 pb-4 pt-2 md:hidden dark:border-zinc-800">
|
||||
{({ close }) => (
|
||||
<nav className="flex flex-col gap-1" aria-label="Principal (móvel)">
|
||||
<NavLink to="/" end className={linkClass} onClick={() => close()}>Início</NavLink>
|
||||
<NavLink to="/validar" className={linkClass} onClick={() => close()}>Validador</NavLink>
|
||||
{byCategory.map(([category, generators]) => (
|
||||
<div key={category} className="mt-2">
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">{category}</p>
|
||||
{generators.map((g) => (
|
||||
<NavLink key={g.id} to={`/gerador/${g.id}`} className={linkClass} onClick={() => close()}>
|
||||
<span className="block">{g.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<a href={docsUrl} target="_blank" rel="noreferrer" className={linkClass({ isActive: false })}>API</a>
|
||||
</nav>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
// components/layout/Layout.tsx
|
||||
import Footer from './Footer';
|
||||
import Header from './Header';
|
||||
|
||||
export const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
import type { ReactNode } from "react";
|
||||
import Footer from "./Footer";
|
||||
import Header from "./Header";
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 transition-colors duration-300">
|
||||
<Header />
|
||||
<main className="flex-grow mx-auto w-full">
|
||||
{children}
|
||||
</main>
|
||||
<div className="flex min-h-screen flex-col bg-white text-zinc-900 transition-colors dark:bg-zinc-900 dark:text-zinc-100">
|
||||
<Header />
|
||||
<main className="w-full flex-grow">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
84
geradoresfe/src/lib/api.ts
Normal file
84
geradoresfe/src/lib/api.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Cliente da API. Por omissão usa caminhos relativos (/api/v1): em dev o Vite faz proxy,
|
||||
// em produção o nginx encaminha /api para o backend. VITE_API_URL permite apontar para outro host.
|
||||
const BASE = `${(import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "")}/api/v1`;
|
||||
|
||||
export type OptionChoice = { value: string; label: string };
|
||||
|
||||
export type GeneratorOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
choices: OptionChoice[];
|
||||
defaultValue: string;
|
||||
};
|
||||
|
||||
export type Generator = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
hasChecksum: boolean;
|
||||
canValidate: boolean;
|
||||
options: GeneratorOption[];
|
||||
};
|
||||
|
||||
export type GeneratedItem = {
|
||||
value: string;
|
||||
fields?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
export type ValidationResult = {
|
||||
isValid: boolean;
|
||||
formatMatch: boolean;
|
||||
message: string;
|
||||
normalized?: string | null;
|
||||
details?: string[] | null;
|
||||
};
|
||||
|
||||
export type DetectMatch = {
|
||||
generatorId: string;
|
||||
generatorName: string;
|
||||
result: ValidationResult;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {}
|
||||
|
||||
async function get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params ?? {}))
|
||||
if (v !== undefined && v !== "") query.set(k, String(v));
|
||||
const qs = query.toString();
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${BASE}${path}${qs ? `?${qs}` : ""}`);
|
||||
} catch {
|
||||
throw new ApiError("Não foi possível contactar o servidor.");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const problem = await response.json().catch(() => null);
|
||||
const firstError = problem?.errors ? Object.values(problem.errors).flat()[0] : null;
|
||||
throw new ApiError(String(firstError ?? problem?.title ?? `Erro ${response.status}`));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listGenerators: () => get<Generator[]>("/generators"),
|
||||
|
||||
generate: (id: string, options: Record<string, string>, count = 1) =>
|
||||
get<{ generator: string; items: GeneratedItem[] }>(`/generators/${encodeURIComponent(id)}/generate`, { ...options, count })
|
||||
.then((r) => r.items),
|
||||
|
||||
validate: (id: string, value: string) =>
|
||||
get<ValidationResult>(`/generators/${encodeURIComponent(id)}/validate`, { value }),
|
||||
|
||||
detect: (value: string) =>
|
||||
get<{ value: string; matches: DetectMatch[] }>("/validate", { value }).then((r) => r.matches),
|
||||
};
|
||||
|
||||
export function defaultOptions(generator: Generator): Record<string, string> {
|
||||
return Object.fromEntries(generator.options.map((o) => [o.key, o.defaultValue]));
|
||||
}
|
||||
|
||||
export const docsUrl =`${(import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "")}/docs`;
|
||||
10
geradoresfe/src/lib/clipboard.ts
Normal file
10
geradoresfe/src/lib/clipboard.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
export async function copyToClipboard(text: string, label = "Valor") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copiado!`, { duration: 1500 });
|
||||
} catch {
|
||||
toast.error("Não foi possível copiar para a área de transferência.");
|
||||
}
|
||||
}
|
||||
33
geradoresfe/src/lib/export.test.ts
Normal file
33
geradoresfe/src/lib/export.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { columnsOf, toCsv, toJson, toText } from "./export";
|
||||
|
||||
const simple = [{ value: "123456789" }, { value: "500000000" }];
|
||||
const composite = [
|
||||
{ value: "Ana Silva", fields: { Nome: "Ana Silva", Morada: 'Rua "A"; 1' } },
|
||||
{ value: "Rui Costa", fields: { Nome: "Rui Costa", Morada: "Rua B" } },
|
||||
];
|
||||
|
||||
describe("export", () => {
|
||||
it("usa a coluna Valor para geradores simples", () => {
|
||||
expect(columnsOf(simple)).toEqual(["Valor"]);
|
||||
});
|
||||
|
||||
it("usa os campos como colunas para geradores compostos", () => {
|
||||
expect(columnsOf(composite)).toEqual(["Nome", "Morada"]);
|
||||
});
|
||||
|
||||
it("gera CSV com BOM, ';' e aspas escapadas", () => {
|
||||
const csv = toCsv(composite);
|
||||
expect(csv.startsWith("")).toBe(true);
|
||||
expect(csv).toBe('Nome;Morada\r\nAna Silva;"Rua ""A""; 1"\r\nRui Costa;Rua B');
|
||||
});
|
||||
|
||||
it("gera JSON com valores ou objectos", () => {
|
||||
expect(JSON.parse(toJson(simple))).toEqual(["123456789", "500000000"]);
|
||||
expect(JSON.parse(toJson(composite))[1]).toEqual({ Nome: "Rui Costa", Morada: "Rua B" });
|
||||
});
|
||||
|
||||
it("gera texto com um valor por linha", () => {
|
||||
expect(toText(simple)).toBe("123456789\n500000000");
|
||||
});
|
||||
});
|
||||
40
geradoresfe/src/lib/export.ts
Normal file
40
geradoresfe/src/lib/export.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { GeneratedItem } from "./api";
|
||||
|
||||
/** Colunas da tabela/exportação: "Valor" para geradores simples, ou os campos dos compostos. */
|
||||
export function columnsOf(items: GeneratedItem[]): string[] {
|
||||
const first = items.find((i) => i.fields);
|
||||
return first?.fields ? Object.keys(first.fields) : ["Valor"];
|
||||
}
|
||||
|
||||
export function rowOf(item: GeneratedItem, columns: string[]): string[] {
|
||||
return item.fields ? columns.map((c) => item.fields?.[c] ?? "") : [item.value];
|
||||
}
|
||||
|
||||
function csvCell(value: string): string {
|
||||
return /[";\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
}
|
||||
|
||||
/** CSV com separador ";" (o Excel em PT usa ";") e BOM para os acentos abrirem bem. */
|
||||
export function toCsv(items: GeneratedItem[]): string {
|
||||
const columns = columnsOf(items);
|
||||
const lines = [columns, ...items.map((i) => rowOf(i, columns))].map((row) => row.map(csvCell).join(";"));
|
||||
return "" + lines.join("\r\n");
|
||||
}
|
||||
|
||||
export function toJson(items: GeneratedItem[]): string {
|
||||
const data = items.map((i) => i.fields ?? i.value);
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export function toText(items: GeneratedItem[]): string {
|
||||
return items.map((i) => i.value).join("\n");
|
||||
}
|
||||
|
||||
export function download(content: string, filename: string, mime: string) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: mime }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
8
geradoresfe/src/lib/slug.ts
Normal file
8
geradoresfe/src/lib/slug.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function slugify(text: string) {
|
||||
return text
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
47
geradoresfe/src/lib/useGenerators.tsx
Normal file
47
geradoresfe/src/lib/useGenerators.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { api, type Generator } from "./api";
|
||||
|
||||
type GeneratorsState = {
|
||||
generators: Generator[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
byCategory: [string, Generator[]][];
|
||||
find: (id: string) => Generator | undefined;
|
||||
};
|
||||
|
||||
const GeneratorsContext = createContext<GeneratorsState | undefined>(undefined);
|
||||
|
||||
/** Carrega a lista de geradores uma vez; toda a UI é construída a partir destes metadados. */
|
||||
export function GeneratorsProvider({ children }: { children: ReactNode }) {
|
||||
const [generators, setGenerators] = useState<Generator[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listGenerators()
|
||||
.then(setGenerators)
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const value = useMemo<GeneratorsState>(() => {
|
||||
const groups = new Map<string, Generator[]>();
|
||||
for (const g of generators) groups.set(g.category, [...(groups.get(g.category) ?? []), g]);
|
||||
return {
|
||||
generators,
|
||||
loading,
|
||||
error,
|
||||
byCategory: [...groups.entries()],
|
||||
find: (id) => generators.find((g) => g.id.toLowerCase() === id.toLowerCase()),
|
||||
};
|
||||
}, [generators, loading, error]);
|
||||
|
||||
return <GeneratorsContext.Provider value={value}>{children}</GeneratorsContext.Provider>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useGenerators() {
|
||||
const ctx = useContext(GeneratorsContext);
|
||||
if (!ctx) throw new Error("useGenerators tem de estar dentro de GeneratorsProvider");
|
||||
return ctx;
|
||||
}
|
||||
7
geradoresfe/src/lib/useTitle.ts
Normal file
7
geradoresfe/src/lib/useTitle.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useTitle(title?: string) {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} · Factory Id` : "Factory Id — Geradores e validadores";
|
||||
}, [title]);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
|
||||
const Utils = {
|
||||
EnumToOptions,
|
||||
// Outras fun<75><6E>es podem ser adicionadas aqui
|
||||
};
|
||||
|
||||
export function EnumToOptions<T extends Record<string, string>>(enumObj: T): any[] {
|
||||
return Object.entries(enumObj)
|
||||
.filter(([key]) => isNaN(Number(key))) // Ignora entradas num<75>ricas, caso existam
|
||||
.map(([key, value]) => ({
|
||||
value: key, // A chave do enum como `value`
|
||||
label: value, // O valor do enum como `label`
|
||||
}));
|
||||
}
|
||||
|
||||
export function getEnumKeyByValue<T extends Record<string, string>>(
|
||||
enumObj: T,
|
||||
value: string
|
||||
): string | undefined {
|
||||
return Object.keys(enumObj).find((key) => enumObj[key as keyof T] === value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default Utils;
|
||||
@@ -1,13 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ThemeProvider } from './context/ThemeContext.tsx'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import { ThemeProvider } from "./context/ThemeContext.tsx";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export default function Blank() {
|
||||
console.log("inicalizar componente");
|
||||
return (
|
||||
<div>
|
||||
|
||||
<div className="min-h-screen rounded-2xl border border-gray-200 bg-white px-5 py-7 dark:border-gray-800 dark:bg-white/[0.03] xl:px-10 xl:py-12">
|
||||
<div className="mx-auto w-full max-w-[630px] text-center">
|
||||
<h3 className="mb-4 font-semibold text-gray-800 text-theme-xl dark:text-white/90 sm:text-2xl">
|
||||
Card Title Here
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 sm:text-base">
|
||||
Start putting content on grids or panels, you can also use different
|
||||
combinations of grids.Please check out the dashboard and other pages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import GenerateCC from "../components/Geradores/Tipos/GenerateCC";
|
||||
|
||||
export default function CC() {
|
||||
const location = useLocation();
|
||||
|
||||
// Efeito para rolar até a seção correspondente ao path
|
||||
useEffect(() => {
|
||||
const featuresSection = document.getElementById(location.hash.substring(1));
|
||||
if (featuresSection) {
|
||||
featuresSection.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<div >
|
||||
<div >
|
||||
{/* Seção de Funcionalidades */}
|
||||
<section id="features" className="py-16 px-4 md:px-8">
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-1">
|
||||
<div id="nif">
|
||||
<GenerateCC />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
geradoresfe/src/pages/GeneratorPage.tsx
Normal file
267
geradoresfe/src/pages/GeneratorPage.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Link, useParams, useSearchParams } from "react-router";
|
||||
import { toast } from "react-hot-toast";
|
||||
import {
|
||||
ArrowLeftIcon, CopyIcon, FileJsonIcon, FileSpreadsheetIcon, FileTextIcon, RefreshCwIcon, SearchCheckIcon, Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { api, defaultOptions, type GeneratedItem, type Generator, type ValidationResult } from "../lib/api";
|
||||
import { columnsOf, download, rowOf, toCsv, toJson, toText } from "../lib/export";
|
||||
import { copyToClipboard } from "../lib/clipboard";
|
||||
import { useGenerators } from "../lib/useGenerators";
|
||||
import { useTitle } from "../lib/useTitle";
|
||||
import { OptionsForm } from "../components/OptionsForm";
|
||||
import { ChecksumBadge } from "../components/ChecksumBadge";
|
||||
import { LoadState } from "../components/LoadState";
|
||||
import { ValidationResultView } from "../components/ValidationResultView";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Input } from "../components/ui/Input";
|
||||
import NotFoundPage from "./NotFoundPage";
|
||||
|
||||
const MAX = 1000;
|
||||
const QUICK_COUNTS = [1, 10, 100, 1000];
|
||||
|
||||
export default function GeneratorPage() {
|
||||
const { id = "" } = useParams();
|
||||
const { find, loading } = useGenerators();
|
||||
const generator = find(id);
|
||||
|
||||
if (!loading && !generator) return <NotFoundPage />;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8">
|
||||
<Link to="/" className="mb-6 inline-flex items-center gap-1 text-sm text-gray-600 hover:text-blue-600 dark:text-gray-400">
|
||||
<ArrowLeftIcon className="h-4 w-4" /> Todos os geradores
|
||||
</Link>
|
||||
<LoadState>{generator && <GeneratorWorkspace key={generator.id} generator={generator} />}</LoadState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratorWorkspace({ generator }: { generator: Generator }) {
|
||||
useTitle(`Gerador de ${generator.name}`);
|
||||
|
||||
// As opções e a quantidade ficam no URL para se poder partilhar a configuração.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const options = { ...defaultOptions(generator) };
|
||||
for (const o of generator.options) {
|
||||
const v = searchParams.get(o.key);
|
||||
if (v && o.choices.some((c) => c.value === v)) options[o.key] = v;
|
||||
}
|
||||
const count = Math.min(MAX, Math.max(1, Number(searchParams.get("n")) || 10));
|
||||
|
||||
const update = (patch: Record<string, string>) =>
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
for (const [k, v] of Object.entries(patch)) next.set(k, v);
|
||||
return next;
|
||||
}, { replace: true });
|
||||
|
||||
const [items, setItems] = useState<GeneratedItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const generate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setItems(await api.generate(generator.id, options, count));
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mostrar logo resultados ao abrir a página.
|
||||
useEffect(() => {
|
||||
void generate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filename =`${generator.id}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "")}`;
|
||||
const apiExample = `/api/v1/generators/${generator.id}/generate?${new URLSearchParams({ count: String(count), ...options })}`;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[22rem_1fr]">
|
||||
<aside className="flex min-w-0 flex-col gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-extrabold text-gray-900 dark:text-white">{generator.name}</h1>
|
||||
<ChecksumBadge generator={generator} />
|
||||
</div>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">{generator.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<OptionsForm generator={generator} values={options} onChange={(k, v) => update({ [k]: v })} />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="count" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Quantidade
|
||||
</label>
|
||||
<Input
|
||||
id="count"
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX}
|
||||
value={count}
|
||||
onChange={(e) => update({ n: e.target.value })}
|
||||
/>
|
||||
<div className="mt-1 flex gap-1">
|
||||
{QUICK_COUNTS.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => update({ n: String(n) })}
|
||||
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium ${n === count
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-zinc-800 dark:text-gray-300 dark:hover:bg-zinc-700"}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={generate} loading={loading} icon={<RefreshCwIcon className="h-4 w-4" />} className="py-3">
|
||||
Gerar {count === 1 ? "" : count}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{generator.canValidate && <ValidateBox generator={generator} />}
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 p-4 text-sm dark:border-zinc-700">
|
||||
<p className="mb-2 font-medium text-gray-700 dark:text-gray-300">Via API</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(`${window.location.origin}${apiExample}`, "URL")}
|
||||
className="w-full break-all rounded bg-gray-100 p-2 text-left font-mono text-xs text-gray-800 hover:bg-gray-200 dark:bg-zinc-800 dark:text-gray-200 dark:hover:bg-zinc-700"
|
||||
title="Copiar URL"
|
||||
>
|
||||
GET {apiExample}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section aria-live="polite" className="min-w-0">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full min-h-64 flex-col items-center justify-center rounded-2xl border border-dashed border-gray-300 p-8 text-center text-gray-500 dark:border-zinc-700">
|
||||
<RefreshCwIcon className="mb-3 h-8 w-8" />
|
||||
<p>Escolha as opções e carregue em <strong>Gerar</strong>.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Results
|
||||
generator={generator}
|
||||
items={items}
|
||||
onClear={() => setItems([])}
|
||||
onCsv={() => download(toCsv(items), `${filename}.csv`, "text/csv;charset=utf-8")}
|
||||
onJson={() => download(toJson(items), `${filename}.json`, "application/json")}
|
||||
onTxt={() => download(toText(items), `${filename}.txt`, "text/plain;charset=utf-8")}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ResultsProps = {
|
||||
generator: Generator;
|
||||
items: GeneratedItem[];
|
||||
onClear: () => void;
|
||||
onCsv: () => void;
|
||||
onJson: () => void;
|
||||
onTxt: () => void;
|
||||
};
|
||||
|
||||
function Results({ generator, items, onClear, onCsv, onJson, onTxt }: ResultsProps) {
|
||||
const columns = columnsOf(items);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="mr-auto text-sm text-gray-600 dark:text-gray-400">
|
||||
{items.length} {items.length === 1 ? "valor gerado" : "valores gerados"}
|
||||
</p>
|
||||
<Button variant="secondary" onClick={() => copyToClipboard(toText(items), "Lista")} icon={<CopyIcon className="h-4 w-4" />}>
|
||||
Copiar tudo
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onCsv} icon={<FileSpreadsheetIcon className="h-4 w-4" />}>CSV</Button>
|
||||
<Button variant="secondary" onClick={onJson} icon={<FileJsonIcon className="h-4 w-4" />}>JSON</Button>
|
||||
<Button variant="secondary" onClick={onTxt} icon={<FileTextIcon className="h-4 w-4" />}>TXT</Button>
|
||||
<Button variant="ghost" onClick={onClear} icon={<Trash2Icon className="h-4 w-4" />} aria-label="Limpar" title="Limpar" />
|
||||
</div>
|
||||
|
||||
<div className="max-h-[70vh] overflow-auto rounded-2xl border border-gray-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500 dark:bg-zinc-800 dark:text-gray-400">
|
||||
<tr>
|
||||
<th className="w-12 px-3 py-2">#</th>
|
||||
{columns.map((c) => (
|
||||
<th key={c} className="whitespace-nowrap px-3 py-2">{c}</th>
|
||||
))}
|
||||
<th className="w-10 px-3 py-2"><span className="sr-only">Copiar</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-zinc-800">
|
||||
{items.map((item, i) => (
|
||||
<tr key={i} className="hover:bg-blue-50/50 dark:hover:bg-zinc-800/50">
|
||||
<td className="px-3 py-2 text-gray-400">{i + 1}</td>
|
||||
{rowOf(item, columns).map((cell, j) => (
|
||||
<td key={j} className="whitespace-nowrap px-3 py-2 font-mono text-gray-900 dark:text-gray-100">{cell}</td>
|
||||
))}
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(item.value, generator.name)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-blue-600 dark:hover:bg-zinc-700"
|
||||
aria-label={`Copiar ${item.value}`}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidateBox({ generator }: { generator: Generator }) {
|
||||
const [value, setValue] = useState("");
|
||||
const [result, setResult] = useState<ValidationResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!value.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setResult(await api.validate(generator.id, value));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex flex-col gap-3 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<label htmlFor="validate" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Validar um {generator.name}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="validate"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setResult(null);
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" loading={loading} icon={<SearchCheckIcon className="h-4 w-4" />} aria-label="Validar" />
|
||||
</div>
|
||||
{result && <ValidationResultView result={result} />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import GenerateNISS from "../components/Geradores/Tipos/GenerateNISS";
|
||||
import GenerateCC from "../components/Geradores/Tipos/GenerateCC";
|
||||
import GenerateNIF from "../components/Geradores/Tipos/GenerateNIF";
|
||||
|
||||
export default function Home() {
|
||||
const location = useLocation(); // Pegando a rota atual
|
||||
|
||||
// Efeito para rolar até a seção correspondente ao path
|
||||
useEffect(() => {
|
||||
const featuresSection = document.getElementById(location.hash.substring(1));
|
||||
if (featuresSection) {
|
||||
featuresSection.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<div >
|
||||
<div >
|
||||
{/* Seção de Funcionalidades */}
|
||||
<section id="features" className="py-16 px-4 md:px-8">
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div id="nif">
|
||||
<GenerateNIF />
|
||||
</div>
|
||||
<div id="niss">
|
||||
<GenerateNISS />
|
||||
</div>
|
||||
<div id="cc">
|
||||
<GenerateCC />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
geradoresfe/src/pages/HomePage.tsx
Normal file
40
geradoresfe/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { GeneratorCard } from "../components/GeneratorCard";
|
||||
import { Validator } from "../components/Validator";
|
||||
import { LoadState } from "../components/LoadState";
|
||||
import { useGenerators } from "../lib/useGenerators";
|
||||
import { useTitle } from "../lib/useTitle";
|
||||
import { slugify } from "../lib/slug";
|
||||
|
||||
export default function HomePage() {
|
||||
useTitle();
|
||||
const { byCategory } = useGenerators();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<section className="mx-auto max-w-3xl text-center">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-gray-900 sm:text-5xl dark:text-white">
|
||||
Dados portugueses <span className="text-blue-600">válidos</span> para testes
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-gray-600 dark:text-gray-400">
|
||||
Gere e valide NIF, NISS, Cartão de Cidadão, IBAN, cartões, matrículas e muito mais — um a um ou aos milhares.
|
||||
</p>
|
||||
<div className="mt-8 text-left">
|
||||
<Validator />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LoadState>
|
||||
{byCategory.map(([category, generators]) => (
|
||||
<section key={category} id={slugify(category)} className="mt-16 scroll-mt-24">
|
||||
<h2 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{category}</h2>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{generators.map((g) => (
|
||||
<GeneratorCard key={g.id} generator={g} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</LoadState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import GenerateNIF from "../components/Geradores/Tipos/GenerateNIF";
|
||||
|
||||
export default function NIF() {
|
||||
const location = useLocation();
|
||||
|
||||
// Efeito para rolar até a seção correspondente ao path
|
||||
useEffect(() => {
|
||||
const featuresSection = document.getElementById(location.hash.substring(1));
|
||||
if (featuresSection) {
|
||||
featuresSection.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<div >
|
||||
<div >
|
||||
{/* Seção de Funcionalidades */}
|
||||
<section id="features" className="py-16 px-4 md:px-8">
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-1">
|
||||
<div id="nif">
|
||||
<GenerateNIF />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import GenerateNISS from "../components/Geradores/Tipos/GenerateNISS";
|
||||
|
||||
export default function NISS() {
|
||||
const location = useLocation();
|
||||
|
||||
// Efeito para rolar até a seção correspondente ao path
|
||||
useEffect(() => {
|
||||
const featuresSection = document.getElementById(location.hash.substring(1));
|
||||
if (featuresSection) {
|
||||
featuresSection.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<div >
|
||||
<div >
|
||||
{/* Seção de Funcionalidades */}
|
||||
<section id="features" className="py-16 px-4 md:px-8">
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-1">
|
||||
<div id="nif">
|
||||
<GenerateNISS />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
geradoresfe/src/pages/NotFoundPage.tsx
Normal file
16
geradoresfe/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Link } from "react-router";
|
||||
import { useTitle } from "../lib/useTitle";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
useTitle("Página não encontrada");
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center px-4 py-24 text-center">
|
||||
<p className="text-6xl font-extrabold text-blue-600">404</p>
|
||||
<h1 className="mt-4 text-2xl font-bold text-gray-900 dark:text-white">Página não encontrada</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">O endereço que procura não existe.</p>
|
||||
<Link to="/" className="mt-6 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Voltar ao início
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
geradoresfe/src/pages/ValidatorPage.tsx
Normal file
24
geradoresfe/src/pages/ValidatorPage.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Validator } from "../components/Validator";
|
||||
import { useGenerators } from "../lib/useGenerators";
|
||||
import { useTitle } from "../lib/useTitle";
|
||||
|
||||
export default function ValidatorPage() {
|
||||
useTitle("Validador");
|
||||
const { generators } = useGenerators();
|
||||
const supported = generators.filter((g) => g.canValidate).map((g) => g.name);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
|
||||
<h1 className="text-3xl font-extrabold text-gray-900 dark:text-white">Validador</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Cole qualquer valor: identificamos o tipo automaticamente e explicamos se é válido — e, se não for, porquê.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Validator autoFocus />
|
||||
</div>
|
||||
{supported.length > 0 && (
|
||||
<p className="mt-8 text-sm text-gray-500 dark:text-gray-400">Tipos suportados: {supported.join(", ")}.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* ---------------------------------------------------------------
|
||||
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
|
||||
* ## ##
|
||||
* ## AUTHOR: acacode ##
|
||||
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
|
||||
* ---------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export enum NIFType {
|
||||
/** Pessoa singular (1) */
|
||||
PessoaSingular1 = "Pessoa singular (1)",
|
||||
/** Pessoa singular (2) */
|
||||
PessoaSingular2 = "Pessoa singular (2)",
|
||||
/** Pessoa singular (3; novo em 2019) */
|
||||
PessoaSingular3 = "Pessoa singular (3; novo em 2019)",
|
||||
/** Pessoa colectiva (5) */
|
||||
PessoaColectiva = "Pessoa colectiva (5)",
|
||||
/** Pessoa colectiva pública (6) */
|
||||
PessoaColectivaPublica = "Pessoa colectiva pública (6)",
|
||||
/** Empresário em nome individual (8) */
|
||||
EmpresarioIndividual = "Empresário em nome individual (8)",
|
||||
/** Pessoa colectiva irregular ou número provisório (9) */
|
||||
PessoaColectivaIrregular = "Pessoa colectiva irregular ou número provisório (9)",
|
||||
}
|
||||
|
||||
export interface WeatherForecast {
|
||||
/** @format date */
|
||||
date?: string;
|
||||
/** @format int32 */
|
||||
temperatureC?: number;
|
||||
/** @format int32 */
|
||||
temperatureF?: number;
|
||||
summary?: string | null;
|
||||
}
|
||||
|
||||
import type {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
HeadersDefaults,
|
||||
ResponseType,
|
||||
} from "axios";
|
||||
import axios from "axios";
|
||||
|
||||
export type QueryParamsType = Record<string | number, any>;
|
||||
|
||||
export interface FullRequestParams
|
||||
extends Omit<AxiosRequestConfig, "data" | "params" | "url" | "responseType"> {
|
||||
/** set parameter to `true` for call `securityWorker` for this request */
|
||||
secure?: boolean;
|
||||
/** request path */
|
||||
path: string;
|
||||
/** content type of request body */
|
||||
type?: ContentType;
|
||||
/** query params */
|
||||
query?: QueryParamsType;
|
||||
/** format of response (i.e. response.json() -> format: "json") */
|
||||
format?: ResponseType;
|
||||
/** request body */
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export type RequestParams = Omit<
|
||||
FullRequestParams,
|
||||
"body" | "method" | "query" | "path"
|
||||
>;
|
||||
|
||||
export interface ApiConfig<SecurityDataType = unknown>
|
||||
extends Omit<AxiosRequestConfig, "data" | "cancelToken"> {
|
||||
securityWorker?: (
|
||||
securityData: SecurityDataType | null,
|
||||
) => Promise<AxiosRequestConfig | void> | AxiosRequestConfig | void;
|
||||
secure?: boolean;
|
||||
format?: ResponseType;
|
||||
}
|
||||
|
||||
export enum ContentType {
|
||||
Json = "application/json",
|
||||
JsonApi = "application/vnd.api+json",
|
||||
FormData = "multipart/form-data",
|
||||
UrlEncoded = "application/x-www-form-urlencoded",
|
||||
Text = "text/plain",
|
||||
}
|
||||
|
||||
export class HttpClient<SecurityDataType = unknown> {
|
||||
public instance: AxiosInstance;
|
||||
private securityData: SecurityDataType | null = null;
|
||||
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
|
||||
private secure?: boolean;
|
||||
private format?: ResponseType;
|
||||
|
||||
constructor({
|
||||
securityWorker,
|
||||
secure,
|
||||
format,
|
||||
...axiosConfig
|
||||
}: ApiConfig<SecurityDataType> = {}) {
|
||||
this.instance = axios.create({
|
||||
...axiosConfig,
|
||||
baseURL: axiosConfig.baseURL || "",
|
||||
});
|
||||
this.secure = secure;
|
||||
this.format = format;
|
||||
this.securityWorker = securityWorker;
|
||||
}
|
||||
|
||||
public setSecurityData = (data: SecurityDataType | null) => {
|
||||
this.securityData = data;
|
||||
};
|
||||
|
||||
protected mergeRequestParams(
|
||||
params1: AxiosRequestConfig,
|
||||
params2?: AxiosRequestConfig,
|
||||
): AxiosRequestConfig {
|
||||
const method = params1.method || (params2 && params2.method);
|
||||
|
||||
return {
|
||||
...this.instance.defaults,
|
||||
...params1,
|
||||
...(params2 || {}),
|
||||
headers: {
|
||||
...((method &&
|
||||
this.instance.defaults.headers[
|
||||
method.toLowerCase() as keyof HeadersDefaults
|
||||
]) ||
|
||||
{}),
|
||||
...(params1.headers || {}),
|
||||
...((params2 && params2.headers) || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected stringifyFormItem(formItem: unknown) {
|
||||
if (typeof formItem === "object" && formItem !== null) {
|
||||
return JSON.stringify(formItem);
|
||||
} else {
|
||||
return `${formItem}`;
|
||||
}
|
||||
}
|
||||
|
||||
protected createFormData(input: Record<string, unknown>): FormData {
|
||||
if (input instanceof FormData) {
|
||||
return input;
|
||||
}
|
||||
return Object.keys(input || {}).reduce((formData, key) => {
|
||||
const property = input[key];
|
||||
const propertyContent: any[] =
|
||||
property instanceof Array ? property : [property];
|
||||
|
||||
for (const formItem of propertyContent) {
|
||||
const isFileType = formItem instanceof Blob || formItem instanceof File;
|
||||
formData.append(
|
||||
key,
|
||||
isFileType ? formItem : this.stringifyFormItem(formItem),
|
||||
);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}, new FormData());
|
||||
}
|
||||
|
||||
public request = async <T = any, _E = any>({
|
||||
secure,
|
||||
path,
|
||||
type,
|
||||
query,
|
||||
format,
|
||||
body,
|
||||
...params
|
||||
}: FullRequestParams): Promise<AxiosResponse<T>> => {
|
||||
const secureParams =
|
||||
((typeof secure === "boolean" ? secure : this.secure) &&
|
||||
this.securityWorker &&
|
||||
(await this.securityWorker(this.securityData))) ||
|
||||
{};
|
||||
const requestParams = this.mergeRequestParams(params, secureParams);
|
||||
const responseFormat = format || this.format || undefined;
|
||||
|
||||
if (
|
||||
type === ContentType.FormData &&
|
||||
body &&
|
||||
body !== null &&
|
||||
typeof body === "object"
|
||||
) {
|
||||
body = this.createFormData(body as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (
|
||||
type === ContentType.Text &&
|
||||
body &&
|
||||
body !== null &&
|
||||
typeof body !== "string"
|
||||
) {
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
return this.instance.request({
|
||||
...requestParams,
|
||||
headers: {
|
||||
...(requestParams.headers || {}),
|
||||
...(type ? { "Content-Type": type } : {}),
|
||||
},
|
||||
params: query,
|
||||
responseType: responseFormat,
|
||||
data: body,
|
||||
url: path,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @title FactoryId
|
||||
* @version v1
|
||||
* @contact Marco Santos
|
||||
*/
|
||||
export class Api<
|
||||
SecurityDataType extends unknown,
|
||||
> extends HttpClient<SecurityDataType> {
|
||||
generate = {
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags Generate
|
||||
* @name GenerateNifList
|
||||
* @request GET:/Generate/GenerateNIF
|
||||
*/
|
||||
generateNifList: (
|
||||
query?: {
|
||||
type?: NIFType;
|
||||
},
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<string, any>({
|
||||
path: `/Generate/GenerateNIF`,
|
||||
method: "GET",
|
||||
query: query,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags Generate
|
||||
* @name ValidateNifList
|
||||
* @request GET:/Generate/ValidateNIF
|
||||
*/
|
||||
validateNifList: (
|
||||
query?: {
|
||||
nif?: string;
|
||||
},
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<boolean, any>({
|
||||
path: `/Generate/ValidateNIF`,
|
||||
method: "GET",
|
||||
query: query,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags Generate
|
||||
* @name GenerateNissList
|
||||
* @request GET:/Generate/GenerateNISS
|
||||
*/
|
||||
generateNissList: (params: RequestParams = {}) =>
|
||||
this.request<string, any>({
|
||||
path: `/Generate/GenerateNISS`,
|
||||
method: "GET",
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags Generate
|
||||
* @name ValidateNissList
|
||||
* @request GET:/Generate/ValidateNISS
|
||||
*/
|
||||
validateNissList: (
|
||||
query?: {
|
||||
nif?: string;
|
||||
},
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<boolean, any>({
|
||||
path: `/Generate/ValidateNISS`,
|
||||
method: "GET",
|
||||
query: query,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags Generate
|
||||
* @name GenerateCcList
|
||||
* @request GET:/Generate/GenerateCC
|
||||
*/
|
||||
generateCcList: (params: RequestParams = {}) =>
|
||||
this.request<string, any>({
|
||||
path: `/Generate/GenerateCC`,
|
||||
method: "GET",
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
};
|
||||
weatherForecast = {
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags WeatherForecast
|
||||
* @name GetWeatherForecast
|
||||
* @request GET:/WeatherForecast
|
||||
*/
|
||||
getWeatherForecast: (params: RequestParams = {}) =>
|
||||
this.request<WeatherForecast[], any>({
|
||||
path: `/WeatherForecast`,
|
||||
method: "GET",
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
const defaultTheme = require("tailwindcss/defaultTheme");
|
||||
// @type {import('tailwindcss').Config}
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,jsx,ts,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
fontFamily: {
|
||||
outfit: ["Outfit", "sans-serif"],
|
||||
},
|
||||
screens: {
|
||||
"2xsm": "375px",
|
||||
xsm: "425px",
|
||||
"3xl": "2000px",
|
||||
...defaultTheme.screens,
|
||||
},
|
||||
extend: {
|
||||
fontSize: {
|
||||
"title-2xl": ["72px", "90px"],
|
||||
"title-xl": ["60px", "72px"],
|
||||
"title-lg": ["48px", "60px"],
|
||||
"title-md": ["36px", "44px"],
|
||||
"title-sm": ["30px", "38px"],
|
||||
"theme-xl": ["20px", "30px"],
|
||||
"theme-sm": ["14px", "20px"],
|
||||
"theme-xs": ["12px", "18px"],
|
||||
},
|
||||
colors: {
|
||||
primary: "#2175d9",
|
||||
"primary-dark": "#1c5fb3",
|
||||
current: "currentColor",
|
||||
transparent: "transparent",
|
||||
white: "#FFFFFF",
|
||||
black: "#101828",
|
||||
brand: {
|
||||
25: "#F2F7FF",
|
||||
50: "#ECF3FF",
|
||||
100: "#DDE9FF",
|
||||
200: "#C2D6FF",
|
||||
300: "#9CB9FF",
|
||||
400: "#7592FF",
|
||||
500: "#465FFF",
|
||||
600: "#3641F5",
|
||||
700: "#2A31D8",
|
||||
800: "#252DAE",
|
||||
900: "#262E89",
|
||||
950: "#161950",
|
||||
},
|
||||
"blue-light": {
|
||||
25: "#F5FBFF",
|
||||
50: "#F0F9FF",
|
||||
100: "#E0F2FE",
|
||||
200: "#B9E6FE",
|
||||
300: "#7CD4FD",
|
||||
400: "#36BFFA",
|
||||
500: "#0BA5EC",
|
||||
600: "#0086C9",
|
||||
700: "#026AA2",
|
||||
800: "#065986",
|
||||
900: "#0B4A6F",
|
||||
950: "#062C41",
|
||||
},
|
||||
gray: {
|
||||
dark: "#1A2231",
|
||||
25: "#FCFCFD",
|
||||
50: "#F9FAFB",
|
||||
100: "#F2F4F7",
|
||||
200: "#E4E7EC",
|
||||
300: "#D0D5DD",
|
||||
400: "#98A2B3",
|
||||
500: "#667085",
|
||||
600: "#475467",
|
||||
700: "#344054",
|
||||
800: "#1D2939",
|
||||
900: "#101828",
|
||||
950: "#0C111D",
|
||||
},
|
||||
orange: {
|
||||
25: "#FFFAF5",
|
||||
50: "#FFF6ED",
|
||||
100: "#FFEAD5",
|
||||
200: "#FDDCAB",
|
||||
300: "#FEB273",
|
||||
400: "#FD853A",
|
||||
500: "#FB6514",
|
||||
600: "#EC4A0A",
|
||||
700: "#C4320A",
|
||||
800: "#9C2A10",
|
||||
900: "#7E2410",
|
||||
950: "#511C10",
|
||||
},
|
||||
success: {
|
||||
25: "#F6FEF9",
|
||||
50: "#ECFDF3",
|
||||
100: "#D1FADF",
|
||||
200: "#A6F4C5",
|
||||
300: "#6CE9A6",
|
||||
400: "#32D583",
|
||||
500: "#12B76A",
|
||||
600: "#039855",
|
||||
700: "#027A48",
|
||||
800: "#05603A",
|
||||
900: "#054F31",
|
||||
950: "#053321",
|
||||
},
|
||||
error: {
|
||||
25: "#FFFBFA",
|
||||
50: "#FEF3F2",
|
||||
100: "#FEE4E2",
|
||||
200: "#FECDCA",
|
||||
300: "#FDA29B",
|
||||
400: "#F97066",
|
||||
500: "#F04438",
|
||||
600: "#D92D20",
|
||||
700: "#B42318",
|
||||
800: "#912018",
|
||||
900: "#7A271A",
|
||||
950: "#55160C",
|
||||
},
|
||||
warning: {
|
||||
25: "#FFFCF5",
|
||||
50: "#FFFAEB",
|
||||
100: "#FEF0C7",
|
||||
200: "#FEDF89",
|
||||
300: "#FEC84B",
|
||||
400: "#FDB022",
|
||||
500: "#F79009",
|
||||
600: "#DC6803",
|
||||
700: "#B54708",
|
||||
800: "#93370D",
|
||||
900: "#7A2E0E",
|
||||
950: "#4E1D09",
|
||||
},
|
||||
"theme-pink": {
|
||||
500: "#EE46BC",
|
||||
},
|
||||
"theme-purple": {
|
||||
500: "#7A5AF8",
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
"theme-md":
|
||||
"0px 4px 8px -2px rgba(16, 24, 40, 0.10), 0px 2px 4px -2px rgba(16, 24, 40, 0.06)",
|
||||
"theme-lg":
|
||||
"0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)",
|
||||
|
||||
"theme-sm":
|
||||
"0px 1px 3px 0px rgba(16, 24, 40, 0.10), 0px 1px 2px 0px rgba(16, 24, 40, 0.06)",
|
||||
"theme-xs": "0px 1px 2px 0px rgba(16, 24, 40, 0.05)",
|
||||
"theme-xl":
|
||||
"0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px rgba(16, 24, 40, 0.03)",
|
||||
datepicker: "-5px 0 0 #262d3c, 5px 0 0 #262d3c",
|
||||
"focus-ring": "0px 0px 0px 4px rgba(70, 95, 255, 0.12)",
|
||||
"slider-navigation":
|
||||
"0px 1px 2px 0px rgba(16, 24, 40, 0.10), 0px 1px 3px 0px rgba(16, 24, 40, 0.10)",
|
||||
tooltip:
|
||||
"0px 4px 6px -2px rgba(16, 24, 40, 0.05), -8px 0px 20px 8px rgba(16, 24, 40, 0.05)",
|
||||
},
|
||||
dropShadow: {
|
||||
"4xl": [
|
||||
"0 35px 35px rgba(0, 0, 0, 0.25)",
|
||||
"0 45px 65px rgba(0, 0, 0, 0.15)",
|
||||
],
|
||||
},
|
||||
zIndex: {
|
||||
999999: "999999",
|
||||
99999: "99999",
|
||||
9999: "9999",
|
||||
999: "999",
|
||||
99: "99",
|
||||
9: "9",
|
||||
1: "1",
|
||||
},
|
||||
spacing: {
|
||||
4.5: "1.125rem",
|
||||
5.5: "1.375rem",
|
||||
6.5: "1.625rem",
|
||||
7.5: "1.875rem",
|
||||
8.5: "2.125rem",
|
||||
9.5: "2.375rem",
|
||||
10.5: "2.625rem",
|
||||
11.5: "2.875rem",
|
||||
12.5: "3.125rem",
|
||||
13: "3.25rem",
|
||||
13.5: "3.375rem",
|
||||
14.5: "3.625rem",
|
||||
15: "3.75rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/forms"),
|
||||
require('tailwindcss-animate')
|
||||
],
|
||||
};
|
||||
@@ -1,11 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// Em dev, /api e /docs vão para o backend (dotnet run → http://localhost:5053).
|
||||
const apiTarget = process.env.API_PROXY_TARGET ?? "http://localhost:5053";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/api": apiTarget,
|
||||
"/docs": apiTarget,
|
||||
"/openapi": apiTarget,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user