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:
2026-10-11 23:51:39 +01:00
parent 0f764b1239
commit d7ea8a9b9e
119 changed files with 7764 additions and 2502 deletions

View File

@@ -1 +1,2 @@
node_modules
node_modules
dist

View File

@@ -1 +0,0 @@
VITE_API_URL=https://localhost:7266/

View File

@@ -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;"]

View File

@@ -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...
},
},
])
```

View File

@@ -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&amp;&amp;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>

View File

@@ -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>

View File

@@ -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;
}

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: [
require('@tailwindcss/postcss'),
require('autoprefixer'),
],
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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}</>;
}

View File

@@ -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" />
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>;
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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;
};
}

View File

@@ -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;

View File

@@ -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 };
};

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
};
}

View 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`;

View 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.");
}
}

View 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");
});
});

View 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);
}

View File

@@ -0,0 +1,8 @@
export function slugify(text: string) {
return text
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}

View 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;
}

View 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]);
}

View File

@@ -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;

View File

@@ -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>,
)
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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,
}),
};
}

View File

@@ -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')
],
};

View File

@@ -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",
},
});