Merge pull request 'Reescrita dos geradores: biblioteca IGenerator, minimal API e novo frontend' (#1) from feature/boost into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-10-11 23:55:52 +01:00
122 changed files with 7974 additions and 2494 deletions

View File

@@ -1,4 +1,12 @@
node_modules
Dockerfile
.git
.gitignore
.vs
.vscode
**/bin
**/obj
**/node_modules
**/dist
geradoresfe
UnitTest
Dockerfile
docker-compose.yml

1
.gitignore vendored
View File

@@ -361,5 +361,4 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/geradoresfe/package-lock.json
/geradoresfe/public

2
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/GeradoresWS/bin/Debug/net7.0/GeradoresWS.dll",
"program": "${workspaceFolder}/GeradoresWS/bin/Debug/net10.0/GeradoresWS.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,

View File

@@ -1,31 +1,22 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
# API (GeradoresWS). Contexto de build: raiz do repositório.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["GeradoresWS/GeradoresWS.csproj", "GeradoresWS/"]
COPY ["GeradoresService/GeradoresService.csproj", "GeradoresService/"]
RUN dotnet restore "GeradoresWS/GeradoresWS.csproj"
COPY . .
WORKDIR "/src/GeradoresWS"
RUN dotnet build "GeradoresWS.csproj" -c Release -o /app/build
COPY GeradoresService/ GeradoresService/
COPY GeradoresWS/ GeradoresWS/
RUN dotnet publish "GeradoresWS/GeradoresWS.csproj" -c Release -o /app/publish --no-restore /p:UseAppHost=false
FROM build AS publish
RUN dotnet publish "GeradoresWS.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=publish /app/publish .
COPY --from=build /app/publish .
# Set environment variables
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_HTTP_PORTS=8080 \
ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "GeradoresWS.dll"]

View File

@@ -1,81 +0,0 @@
using System;
namespace GeradoresService
{
public static class CartaoCidadao
{
// Função estática para gerar o número de documento
public static string Generate()
{
Random rand = new Random();
// Gerar o Número de Identificação Civil (8 dígitos)
string numeroIdentificacaoCivil = rand.Next(0, 100000000).ToString("D8");
// Gerar o Check Digit C
int checkDigitC = CalcularCheckDigit(numeroIdentificacaoCivil);
// Gerar a Versão (duas letras ou números aleatórios entre A-Z ou 0-9)
string versao = GerarVersao();
// Gerar o Check Digit T (Número aleatório entre 0-9)
int checkDigitT = rand.Next(0, 10); // T: Número aleatório por enquanto, conforme exemplo.
// Montar o número completo do documento com o formato desejado
string numeroDocumento = $"{numeroIdentificacaoCivil} {checkDigitC} {versao}{checkDigitT}";
return numeroDocumento;
}
// Função para calcular o Check Digit C com base no Número de Identificação Civil
private static int CalcularCheckDigit(string numeroIdentificacaoCivil)
{
int soma = 0;
bool segundoElemento = false;
// Percorrer o número da direita para a esquerda
for (int i = numeroIdentificacaoCivil.Length - 1; i >= 0; i--)
{
int valor = numeroIdentificacaoCivil[i] - '0'; // Converte o char para inteiro
// Multiplicar cada segundo número por 2
if (segundoElemento)
{
valor *= 2;
if (valor >= 10) valor -= 9; // Subtrair 9 se o valor for maior ou igual a 10
}
soma += valor;
segundoElemento = !segundoElemento;
}
// Retornar o check digit (resto da soma por 10)
return soma % 10;
}
// Função para gerar a versão (letras ou números)
private static string GerarVersao()
{
Random rand = new Random();
string versao = "";
for (int i = 0; i < 2; i++) // Gerar duas letras/números
{
int numeroAleatorio = rand.Next(0, 36); // 26 letras + 10 números
if (numeroAleatorio < 26)
{
// Gerar uma letra de A a Z
versao += (char)('A' + numeroAleatorio);
}
else
{
// Gerar um número de 0 a 9
versao += (char)('0' + (numeroAleatorio - 26));
}
}
return versao;
}
}
}

View File

@@ -0,0 +1,71 @@
using System.Numerics;
namespace GeradoresService.Core;
public static class Checksums
{
/// <summary>
/// Dígito de controlo módulo 11 com pesos decrescentes (n+1 .. 2), usado no NIF e no NIC do CC.
/// Resto 0 ou 1 dá dígito 0.
/// </summary>
public static int Mod11(ReadOnlySpan<char> digits)
{
var sum = 0;
for (var i = 0; i < digits.Length; i++)
sum += (digits[i] - '0') * (digits.Length + 1 - i);
var remainder = sum % 11;
return remainder < 2 ? 0 : 11 - remainder;
}
/// <summary>
/// Soma Luhn a partir da direita, duplicando posições alternadas.
/// Aceita letras (A=10 .. Z=35), como no número de documento do Cartão de Cidadão.
/// </summary>
public static int LuhnSum(ReadOnlySpan<char> value)
{
var sum = 0;
var doubleIt = false;
for (var i = value.Length - 1; i >= 0; i--)
{
var v = CharValue(value[i]);
if (doubleIt)
{
v *= 2;
if (v > 9) v -= 9;
}
sum += v;
doubleIt = !doubleIt;
}
return sum;
}
public static bool LuhnValid(ReadOnlySpan<char> value) => LuhnSum(value) % 10 == 0;
/// <summary>Calcula o dígito Luhn a acrescentar a <paramref name="payload"/>.</summary>
public static int LuhnCheckDigit(string payload)
{
var sum = LuhnSum(payload + "0");
return (10 - sum % 10) % 10;
}
/// <summary>ISO 7064 MOD 97-10 sobre uma string de dígitos.</summary>
public static int Mod97(string digits) => (int)(BigInteger.Parse(digits) % 97);
/// <summary>Converte letras em números (A=10 .. Z=35) para cálculo de IBAN.</summary>
public static string LettersToDigits(string value) =>
string.Concat(value.Select(c => char.IsLetter(c) ? (char.ToUpperInvariant(c) - 'A' + 10).ToString() : c.ToString()));
private static int CharValue(char c) => c switch
{
>= '0' and <= '9' => c - '0',
>= 'A' and <= 'Z' => c - 'A' + 10,
>= 'a' and <= 'z' => c - 'a' + 10,
_ => throw new ArgumentException($"Carácter inválido '{c}'."),
};
/// <summary>Remove espaços, hífens, pontos e barras.</summary>
public static string Clean(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c) && c is not '-' and not '.' and not '/').ToArray());
public static bool AllDigits(string value) => value.Length > 0 && value.All(char.IsAsciiDigit);
}

View File

@@ -0,0 +1,18 @@
namespace GeradoresService.Core;
public abstract class GeneratorBase : IGenerator
{
public abstract string Id { get; }
public abstract string Name { get; }
public abstract string Category { get; }
public abstract string Description { get; }
public virtual IReadOnlyList<GeneratorOption> Options => [];
public virtual bool HasChecksum => true;
public virtual bool CanValidate => true;
public abstract GeneratedItem Generate(GeneratorContext context);
public abstract ValidationResult Validate(string value);
protected static GeneratorOption Option(string key, string label, string defaultValue, params (string Value, string Label)[] choices) =>
new(key, label, choices.Select(c => new OptionChoice(c.Value, c.Label)).ToArray(), defaultValue);
}

View File

@@ -0,0 +1,39 @@
namespace GeradoresService.Core;
/// <summary>
/// Um gerador/validador de um tipo de identificador.
/// Os metadados (Id, Name, Options, ...) são expostos pela API para o frontend construir a UI.
/// </summary>
public interface IGenerator
{
/// <summary>Identificador estável usado nos URLs (ex.: "nif").</summary>
string Id { get; }
string Name { get; }
string Category { get; }
string Description { get; }
IReadOnlyList<GeneratorOption> Options { get; }
/// <summary>
/// Indica se a validação verifica um dígito de controlo (true) ou apenas o formato (false).
/// </summary>
bool HasChecksum { get; }
/// <summary>False para geradores compostos que não têm validação (ex.: pessoa fictícia).</summary>
bool CanValidate { get; }
GeneratedItem Generate(GeneratorContext context);
ValidationResult Validate(string value);
}
public static class Categories
{
public const string Identificacao = "Identificação";
public const string Financeiro = "Financeiro";
public const string Contactos = "Contactos e Morada";
public const string Outros = "Outros";
}

View File

@@ -0,0 +1,72 @@
namespace GeradoresService.Core;
public sealed record GeneratorOption(
string Key,
string Label,
IReadOnlyList<OptionChoice> Choices,
string DefaultValue);
public sealed record OptionChoice(string Value, string Label);
/// <summary>
/// Valor gerado. <see cref="Fields"/> é usado por geradores compostos (ex.: pessoa fictícia).
/// </summary>
public sealed record GeneratedItem(string Value, IReadOnlyDictionary<string, string>? Fields = null);
/// <param name="IsValid">O valor é válido.</param>
/// <param name="FormatMatch">O valor tem o formato deste tipo (mesmo que o dígito de controlo falhe).</param>
/// <param name="Message">Resumo legível.</param>
/// <param name="Normalized">Valor normalizado/formatado, quando válido.</param>
/// <param name="Details">Passos do cálculo / informação extra.</param>
public sealed record ValidationResult(
bool IsValid,
bool FormatMatch,
string Message,
string? Normalized = null,
IReadOnlyList<string>? Details = null)
{
public static ValidationResult Valid(string normalized, string message, IReadOnlyList<string>? details = null) =>
new(true, true, message, normalized, details);
public static ValidationResult WrongFormat(string message) =>
new(false, false, message);
public static ValidationResult BadChecksum(string message, IReadOnlyList<string>? details = null) =>
new(false, true, message, null, details);
}
/// <summary>
/// Contexto de geração: fonte de aleatoriedade e opções escolhidas pelo utilizador.
/// </summary>
public sealed class GeneratorContext(Random random, IReadOnlyDictionary<string, string?> options)
{
public Random Random { get; } = random;
public static GeneratorContext Default(Random? random = null) =>
new(random ?? Random.Shared, new Dictionary<string, string?>());
/// <summary>
/// Devolve o valor da opção ou o default. O valor tem de ser uma das escolhas válidas.
/// </summary>
public string Option(GeneratorOption option)
{
if (options.TryGetValue(option.Key, out var value) && !string.IsNullOrWhiteSpace(value))
{
var match = option.Choices.FirstOrDefault(c => string.Equals(c.Value, value, StringComparison.OrdinalIgnoreCase));
if (match is null)
throw new ArgumentException($"Valor '{value}' inválido para a opção '{option.Key}'.");
return match.Value;
}
return option.DefaultValue;
}
public string Digits(int length)
{
Span<char> buffer = stackalloc char[length];
for (var i = 0; i < length; i++)
buffer[i] = (char)('0' + Random.Next(10));
return new string(buffer);
}
public T Pick<T>(IReadOnlyList<T> items) => items[Random.Next(items.Count)];
}

View File

@@ -1,13 +0,0 @@
using System;
using System.Collections.Generic;
namespace GeradoresService.DAL;
public partial class Geradore
{
public int Id { get; set; }
public int? Tipo { get; set; }
public string? Valor { get; set; }
}

View File

@@ -1,34 +0,0 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
namespace GeradoresService.DAL;
public partial class GeradoresContext : DbContext
{
public GeradoresContext()
{
}
public GeradoresContext(DbContextOptions<GeradoresContext> options)
: base(options)
{
}
public virtual DbSet<Geradore> Geradores { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Geradore>(entity =>
{
entity.HasKey(e => e.Id).HasName("PK__Geradore__3214EC2710D5AD88");
entity.Property(e => e.Id).HasColumnName("ID");
entity.Property(e => e.Valor).HasMaxLength(50);
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,66 @@
namespace GeradoresService.Data;
/// <summary>Dados fictícios portugueses usados pelos geradores de pessoas, moradas e emails.</summary>
public static class PtData
{
public static readonly string[] MaleNames =
[
"João", "Francisco", "Santiago", "Afonso", "Tomás", "Duarte", "Rodrigo", "Martim", "Lourenço", "Gabriel",
"Miguel", "Pedro", "Tiago", "Diogo", "Rafael", "Gonçalo", "Guilherme", "Salvador", "Vicente", "Simão",
"António", "José", "Manuel", "Rui", "Paulo", "Ricardo", "Nuno", "Bruno", "André", "Luís",
];
public static readonly string[] FemaleNames =
[
"Maria", "Leonor", "Matilde", "Beatriz", "Carolina", "Mariana", "Ana", "Inês", "Sofia", "Margarida",
"Alice", "Benedita", "Francisca", "Constança", "Laura", "Clara", "Lara", "Joana", "Rita", "Catarina",
"Marta", "Sara", "Daniela", "Teresa", "Helena", "Isabel", "Patrícia", "Filipa", "Raquel", "Madalena",
];
public static readonly string[] Surnames =
[
"Silva", "Santos", "Ferreira", "Pereira", "Oliveira", "Costa", "Rodrigues", "Martins", "Jesus", "Sousa",
"Fernandes", "Gonçalves", "Gomes", "Lopes", "Marques", "Alves", "Almeida", "Ribeiro", "Pinto", "Carvalho",
"Teixeira", "Moreira", "Correia", "Mendes", "Nunes", "Soares", "Vieira", "Monteiro", "Cardoso", "Rocha",
"Raposo", "Neves", "Coelho", "Cruz", "Cunha", "Pires", "Ramos", "Reis", "Simões", "Antunes",
];
public static readonly string[] StreetTypes = ["Rua", "Avenida", "Travessa", "Largo", "Praceta", "Alameda", "Calçada"];
public static readonly string[] StreetNames =
[
"da Liberdade", "de Camões", "25 de Abril", "da República", "do Comércio", "das Flores", "dos Combatentes",
"Dom Afonso Henriques", "Almirante Reis", "da Boavista", "de Santa Catarina", "do Carmo", "da Estação",
"Gago Coutinho", "Infante D. Henrique", "Marquês de Pombal", "Miguel Bombarda", "da Igreja", "do Mercado",
"Bartolomeu Dias", "Vasco da Gama", "Fernando Pessoa", "Sá da Bandeira", "Cidade de Lyon", "dos Bombeiros",
];
/// <summary>Código postal (4 dígitos) e localidade de cada capital de distrito/região.</summary>
public static readonly (string Cp4, string Locality, string District)[] Localities =
[
("1000", "Lisboa", "Lisboa"),
("4000", "Porto", "Porto"),
("3000", "Coimbra", "Coimbra"),
("4700", "Braga", "Braga"),
("8000", "Faro", "Faro"),
("2400", "Leiria", "Leiria"),
("3800", "Aveiro", "Aveiro"),
("7000", "Évora", "Évora"),
("2900", "Setúbal", "Setúbal"),
("2000", "Santarém", "Santarém"),
("3500", "Viseu", "Viseu"),
("5000", "Vila Real", "Vila Real"),
("4900", "Viana do Castelo", "Viana do Castelo"),
("5300", "Bragança", "Bragança"),
("6300", "Guarda", "Guarda"),
("6000", "Castelo Branco", "Castelo Branco"),
("7300", "Portalegre", "Portalegre"),
("7800", "Beja", "Beja"),
("9000", "Funchal", "Madeira"),
("9500", "Ponta Delgada", "Açores"),
];
public static readonly string[] SafeEmailDomains = ["example.com", "example.org", "example.net"];
public static readonly string[] RealisticEmailDomains = ["gmail.com", "sapo.pt", "outlook.pt", "hotmail.com", "yahoo.com", "iol.pt"];
}

View File

@@ -0,0 +1,85 @@
using GeradoresService.Core;
using GeradoresService.Generators.Contactos;
using GeradoresService.Generators.Financeiro;
using GeradoresService.Generators.Identificacao;
using GeradoresService.Generators.Outros;
namespace GeradoresService;
public sealed record DetectionMatch(IGenerator Generator, ValidationResult Result);
public sealed class GeneratorRegistry
{
public const int MaxBatch = 1000;
public IReadOnlyList<IGenerator> All { get; }
public GeneratorRegistry() : this(
[
new NifGenerator(),
new NipcGenerator(),
new NissGenerator(),
new CartaoCidadaoGenerator(),
new PassaporteGenerator(),
new IbanGenerator(),
new NibGenerator(),
new CartaoCreditoGenerator(),
new ReferenciaMultibancoGenerator(),
new PessoaGenerator(),
new TelefoneGenerator(),
new CodigoPostalGenerator(),
new EmailGenerator(),
new MatriculaGenerator(),
new EanGenerator(),
new UuidGenerator(),
])
{ }
public GeneratorRegistry(IReadOnlyList<IGenerator> generators) => All = generators;
public IGenerator? Find(string id) =>
All.FirstOrDefault(g => string.Equals(g.Id, id, StringComparison.OrdinalIgnoreCase));
/// <summary>Gera <paramref name="count"/> valores. Um <paramref name="seed"/> torna o resultado reprodutível.</summary>
public IReadOnlyList<GeneratedItem> Generate(IGenerator generator, int count, IReadOnlyDictionary<string, string?> options, int? seed = null)
{
if (count is < 1 or > MaxBatch)
throw new ArgumentOutOfRangeException(nameof(count), $"A quantidade deve estar entre 1 e {MaxBatch}.");
var context = new GeneratorContext(seed is { } s ? new Random(s) : Random.Shared, options);
var items = new GeneratedItem[count];
for (var i = 0; i < count; i++)
items[i] = generator.Generate(context);
return items;
}
/// <summary>
/// Validador livre: tenta todos os tipos. Devolve primeiro os válidos, depois os que têm o formato
/// certo mas falham o controlo (ex.: "parece um NIF mas o dígito está errado").
/// </summary>
public IReadOnlyList<DetectionMatch> Detect(string value)
{
if (string.IsNullOrWhiteSpace(value))
return [];
return All
.Where(g => g.CanValidate)
.Select(g => new DetectionMatch(g, SafeValidate(g, value)))
.Where(m => m.Result.FormatMatch)
.OrderByDescending(m => m.Result.IsValid)
.ThenByDescending(m => m.Generator.HasChecksum)
.ToList();
}
private static ValidationResult SafeValidate(IGenerator generator, string value)
{
try
{
return generator.Validate(value);
}
catch (Exception ex) when (ex is FormatException or ArgumentException or OverflowException)
{
return ValidationResult.WrongFormat(ex.Message);
}
}
}

View File

@@ -0,0 +1,44 @@
using System.Text.RegularExpressions;
using GeradoresService.Core;
using GeradoresService.Data;
namespace GeradoresService.Generators.Contactos;
/// <summary>Código postal português (NNNN-NNN). Apenas formato.</summary>
public sealed partial class CodigoPostalGenerator : GeneratorBase
{
public override string Id => "codigo-postal";
public override string Name => "Código postal";
public override string Category => Categories.Contactos;
public override string Description => "Código postal português (CP4-CP3) com localidade.";
public override bool HasChecksum => false;
public override GeneratedItem Generate(GeneratorContext context)
{
var (cp, locality, district) = Random(context);
return new GeneratedItem(cp, new Dictionary<string, string>
{
["Código postal"] = cp,
["Localidade"] = locality,
["Distrito"] = district,
});
}
public static (string Cp, string Locality, string District) Random(GeneratorContext context)
{
var l = context.Pick(PtData.Localities);
return ($"{l.Cp4}-{1 + context.Random.Next(999):D3}", l.Locality, l.District);
}
public override ValidationResult Validate(string value)
{
var m = Pattern().Match(value.Trim());
if (!m.Success)
return ValidationResult.WrongFormat("Formato esperado: NNNN-NNN (ex.: 1000-001).");
var cp = $"{m.Groups[1].Value}-{m.Groups[2].Value}";
return ValidationResult.Valid(cp, "Formato de código postal válido.");
}
[GeneratedRegex(@"^([1-9]\d{3})[-\s]?(\d{3})$")]
private static partial Regex Pattern();
}

View File

@@ -0,0 +1,56 @@
using System.Globalization;
using System.Net.Mail;
using System.Text;
using GeradoresService.Core;
using GeradoresService.Data;
namespace GeradoresService.Generators.Contactos;
public sealed class EmailGenerator : GeneratorBase
{
public override string Id => "email";
public override string Name => "Email";
public override string Category => Categories.Contactos;
public override string Description => "Endereço de email fictício. Por omissão usa domínios reservados para testes (example.com).";
public override bool HasChecksum => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("dominio", "Domínio", "teste",
("teste", "Reservado para testes (example.com)"),
("realista", "Realista (gmail.com, sapo.pt…)")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var first = context.Random.Next(2) == 0 ? context.Pick(PtData.MaleNames) : context.Pick(PtData.FemaleNames);
return new GeneratedItem(Build(first, context.Pick(PtData.Surnames), context, context.Option(Options[0]) == "realista"));
}
public static string Build(string firstName, string surname, GeneratorContext context, bool realistic = false)
{
var domains = realistic ? PtData.RealisticEmailDomains : PtData.SafeEmailDomains;
var separator = context.Pick([".", "_", ""]);
var suffix = context.Random.Next(3) == 0 ? context.Random.Next(1, 100).ToString() : "";
return $"{Slug(firstName)}{separator}{Slug(surname)}{suffix}@{context.Pick(domains)}";
}
private static string Slug(string s)
{
var sb = new StringBuilder();
foreach (var c in s.Normalize(NormalizationForm.FormD))
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark && char.IsLetterOrDigit(c))
sb.Append(char.ToLowerInvariant(c));
return sb.ToString();
}
public override ValidationResult Validate(string value)
{
var v = value.Trim();
var at = v.LastIndexOf('@');
if (at <= 0 || at == v.Length - 1 || !v[(at + 1)..].Contains('.') || v.Any(char.IsWhiteSpace)
|| !MailAddress.TryCreate(v, out var address) || address.Address != v)
return ValidationResult.WrongFormat("Não é um endereço de email válido.");
return ValidationResult.Valid(v.ToLowerInvariant(), "Formato de email válido.");
}
}

View File

@@ -0,0 +1,69 @@
using GeradoresService.Core;
using GeradoresService.Data;
using GeradoresService.Generators.Financeiro;
using GeradoresService.Generators.Identificacao;
namespace GeradoresService.Generators.Contactos;
/// <summary>Pessoa fictícia completa, com todos os identificadores válidos.</summary>
public sealed class PessoaGenerator : GeneratorBase
{
public override string Id => "pessoa";
public override string Name => "Pessoa fictícia";
public override string Category => Categories.Contactos;
public override string Description => "Nome, data de nascimento, NIF, NISS, CC, contactos, morada e IBAN — tudo consistente e válido.";
public override bool CanValidate => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("genero", "Género", "qualquer", ("qualquer", "Qualquer"), ("f", "Feminino"), ("m", "Masculino")),
Option("idade", "Idade", "adulto", ("adulto", "Adulto (18–80)"), ("menor", "Menor (0–17)"), ("qualquer", "Qualquer (0–90)")),
];
public override ValidationResult Validate(string value) =>
ValidationResult.WrongFormat("Este gerador não tem validação.");
public override GeneratedItem Generate(GeneratorContext context)
{
var genero = context.Option(Options[0]);
if (genero == "qualquer")
genero = context.Random.Next(2) == 0 ? "f" : "m";
var names = genero == "f" ? PtData.FemaleNames : PtData.MaleNames;
var first = context.Pick(names);
var middle = context.Random.Next(3) == 0 ? " " + context.Pick(names.Where(n => n != first).ToArray()) : "";
var surname1 = context.Pick(PtData.Surnames);
var surname2 = context.Pick(PtData.Surnames.Where(s => s != surname1).ToArray());
var fullName = $"{first}{middle} {surname1} {surname2}";
var (minAge, maxAge) = context.Option(Options[1]) switch
{
"menor" => (0, 17),
"qualquer" => (0, 90),
_ => (18, 80),
};
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var birth = today.AddYears(-maxAge - 1).AddDays(1 + context.Random.Next((maxAge - minAge + 1) * 365));
var (cp, locality, _) = CodigoPostalGenerator.Random(context);
var street = $"{context.Pick(PtData.StreetTypes)} {context.Pick(PtData.StreetNames)}, {1 + context.Random.Next(300)}";
var nibContext = new GeneratorContext(context.Random, new Dictionary<string, string?>());
var fields = new Dictionary<string, string>
{
["Nome"] = fullName,
["Género"] = genero == "f" ? "Feminino" : "Masculino",
["Data de nascimento"] = birth.ToString("yyyy-MM-dd"),
["NIF"] = NifGenerator.Build(context.Pick(["1", "2", "3"]), context),
["NISS"] = new NissGenerator().Generate(nibContext).Value,
["Cartão de Cidadão"] = new CartaoCidadaoGenerator().Generate(nibContext).Value,
["Telemóvel"] = new TelefoneGenerator().Generate(nibContext).Value,
["Email"] = EmailGenerator.Build(first, surname2, context),
["Morada"] = street,
["Código postal"] = cp,
["Localidade"] = locality,
["IBAN"] = new IbanGenerator().Generate(nibContext).Value,
};
return new GeneratedItem(fullName, fields);
}
}

View File

@@ -0,0 +1,67 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Contactos;
/// <summary>Números de telefone portugueses (Plano Nacional de Numeração). Apenas formato.</summary>
public sealed class TelefoneGenerator : GeneratorBase
{
private static readonly string[] MobilePrefixes = ["91", "92", "93", "96"];
public override string Id => "telefone";
public override string Name => "Telefone";
public override string Category => Categories.Contactos;
public override string Description => "Telemóvel ou telefone fixo português.";
public override bool HasChecksum => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("tipo", "Tipo", "movel", ("movel", "Telemóvel"), ("fixo", "Fixo"), ("qualquer", "Qualquer")),
Option("formato", "Formato", "nacional",
("nacional", "912 345 678"), ("internacional", "+351 912 345 678"), ("compacto", "912345678")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var tipo = context.Option(Options[0]);
if (tipo == "qualquer")
tipo = context.Random.Next(2) == 0 ? "movel" : "fixo";
var number = tipo == "movel"
? context.Pick(MobilePrefixes) + context.Digits(7)
: "2" + (1 + context.Random.Next(9)) + context.Digits(7);
return new GeneratedItem(Format(number, context.Option(Options[1])));
}
private static string Format(string n, string formato) => formato switch
{
"compacto" => n,
"internacional" => $"+351 {n[..3]} {n[3..6]} {n[6..]}",
_ => $"{n[..3]} {n[3..6]} {n[6..]}",
};
public override ValidationResult Validate(string value)
{
var n = Checksums.Clean(value).Replace("(", "").Replace(")", "");
if (n.StartsWith("+351")) n = n[4..];
else if (n.StartsWith("00351")) n = n[5..];
if (n.Length != 9 || !Checksums.AllDigits(n))
return ValidationResult.WrongFormat("Um número português tem 9 dígitos (opcionalmente com +351).");
var tipo = n switch
{
['9', '1' or '2' or '3' or '6', ..] => "telemóvel",
['2', ..] => "fixo",
['3', '0', ..] => "VoIP nómada",
['8', '0', '0', ..] => "número verde (gratuito)",
['8', '0', '8', ..] => "número azul (custo partilhado)",
['7', '0', '7', ..] or ['7', '6', '0', ..] => "número de valor acrescentado",
_ => null,
};
return tipo is null
? ValidationResult.WrongFormat("O prefixo não corresponde a um número português conhecido.")
: ValidationResult.Valid(Format(n, "internacional"), $"Número português válido ({tipo}).", [$"Tipo: {tipo}"]);
}
}

View File

@@ -0,0 +1,82 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// Números de cartão de pagamento válidos pelo algoritmo de Luhn (apenas para testes).
/// </summary>
public sealed class CartaoCreditoGenerator : GeneratorBase
{
private sealed record Brand(string Key, string Name, int Length, Func<Random, string> Prefix, Func<string, bool> Matches);
private static readonly Brand[] Brands =
[
new("visa", "Visa", 16, _ => "4", n => n.StartsWith('4') && n.Length is 13 or 16 or 19),
new("mastercard", "Mastercard", 16,
r => r.Next(2) == 0 ? (51 + r.Next(5)).ToString() : (2221 + r.Next(500)).ToString(),
n => n.Length == 16 && (InRange(n, 2, 51, 55) || InRange(n, 4, 2221, 2720))),
new("amex", "American Express", 15, r => r.Next(2) == 0 ? "34" : "37",
n => n.Length == 15 && (n.StartsWith("34") || n.StartsWith("37"))),
];
public override string Id => "cartao-credito";
public override string Name => "Cartão de crédito";
public override string Category => Categories.Financeiro;
public override string Description => "Número de cartão válido pelo algoritmo de Luhn, com validade e CVV. Apenas para testes.";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("rede", "Rede", "visa", [.. Brands.Select(b => (b.Key, b.Name)), ("qualquer", "Qualquer")]),
Option("formato", "Formato", "espacos", ("espacos", "4111 1111 1111 1111"), ("compacto", "4111111111111111")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var key = context.Option(Options[0]);
var brand = key == "qualquer" ? context.Pick(Brands) : Brands.Single(b => b.Key == key);
var body = brand.Prefix(context.Random);
body += context.Digits(brand.Length - 1 - body.Length);
var number = body + Checksums.LuhnCheckDigit(body);
var expiry = DateTime.UtcNow.AddMonths(context.Random.Next(6, 60));
var cvv = context.Digits(brand.Key == "amex" ? 4 : 3);
var display = context.Option(Options[1]) == "compacto" ? number : Format(number);
return new GeneratedItem(display, new Dictionary<string, string>
{
["Número"] = display,
["Rede"] = brand.Name,
["Validade"] = expiry.ToString("MM/yy"),
["CVV"] = cvv,
});
}
private static string Format(string number) => number.Length == 15
? $"{number[..4]} {number[4..10]} {number[10..]}"
: string.Join(' ', number.Chunk(4).Select(c => new string(c)));
private static bool InRange(string n, int digits, int min, int max) =>
int.TryParse(n.AsSpan(0, digits), out var p) && p >= min && p <= max;
public override ValidationResult Validate(string value)
{
var number = Checksums.Clean(value);
if (number.Length is < 12 or > 19 || !Checksums.AllDigits(number))
return ValidationResult.WrongFormat("Um número de cartão tem entre 12 e 19 dígitos.");
var brand = Brands.FirstOrDefault(b => b.Matches(number));
var details = new List<string>
{
$"Rede: {brand?.Name ?? "desconhecida"}",
"Controlo: algoritmo de Luhn",
};
if (!Checksums.LuhnValid(number))
{
var expected = Checksums.LuhnCheckDigit(number[..^1]);
return ValidationResult.BadChecksum($"Falha no algoritmo de Luhn: último dígito devia ser {expected}.", details);
}
return ValidationResult.Valid(Format(number), $"Número de cartão válido{(brand is null ? "" : $" ({brand.Name})")}.", details);
}
}

View File

@@ -0,0 +1,75 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// IBAN português: PT + 2 dígitos de controlo ISO 7064 (MOD 97-10) + NIB.
/// A validação aceita IBAN de qualquer país (verifica comprimento conhecido e MOD 97).
/// </summary>
public sealed class IbanGenerator : NibGenerator
{
private static readonly Dictionary<string, int> Lengths = new()
{
["PT"] = 25, ["ES"] = 24, ["FR"] = 27, ["DE"] = 22, ["IT"] = 27, ["GB"] = 22, ["IE"] = 22,
["NL"] = 18, ["BE"] = 16, ["LU"] = 20, ["CH"] = 21, ["AT"] = 20, ["DK"] = 18, ["FI"] = 18,
["SE"] = 24, ["NO"] = 15, ["PL"] = 28, ["CZ"] = 24, ["GR"] = 27, ["HR"] = 21, ["RO"] = 24,
["MT"] = 31, ["CY"] = 28, ["SK"] = 24, ["SI"] = 19, ["HU"] = 28, ["BG"] = 22, ["EE"] = 20,
["LT"] = 20, ["LV"] = 21, ["IS"] = 26, ["LI"] = 21, ["MC"] = 27, ["SM"] = 27, ["BR"] = 29,
};
public override string Id => "iban";
public override string Name => "IBAN";
public override string Description => "IBAN português (PT50 + NIB). Valida IBAN de qualquer país.";
public override IReadOnlyList<GeneratorOption> Options =>
[
.. base.Options,
Option("formato", "Formato", "espacos", ("espacos", "PT50 0000 0000 …"), ("compacto", "PT500000…")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var iban = FromBban("PT", BuildNib(context));
return new GeneratedItem(context.Option(Options[1]) == "compacto" ? iban : Format(iban));
}
public static string FromBban(string country, string bban)
{
var check = 98 - Checksums.Mod97(Checksums.LettersToDigits(bban + country) + "00");
return $"{country}{check:D2}{bban}";
}
public static string Format(string iban) =>
string.Join(' ', iban.Chunk(4).Select(c => new string(c)));
public override ValidationResult Validate(string value)
{
var iban = Checksums.Clean(value).ToUpperInvariant();
if (iban.Length < 15 || iban.Length > 34
|| !char.IsAsciiLetter(iban[0]) || !char.IsAsciiLetter(iban[1])
|| !char.IsAsciiDigit(iban[2]) || !char.IsAsciiDigit(iban[3])
|| !iban.All(char.IsAsciiLetterOrDigit))
return ValidationResult.WrongFormat("Um IBAN tem código de país (2 letras), 2 dígitos de controlo e o BBAN.");
var country = iban[..2];
if (Lengths.TryGetValue(country, out var expectedLength) && iban.Length != expectedLength)
return ValidationResult.WrongFormat($"Um IBAN {country} tem {expectedLength} caracteres (tem {iban.Length}).");
var rearranged = Checksums.LettersToDigits(iban[4..] + iban[..4]);
if (Checksums.Mod97(rearranged) != 1)
{
var expected = FromBban(country, iban[4..])[2..4];
return ValidationResult.BadChecksum(
$"Dígitos de controlo do IBAN inválidos: esperado {expected}, encontrado {iban[2..4]}.",
["Controlo: ISO 7064 MOD 97-10"]);
}
if (country == "PT")
{
var nibResult = ValidateNib(iban[4..], "IBAN");
return nibResult with { Normalized = nibResult.IsValid ? Format(iban) : null };
}
return ValidationResult.Valid(Format(iban), $"IBAN válido ({country}).", ["Controlo: ISO 7064 MOD 97-10"]);
}
}

View File

@@ -0,0 +1,75 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// NIB: banco (4) + balcão (4) + conta (11) + controlo (2). Controlo = 98 - (NIB19 × 100 mod 97).
/// </summary>
public class NibGenerator : GeneratorBase
{
public static readonly IReadOnlyList<(string Code, string Name)> Banks =
[
("0007", "Novo Banco"),
("0010", "BPI"),
("0018", "Santander Totta"),
("0023", "Banco ActivoBank"),
("0033", "Millennium BCP"),
("0035", "Caixa Geral de Depósitos"),
("0036", "Banco Montepio"),
("0045", "Crédito Agrícola"),
("0079", "Banco BIC"),
("0193", "Banco CTT"),
("0269", "Bankinter"),
];
public override string Id => "nib";
public override string Name => "NIB";
public override string Category => Categories.Financeiro;
public override string Description => "Número de Identificação Bancária (21 dígitos).";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("banco", "Banco", "qualquer",
[("qualquer", "Qualquer"), .. Banks.Select(b => (b.Code, $"{b.Code} — {b.Name}"))]),
];
public override GeneratedItem Generate(GeneratorContext context) => new(BuildNib(context));
protected string BuildNib(GeneratorContext context)
{
var bank = context.Option(Options[0]);
if (bank == "qualquer")
bank = context.Pick(Banks).Code;
var body = bank + context.Digits(4) + context.Digits(11);
return body + NibCheck(body);
}
public static string NibCheck(string first19) =>
(98 - Checksums.Mod97(first19 + "00")).ToString("D2");
public static string? BankName(string nib) =>
Banks.FirstOrDefault(b => nib.StartsWith(b.Code)).Name;
public override ValidationResult Validate(string value)
{
var nib = Checksums.Clean(value);
if (nib.Length != 21 || !Checksums.AllDigits(nib))
return ValidationResult.WrongFormat("Um NIB tem 21 dígitos.");
return ValidateNib(nib, "NIB");
}
public static ValidationResult ValidateNib(string nib, string label)
{
var expected = NibCheck(nib[..19]);
var details = new List<string>
{
$"Banco: {nib[..4]}{(BankName(nib) is { } n ? $" ({n})" : "")}",
$"Balcão: {nib[4..8]}",
$"Conta: {nib[8..19]}",
$"Dígitos de controlo esperados: {expected} (módulo 97)",
};
return nib[19..] == expected
? ValidationResult.Valid(nib, $"{label} válido.", details)
: ValidationResult.BadChecksum($"Dígitos de controlo do NIB inválidos: esperado {expected}, encontrado {nib[19..]}.", details);
}
}

View File

@@ -0,0 +1,75 @@
using System.Globalization;
using System.Text.RegularExpressions;
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// Referência Multibanco: entidade (5) + referência (9) + montante.
/// Os 2 últimos dígitos da referência são controlo: 98 - (Σ dígito × peso mod 97),
/// calculado sobre entidade(5) + referência(7) + montante em cêntimos(8).
/// </summary>
public sealed partial class ReferenciaMultibancoGenerator : GeneratorBase
{
private static readonly int[] Weights = [51, 73, 17, 89, 38, 62, 45, 53, 15, 50, 5, 49, 34, 81, 76, 27, 90, 9, 30, 3];
public override string Id => "multibanco";
public override string Name => "Referência Multibanco";
public override string Category => Categories.Financeiro;
public override string Description => "Entidade, referência e montante com dígitos de controlo válidos.";
public override GeneratedItem Generate(GeneratorContext context)
{
var entity = (10000 + context.Random.Next(90000)).ToString();
var cents = 100 + context.Random.Next(50000);
var reference = context.Digits(7);
reference += CheckDigits(entity, reference, cents);
var amount = (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
return new GeneratedItem($"{entity} {FormatReference(reference)} {amount}", new Dictionary<string, string>
{
["Entidade"] = entity,
["Referência"] = FormatReference(reference),
["Montante"] = $"{amount} €",
});
}
public static string CheckDigits(string entity, string reference7, long cents)
{
var chk = entity + reference7 + cents.ToString("D8");
var sum = 0;
for (var i = 0; i < 20; i++)
sum += (chk[i] - '0') * Weights[i];
return (98 - sum % 97).ToString("D2");
}
private static string FormatReference(string r) => $"{r[..3]} {r[3..6]} {r[6..]}";
public override ValidationResult Validate(string value)
{
var m = Pattern().Match(value.Trim());
if (!m.Success)
return ValidationResult.WrongFormat("Formato esperado: entidade (5 dígitos), referência (9 dígitos) e montante, ex.: 12345 123 456 789 10.00");
var entity = m.Groups["ent"].Value;
var reference = Checksums.Clean(m.Groups["ref"].Value);
var amount = decimal.Parse(m.Groups["val"].Value.Replace(',', '.'), CultureInfo.InvariantCulture);
var cents = (long)Math.Round(amount * 100);
if (cents > 99_999_999)
return ValidationResult.WrongFormat("Montante demasiado elevado.");
var expected = CheckDigits(entity, reference[..7], cents);
var details = new List<string>
{
$"Entidade: {entity}",
$"Montante: {amount.ToString("0.00", CultureInfo.InvariantCulture)} €",
$"Dígitos de controlo esperados: {expected}",
};
return reference[7..] == expected
? ValidationResult.Valid($"{entity} {FormatReference(reference)} {amount.ToString("0.00", CultureInfo.InvariantCulture)}", "Referência Multibanco válida.", details)
: ValidationResult.BadChecksum($"Dígitos de controlo inválidos: esperado {expected}, encontrado {reference[7..]}.", details);
}
[GeneratedRegex(@"^(?<ent>\d{5})[\s|;]+(?<ref>\d{3}\s?\d{3}\s?\d{3})[\s|;]+(?<val>\d{1,6}(?:[.,]\d{1,2})?)\s*€?$")]
private static partial Regex Pattern();
}

View File

@@ -0,0 +1,61 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Identificacao;
/// <summary>
/// Número de documento do Cartão de Cidadão: NNNNNNNN C VV T
/// - NNNNNNNN: número de identificação civil (NIC)
/// - C: dígito de controlo do NIC (módulo 11)
/// - VV: versão (duas letras/dígitos)
/// - T: dígito de controlo do documento completo (Luhn com A=10..Z=35), conforme especificação da AMA.
/// </summary>
public sealed class CartaoCidadaoGenerator : GeneratorBase
{
public override string Id => "cc";
public override string Name => "Cartão de Cidadão";
public override string Category => Categories.Identificacao;
public override string Description => "Número de documento do Cartão de Cidadão.";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("formato", "Formato", "espacos", ("espacos", "12345678 9 ZZ1"), ("compacto", "123456789ZZ1")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var nic = context.Digits(8);
var c = Checksums.Mod11(nic);
// As versões começam em ZZ e vão decrementando.
var version = "Z" + (char)('A' + context.Random.Next(26));
var payload = $"{nic}{c}{version}";
var t = Checksums.LuhnCheckDigit(payload);
var compact = payload + t;
return new GeneratedItem(context.Option(Options[0]) == "compacto" ? compact : Format(compact));
}
private static string Format(string compact) => $"{compact[..8]} {compact[8]} {compact[9..]}";
public override ValidationResult Validate(string value)
{
var cc = Checksums.Clean(value).ToUpperInvariant();
if (cc.Length != 12
|| !Checksums.AllDigits(cc[..9])
|| !cc[9..11].All(char.IsAsciiLetterOrDigit)
|| !char.IsAsciiDigit(cc[11]))
return ValidationResult.WrongFormat("Formato esperado: 8 dígitos, 1 dígito, 2 letras/dígitos de versão e 1 dígito (ex.: 00000000 0 ZZ4).");
var details = new List<string>
{
$"NIC: {cc[..8]}",
$"Versão: {cc[9..11]}",
"Controlo: algoritmo Luhn sobre os 12 caracteres (A=10 … Z=35)",
};
if (!Checksums.LuhnValid(cc))
{
var expected = Checksums.LuhnCheckDigit(cc[..11]);
return ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {cc[11]}.", details);
}
return ValidationResult.Valid(Format(cc), "Número de documento do CC válido.", details);
}
}

View File

@@ -0,0 +1,119 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Identificacao;
/// <summary>
/// Número de Identificação Fiscal. 9 dígitos; o último é módulo 11 com pesos 9..2.
/// </summary>
public class NifGenerator : GeneratorBase
{
public sealed record NifPrefix(string Prefix, string Description, bool Colectiva);
public static readonly IReadOnlyList<NifPrefix> Prefixes =
[
new("1", "Pessoa singular", false),
new("2", "Pessoa singular", false),
new("3", "Pessoa singular", false),
new("45", "Pessoa singular não residente", false),
new("5", "Pessoa colectiva", true),
new("6", "Administração pública", true),
new("70", "Herança indivisa", false),
new("71", "Pessoa colectiva não residente", true),
new("72", "Fundo de investimento", true),
new("74", "Herança indivisa", false),
new("75", "Herança indivisa", false),
new("77", "Atribuição oficiosa", false),
new("78", "Não residente (reembolso de IVA)", false),
new("79", "Regime excepcional (Expo 98)", false),
new("8", "Empresário em nome individual (extinto)", false),
new("90", "Condomínio / sociedade irregular", true),
new("91", "Condomínio / sociedade irregular", true),
new("98", "Não residente sem estabelecimento estável", true),
new("99", "Sociedade civil sem personalidade jurídica", true),
];
private const string AnySingular = "singular";
private const string Any = "qualquer";
public override string Id => "nif";
public override string Name => "NIF";
public override string Category => Categories.Identificacao;
public override string Description => "Número de Identificação Fiscal (contribuinte).";
protected virtual IEnumerable<NifPrefix> AllowedPrefixes => Prefixes;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("tipo", "Tipo", DefaultChoice,
[.. ExtraChoices, .. AllowedPrefixes.Select(p => (p.Prefix, $"{p.Prefix} — {p.Description}"))]),
];
protected virtual string DefaultChoice => AnySingular;
protected virtual (string, string)[] ExtraChoices =>
[
(AnySingular, "Pessoa singular (1, 2 ou 3)"),
(Any, "Qualquer"),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var choice = context.Option(Options[0]);
var prefix = choice switch
{
AnySingular => context.Pick(["1", "2", "3"]),
Any => context.Pick(AllowedPrefixes.ToArray()).Prefix,
_ => choice,
};
return new GeneratedItem(Build(prefix, context));
}
public static string Build(string prefix, GeneratorContext context)
{
var body = prefix + context.Digits(8 - prefix.Length);
return body + Checksums.Mod11(body);
}
public override ValidationResult Validate(string value)
{
var nif = Checksums.Clean(value);
if (nif.StartsWith("PT", StringComparison.OrdinalIgnoreCase))
nif = nif[2..];
if (nif.Length != 9 || !Checksums.AllDigits(nif))
return ValidationResult.WrongFormat("Um NIF tem 9 dígitos.");
var prefix = FindPrefix(nif);
if (prefix is null || !AllowedPrefixes.Contains(prefix))
return ValidationResult.WrongFormat($"Prefixo '{nif[..2]}' não corresponde a um {Name} válido.");
var expected = Checksums.Mod11(nif.AsSpan(0, 8));
var details = new List<string>
{
$"Prefixo {prefix.Prefix}: {prefix.Description}",
$"Dígito de controlo esperado: {expected} (módulo 11, pesos 9..2)",
};
return expected == nif[8] - '0'
? ValidationResult.Valid(nif, $"{Name} válido — {prefix.Description}.", details)
: ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {nif[8]}.", details);
}
public static NifPrefix? FindPrefix(string nif) =>
Prefixes.FirstOrDefault(p => p.Prefix.Length == 2 && nif.StartsWith(p.Prefix))
?? Prefixes.FirstOrDefault(p => p.Prefix.Length == 1 && nif.StartsWith(p.Prefix));
}
/// <summary>NIPC: NIF de pessoas colectivas. Mesmo algoritmo, prefixos restritos.</summary>
public sealed class NipcGenerator : NifGenerator
{
public override string Id => "nipc";
public override string Name => "NIPC";
public override string Description => "Número de Identificação de Pessoa Colectiva (empresas e entidades).";
protected override IEnumerable<NifPrefix> AllowedPrefixes => Prefixes.Where(p => p.Colectiva);
protected override string DefaultChoice => "5";
protected override (string, string)[] ExtraChoices => [("qualquer", "Qualquer")];
}

View File

@@ -0,0 +1,57 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Identificacao;
/// <summary>
/// Número de Identificação da Segurança Social. 11 dígitos, começa por 1 (singular) ou 2 (colectiva).
/// Controlo: 9 - (Σ dígito × peso) mod 10, pesos 29, 23, 19, 17, 13, 11, 7, 5, 3, 2.
/// </summary>
public sealed class NissGenerator : GeneratorBase
{
private static readonly int[] Weights = [29, 23, 19, 17, 13, 11, 7, 5, 3, 2];
public override string Id => "niss";
public override string Name => "NISS";
public override string Category => Categories.Identificacao;
public override string Description => "Número de Identificação da Segurança Social.";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("tipo", "Tipo", "1", ("1", "1 — Pessoa singular"), ("2", "2 — Pessoa colectiva")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var body = context.Option(Options[0]) + context.Digits(9);
return new GeneratedItem(body + CheckDigit(body));
}
public static int CheckDigit(ReadOnlySpan<char> first10)
{
var sum = 0;
for (var i = 0; i < 10; i++)
sum += (first10[i] - '0') * Weights[i];
return 9 - sum % 10;
}
public override ValidationResult Validate(string value)
{
var niss = Checksums.Clean(value);
if (niss.Length != 11 || !Checksums.AllDigits(niss))
return ValidationResult.WrongFormat("Um NISS tem 11 dígitos.");
if (niss[0] is not ('1' or '2'))
return ValidationResult.WrongFormat("Um NISS começa por 1 (singular) ou 2 (colectiva).");
var expected = CheckDigit(niss);
var tipo = niss[0] == '1' ? "pessoa singular" : "pessoa colectiva";
var details = new List<string>
{
$"Tipo: {tipo}",
$"Dígito de controlo esperado: {expected}",
};
return expected == niss[10] - '0'
? ValidationResult.Valid(niss, $"NISS válido — {tipo}.", details)
: ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {niss[10]}.", details);
}
}

View File

@@ -0,0 +1,31 @@
using System.Text.RegularExpressions;
using GeradoresService.Core;
namespace GeradoresService.Generators.Identificacao;
/// <summary>
/// Número de passaporte português: uma letra seguida de 6 dígitos.
/// Não existe dígito de controlo público no número em si; valida-se apenas o formato.
/// </summary>
public sealed partial class PassaporteGenerator : GeneratorBase
{
public override string Id => "passaporte";
public override string Name => "Passaporte";
public override string Category => Categories.Identificacao;
public override string Description => "Número de passaporte português (letra + 6 dígitos). Apenas formato.";
public override bool HasChecksum => false;
public override GeneratedItem Generate(GeneratorContext context) =>
new($"{(char)('A' + context.Random.Next(26))}{context.Digits(6)}");
public override ValidationResult Validate(string value)
{
var p = Checksums.Clean(value).ToUpperInvariant();
return Pattern().IsMatch(p)
? ValidationResult.Valid(p, "Formato de passaporte português válido (sem dígito de controlo).")
: ValidationResult.WrongFormat("Formato esperado: uma letra seguida de 6 dígitos (ex.: C123456).");
}
[GeneratedRegex("^[A-Z][0-9]{6}$")]
private static partial Regex Pattern();
}

View File

@@ -0,0 +1,53 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Outros;
/// <summary>Códigos de barras EAN-13 / EAN-8 (GS1). Controlo: pesos 3,1,3,1… a partir da direita.</summary>
public sealed class EanGenerator : GeneratorBase
{
public override string Id => "ean";
public override string Name => "EAN / Código de barras";
public override string Category => Categories.Outros;
public override string Description => "Códigos EAN-13 e EAN-8 com dígito de controlo GS1.";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("tipo", "Tipo", "ean13", ("ean13", "EAN-13"), ("ean8", "EAN-8")),
Option("prefixo", "Prefixo GS1", "560", ("560", "560 — Portugal"), ("qualquer", "Qualquer")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var length = context.Option(Options[0]) == "ean8" ? 8 : 13;
var prefix = context.Option(Options[1]) == "560" ? "560" : context.Digits(3);
var body = prefix + context.Digits(length - 1 - prefix.Length);
return new GeneratedItem(body + CheckDigit(body));
}
public static int CheckDigit(string payload)
{
var sum = 0;
for (var i = 0; i < payload.Length; i++)
{
var weight = (payload.Length - i) % 2 == 1 ? 3 : 1;
sum += (payload[i] - '0') * weight;
}
return (10 - sum % 10) % 10;
}
public override ValidationResult Validate(string value)
{
var ean = Checksums.Clean(value);
if (ean.Length is not (8 or 13) || !Checksums.AllDigits(ean))
return ValidationResult.WrongFormat("Um EAN tem 8 ou 13 dígitos.");
var expected = CheckDigit(ean[..^1]);
var details = new List<string> { $"Tipo: EAN-{ean.Length}" };
if (ean.Length == 13 && ean.StartsWith("560"))
details.Add("Prefixo 560: Portugal");
return expected == ean[^1] - '0'
? ValidationResult.Valid(ean, $"EAN-{ean.Length} válido.", details)
: ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {ean[^1]}.", details);
}
}

View File

@@ -0,0 +1,64 @@
using System.Text.RegularExpressions;
using GeradoresService.Core;
namespace GeradoresService.Generators.Outros;
/// <summary>Matrículas portuguesas em todos os formatos desde 1937. Apenas formato.</summary>
public sealed partial class MatriculaGenerator : GeneratorBase
{
// K, W e Y não eram usadas nas matrículas portuguesas.
private const string Letters = "ABCDEFGHIJLMNOPQRSTUVXZ";
private sealed record Format(string Key, string Label, string Mask, string Period);
private static readonly Format[] Formats =
[
new("2020", "AA-00-AA (desde 2020)", "LL-DD-LL", "2020 – actualidade"),
new("2005", "00-AA-00 (2005–2020)", "DD-LL-DD", "2005 – 2020"),
new("1992", "00-00-AA (1992–2005)", "DD-DD-LL", "1992 – 2005"),
new("1937", "AA-00-00 (1937–1992)", "LL-DD-DD", "1937 – 1992"),
];
public override string Id => "matricula";
public override string Name => "Matrícula";
public override string Category => Categories.Outros;
public override string Description => "Matrícula automóvel portuguesa em qualquer um dos formatos.";
public override bool HasChecksum => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("formato", "Formato", "2020", [.. Formats.Select(f => (f.Key, f.Label)), ("qualquer", "Qualquer")]),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var key = context.Option(Options[0]);
var format = key == "qualquer" ? context.Pick(Formats) : Formats.Single(f => f.Key == key);
var plate = string.Concat(format.Mask.Select(c => c switch
{
'L' => Letters[context.Random.Next(Letters.Length)],
'D' => (char)('0' + context.Random.Next(10)),
_ => c,
}));
return new GeneratedItem(plate);
}
public override ValidationResult Validate(string value)
{
var plate = Checksums.Clean(value).ToUpperInvariant();
if (plate.Length != 6 || !plate.All(char.IsAsciiLetterOrDigit))
return ValidationResult.WrongFormat("Uma matrícula tem 6 caracteres em 3 pares (ex.: AA-00-AA).");
var mask = string.Concat(plate.Chunk(2).Select(p => p.All(char.IsAsciiDigit) ? "DD" : p.All(char.IsAsciiLetter) ? "LL" : "??"));
var masked = $"{mask[..2]}-{mask[2..4]}-{mask[4..]}";
var format = Formats.FirstOrDefault(f => f.Mask == masked);
if (format is null)
return ValidationResult.WrongFormat("Não corresponde a nenhum formato de matrícula portuguesa.");
var formatted = $"{plate[..2]}-{plate[2..4]}-{plate[4..]}";
if (plate.Any(c => c is 'K' or 'W' or 'Y'))
return ValidationResult.BadChecksum("As letras K, W e Y não são usadas em matrículas portuguesas.");
return ValidationResult.Valid(formatted, $"Matrícula válida (formato {format.Period}).", [$"Formato: {format.Label}"]);
}
}

View File

@@ -0,0 +1,50 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Outros;
public sealed class UuidGenerator : GeneratorBase
{
public override string Id => "uuid";
public override string Name => "UUID / GUID";
public override string Category => Categories.Outros;
public override string Description => "Identificadores únicos UUID v4 (aleatório) ou v7 (ordenado por tempo).";
public override bool HasChecksum => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("versao", "Versão", "v4", ("v4", "v4 — aleatório"), ("v7", "v7 — ordenado por tempo")),
Option("formato", "Formato", "minusculas",
("minusculas", "minúsculas com hífens"), ("maiusculas", "MAIÚSCULAS com hífens"), ("compacto", "sem hífens")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var guid = context.Option(Options[0]) == "v7" ? Guid.CreateVersion7() : NewV4(context.Random);
var text = context.Option(Options[1]) switch
{
"maiusculas" => guid.ToString("D").ToUpperInvariant(),
"compacto" => guid.ToString("N"),
_ => guid.ToString("D"),
};
return new GeneratedItem(text);
}
/// <summary>UUID v4 a partir de <paramref name="random"/> (permite resultados reprodutíveis com seed).</summary>
private static Guid NewV4(Random random)
{
Span<byte> bytes = stackalloc byte[16];
random.NextBytes(bytes);
// Versão 4 (nibble alto do byte 7 no layout do System.Guid) e variante RFC 9562.
bytes[7] = (byte)((bytes[7] & 0x0F) | 0x40);
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
return new Guid(bytes);
}
public override ValidationResult Validate(string value)
{
var v = value.Trim().Trim('{', '}');
if (v.Length is not (32 or 36) || !Guid.TryParse(v, out var guid))
return ValidationResult.WrongFormat("Não é um UUID válido.");
return ValidationResult.Valid(guid.ToString("D"), $"UUID válido (versão {guid.Version}).", [$"Versão: {guid.Version}", $"Variante: {guid.Variant}"]);
}
}

View File

@@ -1,21 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Folder Include="DAL\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
</ItemGroup>
</Project>

View File

@@ -1,97 +0,0 @@
using GeradoresService.DAL;
using System;
using System.ComponentModel;
using System.Linq;
using System.Text.Json.Serialization;
namespace GeradoresService
{
public class NIF
{
private readonly GeradoresContext _geradoresContext;
public NIF(GeradoresContext geradoresContext)
{
_geradoresContext = geradoresContext;
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NIFType
{
[Description("Pessoa singular (1)")]
PessoaSingular1 = 1,
[Description("Pessoa singular (2)")]
PessoaSingular2 = 2,
[Description("Pessoa singular (3; novo em 2019)")]
PessoaSingular3 = 3,
[Description("Pessoa colectiva (5)")]
PessoaColectiva = 5,
[Description("Pessoa colectiva pública (6)")]
PessoaColectivaPublica = 6,
[Description("Empresário em nome individual (8)")]
EmpresarioIndividual = 8,
[Description("Pessoa colectiva irregular ou número provisório (9)")]
PessoaColectivaIrregular = 9
}
public string Generate(NIFType? type)
{
var nif = GenerateRandomNIF(type);
return nif;
}
public string GenerateRandomNIF(NIFType? nifType)
{
char firstDigit;
Random rnd = new Random();
if (nifType.HasValue)
{
firstDigit = ((int)nifType.Value).ToString()[0];
}
else
{
var validFirstDigits = Enum.GetValues(typeof(NIFType)).Cast<int>().Select(v => v.ToString()[0]).ToArray();
firstDigit = validFirstDigits[rnd.Next(validFirstDigits.Length)];
}
string nextDigits = string.Concat(Enumerable.Range(0, 7).Select(_ => rnd.Next(0, 10).ToString()));
int checkDigit = (firstDigit - '0') * 9;
for (int i = 0; i < 7; i++)
{
checkDigit += (nextDigits[i] - '0') * (8 - i);
}
checkDigit = 11 - (checkDigit % 11);
if (checkDigit >= 10)
checkDigit = 0;
return firstDigit + nextDigits + checkDigit;
}
public bool Validate(string nif)
{
if (nif.Length != 9 || !nif.All(char.IsDigit))
return false;
int[] digitos = nif.Select(c => c - '0').ToArray();
int soma = digitos.Take(8).Select((num, i) => num * (9 - i)).Sum();
int digitoControlo = (11 - (soma % 11)) % 10;
return digitoControlo == digitos[8];
}
public void SaveNIF(string NIF)
{
var ger = new Geradore() { Valor = NIF };
_geradoresContext.Geradores.Add(ger);
_geradoresContext.SaveChanges();
}
}
}

View File

@@ -1,90 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GeradoresService
{
public class NISS
{
private static readonly Random random = new Random();
public static string Generate() {
return GenerateNISS();
}
private static string GenerateRandomDigits(int length)
{
string digits = "1";
for (int i = 0; i < length; i++)
{
digits += random.Next(0, 10); // Gera um dígito aleatório entre 0 e 9
}
return digits;
}
public static string GenerateNISS()
{
// Gera 9 dígitos aleatórios
string niss = GenerateRandomDigits(9);
// Calcula o dígito de controlo
int soma = 0;
for (int i = 0; i < 10; i++)
{
int digito = int.Parse(niss[i].ToString());
soma += digito * (i + 1);
}
int resto = soma % 11;
int digitoControlo = 11 - resto;
// Verifica se o dígito de controlo está no intervalo [0, 9]
if (digitoControlo == 10)
{
niss += "0";
}
else if (digitoControlo == 11)
{
niss += "1";
}
else
{
niss += digitoControlo.ToString();
}
return niss;
}
public static bool Validate(string niss)
{
// Verifica se o NISS tem 11 dígitos
if (niss.Length != 11)
return false;
// Verifica se é um número
foreach (char c in niss)
{
if (!char.IsDigit(c))
return false;
}
// Calcula o dígito de controlo
int soma = 0;
for (int i = 0; i < 10; i++)
{
int digito = int.Parse(niss[i].ToString());
soma += digito * (i + 1);
}
int resto = soma % 11;
int digitoControlo = 11 - resto;
// Verifica o último dígito do NISS
int ultimoDigito = int.Parse(niss[10].ToString());
if (digitoControlo == 10 && ultimoDigito != 0)
return false;
if (digitoControlo == 11 && ultimoDigito != 0 && ultimoDigito != 1)
return false;
if (digitoControlo != ultimoDigito)
return false;
return true;
}
}
}

View File

@@ -0,0 +1,71 @@
# GeradoresService
Biblioteca .NET 10 com os algoritmos de geração e validação. Não tem dependências externas e é usada pela API
([GeradoresWS](../GeradoresWS/README.md)) e pelos testes ([UnitTest](../UnitTest/README.md)).
## Estrutura
| Caminho | Conteúdo |
|---|---|
| `Core/IGenerator.cs` | Contrato de um gerador/validador e as categorias |
| `Core/GeneratorBase.cs` | Classe base com valores por omissão e o helper `Option(...)` |
| `Core/Models.cs` | `GeneratorOption`, `GeneratedItem`, `ValidationResult`, `GeneratorContext` |
| `Core/Checksums.cs` | Algoritmos partilhados: módulo 11, Luhn (com letras), MOD 97-10, limpeza de input |
| `Data/PtData.cs` | Nomes, apelidos, localidades, bancos e outros dados portugueses fictícios |
| `Generators/<Categoria>/` | Um ficheiro por tipo |
| `GeneratorRegistry.cs` | Lista de geradores, geração em lote e detecção automática do tipo |
## Geradores
| Id | Nome | Categoria | Validação |
|---|---|---|---|
| `nif` | NIF | Identificação | Módulo 11, pesos 9..2 |
| `nipc` | NIPC | Identificação | Igual ao NIF, só prefixos de pessoa colectiva |
| `niss` | NISS | Identificação | 9 − (Σ dígito × peso) mod 10, pesos 29, 23, 19, 17, 13, 11, 7, 5, 3, 2 |
| `cc` | Cartão de Cidadão | Identificação | Módulo 11 no NIC + Luhn (A=10..Z=35) no documento |
| `passaporte` | Passaporte | Identificação | Só formato (letra + 6 dígitos) |
| `iban` | IBAN | Financeiro | ISO 7064 MOD 97-10; aceita IBAN de qualquer país |
| `nib` | NIB | Financeiro | 98 − (NIB19 × 100 mod 97) |
| `cartao-credito` | Cartão de crédito | Financeiro | Luhn |
| `multibanco` | Referência Multibanco | Financeiro | 98 − (Σ dígito × peso mod 97) sobre entidade + referência + montante |
| `pessoa` | Pessoa fictícia | Contactos e Morada | Não valida (gerador composto) |
| `telefone` | Telefone | Contactos e Morada | Só formato (Plano Nacional de Numeração) |
| `codigo-postal` | Código postal | Contactos e Morada | Só formato (NNNN-NNN) |
| `email` | Email | Contactos e Morada | Só formato |
| `matricula` | Matrícula | Outros | Só formato (todos os formatos desde 1937) |
| `ean` | EAN / Código de barras | Outros | Dígito GS1 (pesos 3,1,3,1…) |
| `uuid` | UUID / GUID | Outros | Só formato (v4 ou v7) |
`HasChecksum` indica se a validação verifica um dígito de controlo ou apenas o formato; `CanValidate` é
`false` para geradores compostos como `pessoa`.
## Utilização
```csharp
var registry = new GeneratorRegistry();
var nif = registry.Find("nif")!;
// 10 NIF de pessoa colectiva, reprodutíveis com seed
var items = registry.Generate(nif, 10, new Dictionary<string, string?> { ["tipo"] = "5" }, seed: 42);
// Validação de um tipo
ValidationResult r = nif.Validate("123456789"); // IsValid, FormatMatch, Message, Normalized, Details
// Detecção automática: válidos primeiro, depois os que têm o formato mas falham o controlo
var matches = registry.Detect("PT50 0002 0123 1234 5678 9015 4");
```
- O lote vai de 1 a `GeneratorRegistry.MaxBatch` (1000); fora disso lança `ArgumentOutOfRangeException`.
- Um valor de opção que não esteja nas escolhas do gerador lança `ArgumentException`.
- `ValidationResult.Details` traz os passos do cálculo, para explicar porque um valor é (in)válido.
## Adicionar um gerador
1. Criar uma classe em `Generators/<Categoria>/` que herde de `GeneratorBase` e implemente `Id`, `Name`,
`Category`, `Description`, `Generate` e `Validate`. As opções declaram-se com `Option(key, label, default, choices...)`
e lêem-se com `context.Option(Options[i])`.
2. Usar sempre `context.Random` (nunca `Random.Shared`), para que o `seed` produza resultados reprodutíveis.
3. Registar a instância no construtor de `GeneratorRegistry`.
O novo tipo aparece automaticamente na API e no frontend, e o `GeneratorRoundTripTests` passa a gerá-lo com
todas as opções e a confirmar que o validador aceita os valores gerados.

View File

@@ -1,18 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
public static class EnumHelper
{
public static List<(T Value, string Description)> GetEnumValuesAndDescriptions<T>() where T : Enum
{
return typeof(T).GetFields()
.Select(field => (
Value: (T)field.GetValue(null),
Description: field.GetCustomAttribute<DescriptionAttribute>()?.Description ?? field.Name
))
.ToList();
}
}

View File

@@ -1,55 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using GeradoresService;
using GeradoresService.DAL;
namespace GeradoresWS.Controllers
{
[ApiController]
[Route("[controller]")]
public class GenerateController : Controller
{
GeradoresContext _context;
public GenerateController(GeradoresContext context)
{
_context = context;
}
#region NIF
[HttpGet("GenerateNIF")]
public string GenerateNIF(NIF.NIFType type)
{
var nif = new NIF(_context);
return nif.Generate(type);
}
[HttpGet("ValidateNIF")]
public bool ValidateNIF(string nif)
{
var teste = new NIF(_context);
return teste.Validate(nif);
}
#endregion
#region NISS
[HttpGet("GenerateNISS")]
public string GenerateNISS()
{
return NISS.Generate();
}
[HttpGet("ValidateNISS")]
public bool ValidateNISS(string nif)
{
return NISS.Validate(nif);
}
#endregion
#region CC
[HttpGet("GenerateCC")]
public string GenerateCC()
{
return CartaoCidadao.Generate();
}
#endregion
}
}

View File

@@ -1,33 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace GeradoresWS.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
}

View File

@@ -1,31 +1,26 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
# Usado pelo Visual Studio (perfil "Docker"). Igual ao Dockerfile da raiz, com o stage "base" que o VS espera.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["GeradoresWS/GeradoresWS.csproj", "GeradoresWS/"]
COPY ["GeradoresService/GeradoresService.csproj", "GeradoresService/"]
RUN dotnet restore "GeradoresWS/GeradoresWS.csproj"
COPY . .
WORKDIR "/src/GeradoresWS"
RUN dotnet build "GeradoresWS.csproj" -c debug -o /app/build
COPY GeradoresService/ GeradoresService/
COPY GeradoresWS/ GeradoresWS/
RUN dotnet publish "GeradoresWS/GeradoresWS.csproj" -c Release -o /app/publish --no-restore /p:UseAppHost=false
FROM build AS publish
RUN dotnet publish "GeradoresWS.csproj" -c debug -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
COPY --from=build /app/publish .
# Set environment variables
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_HTTP_PORTS=8080 \
ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "GeradoresWS.dll"]

View File

@@ -1,65 +0,0 @@
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.ComponentModel;
namespace Primus.ProfitabilityModel.WebAPI.Filters
{
public class EnumDescriptionFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (context.Type.IsEnum)
{
// Obter os valores do enum
var enumValues = Enum.GetValues(context.Type)
.Cast<Enum>()
.ToList();
// Obter os nomes dos enums
var enumNames = enumValues.Select(e => e.ToString()).ToList();
// Obter as descrições dos enums
var enumDescriptions = enumValues.Select(e => GetEnumDescription(e)).ToList();
// Adicionar as extensões com os nomes e descrições
var enumNamesArray = new OpenApiArray();
foreach (var name in enumNames)
{
enumNamesArray.Add(new OpenApiString(name));
}
var enumDescriptionsArray = new OpenApiArray();
foreach (var description in enumDescriptions)
{
enumDescriptionsArray.Add(new OpenApiString(description));
}
schema.Extensions.Add("x-enumNames", enumDescriptionsArray);
schema.Extensions.Add("x-enumDescriptions", enumDescriptionsArray);
// Adicionar os valores do enum ao campo Enum
schema.Enum = enumNames
.Select(name => new OpenApiString(name))
.Cast<IOpenApiAny>()
.ToList();
}
}
private static string GetEnumDescription(Enum value)
{
// Verificar se o campo é nulo antes de chamar GetCustomAttribute
var field = value.GetType().GetField(value.ToString());
if (field == null)
{
return value.ToString(); // Se o campo for nulo, retorna o nome do valor do enum
}
// Acessa o atributo Description, caso exista
var attribute = (DescriptionAttribute)Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute));
// Retorna a descrição ou o nome do valor do enum, se a descrição não estiver presente
return attribute?.Description ?? value.ToString();
}
}
}

View File

@@ -0,0 +1,101 @@
using System.ComponentModel;
using GeradoresService;
using GeradoresService.Core;
using Microsoft.AspNetCore.Http.HttpResults;
namespace GeradoresWS;
public static class GeneratorEndpoints
{
private static readonly HashSet<string> ReservedQueryKeys = new(StringComparer.OrdinalIgnoreCase) { "count", "seed" };
public static void MapGeneratorEndpoints(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api/v1").WithTags("Geradores");
api.MapGet("/generators", (GeneratorRegistry registry) =>
registry.All.Select(GeneratorDto.From).ToList())
.WithName("ListGenerators")
.WithSummary("Lista todos os geradores e as respectivas opções.");
api.MapGet("/generators/{id}", Results<Ok<GeneratorDto>, NotFound> (string id, GeneratorRegistry registry) =>
registry.Find(id) is { } g ? TypedResults.Ok(GeneratorDto.From(g)) : TypedResults.NotFound())
.WithName("GetGenerator")
.WithSummary("Detalhe de um gerador.");
api.MapGet("/generators/{id}/generate", Generate)
.WithName("Generate")
.WithSummary("Gera um ou mais valores.")
.WithDescription("As opções do gerador passam-se na query string, ex.: /generators/nif/generate?count=10&tipo=5");
api.MapGet("/generators/{id}/validate",
Results<Ok<ValidationResult>, NotFound> (string id, [Description("Valor a validar")] string value, GeneratorRegistry registry) =>
registry.Find(id) is { CanValidate: true } g
? TypedResults.Ok(g.Validate(value))
: TypedResults.NotFound())
.WithName("Validate")
.WithSummary("Valida um valor de um tipo específico.");
api.MapGet("/validate", (string value, GeneratorRegistry registry) =>
new DetectResponse(value, registry.Detect(value)
.Select(m => new DetectMatchDto(m.Generator.Id, m.Generator.Name, m.Result))
.ToList()))
.WithName("Detect")
.WithSummary("Validador livre: identifica o tipo do valor e diz se é válido.");
}
private static Results<Ok<GenerateResponse>, NotFound, ValidationProblem> Generate(
string id,
HttpRequest request,
GeneratorRegistry registry,
[Description("Quantidade (1–1000)")] int count = 1,
[Description("Seed para resultados reprodutíveis")] int? seed = null)
{
var generator = registry.Find(id);
if (generator is null)
return TypedResults.NotFound();
var options = request.Query
.Where(q => !ReservedQueryKeys.Contains(q.Key))
.ToDictionary(q => q.Key, q => (string?)q.Value.ToString(), StringComparer.OrdinalIgnoreCase);
var unknown = options.Keys.Where(k => generator.Options.All(o => !string.Equals(o.Key, k, StringComparison.OrdinalIgnoreCase))).ToList();
if (unknown.Count > 0)
return Problem(unknown[0], $"Opção desconhecida. Opções válidas: {string.Join(", ", generator.Options.Select(o => o.Key))}.");
if (count is < 1 or > GeneratorRegistry.MaxBatch)
return Problem("count", $"A quantidade deve estar entre 1 e {GeneratorRegistry.MaxBatch}.");
try
{
var items = registry.Generate(generator, count, options, seed);
return TypedResults.Ok(new GenerateResponse(generator.Id, items));
}
catch (ArgumentException ex)
{
return Problem("options", ex.Message);
}
}
private static ValidationProblem Problem(string key, string message) =>
TypedResults.ValidationProblem(new Dictionary<string, string[]> { [key] = [message] });
}
public sealed record GeneratorDto(
string Id,
string Name,
string Category,
string Description,
bool HasChecksum,
bool CanValidate,
IReadOnlyList<GeneratorOption> Options)
{
public static GeneratorDto From(IGenerator g) =>
new(g.Id, g.Name, g.Category, g.Description, g.HasChecksum, g.CanValidate, g.Options);
}
public sealed record GenerateResponse(string Generator, IReadOnlyList<GeneratedItem> Items);
public sealed record DetectMatchDto(string GeneratorId, string GeneratorName, ValidationResult Result);
public sealed record DetectResponse(string Value, IReadOnlyList<DetectMatchDto> Matches);

View File

@@ -1,17 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>df357c84-88b1-4100-86d1-889166b333f7</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.18.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.14" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,56 +1,40 @@
using GeradoresService.DAL;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
using Primus.ProfitabilityModel.WebAPI.Filters;
using System.Text.Json.Serialization;
using GeradoresService;
using GeradoresWS;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers().AddJsonOptions(options =>
builder.Services.AddSingleton<GeneratorRegistry>();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddOpenApi(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
options.SwaggerDoc("v1", new OpenApiInfo
options.AddDocumentTransformer((document, _, _) =>
{
Version = "v1",
Title = "FactoryId",
Contact = new OpenApiContact
{
Name = "Marco Santos"
},
document.Info.Title = "FactoryId API";
document.Info.Description = "Geradores e validadores de identificadores portugueses (NIF, NISS, CC, IBAN, …) para testes.";
document.Info.Contact = new() { Name = "Marco Santos" };
return Task.CompletedTask;
});
// Registra o filtro de esquema que aplica descrições para todos os enums
options.SchemaFilter<EnumDescriptionFilter>();
});
var connectionString = builder.Configuration.GetConnectionString("GeradoresConnection");
builder.Services.AddDbContext<GeradoresContext>(options => options.UseSqlServer(connectionString));
// API pública, só de leitura e sem credenciais: qualquer origem pode fazer GET.
builder.Services.AddCors(options =>
options.AddDefaultPolicy(policy => policy.AllowAnyOrigin().WithMethods("GET").AllowAnyHeader()));
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseCors();
app.UseAuthorization();
app.MapOpenApi();
app.MapScalarApiReference("/docs", options => options.WithTitle("FactoryId API"));
app.MapGet("/", () => Results.Redirect("/docs")).ExcludeFromDescription();
app.MapHealthChecks("/health");
app.UseCors(options =>
{
options.AllowAnyOrigin();
options.AllowAnyMethod();
options.AllowAnyHeader();
});
app.MapControllers();
app.MapGeneratorEndpoints();
app.Run();
public partial class Program;

View File

@@ -3,7 +3,7 @@
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "docs",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
@@ -13,7 +13,7 @@
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "docs",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
@@ -23,7 +23,7 @@
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "docs",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -31,7 +31,7 @@
"Docker": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/docs",
"environmentVariables": {
"ASPNETCORE_URLS": "http://+:8080",
"ASPNETCORE_ENVIRONMENT": "Development"

64
GeradoresWS/README.md Normal file
View File

@@ -0,0 +1,64 @@
# GeradoresWS
API REST ASP.NET Core (.NET 10, minimal APIs) que expõe os geradores do
[GeradoresService](../GeradoresService/README.md). É só de leitura, não tem autenticação nem base de dados.
## Executar
```bash
dotnet run --project GeradoresWS --launch-profile http
# http://localhost:5053 → redirecciona para /docs
```
| Perfil (`launchSettings.json`) | URL |
|---|---|
| `http` | http://localhost:5053 |
| `https` | https://localhost:7266 e http://localhost:5053 |
| `Docker` | Visual Studio, usa o `Dockerfile` desta pasta |
## Endpoints
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/v1/generators` | Lista os geradores e as opções de cada um |
| GET | `/api/v1/generators/{id}` | Detalhe de um gerador (404 se não existir) |
| GET | `/api/v1/generators/{id}/generate` | Gera valores. Query: `count` (1–1000, por omissão 1), `seed` (opcional) e as opções do gerador |
| GET | `/api/v1/generators/{id}/validate?value=...` | Valida um valor de um tipo (404 se o tipo não validar) |
| GET | `/api/v1/validate?value=...` | Validador livre: detecta o tipo e valida |
| GET | `/openapi/v1.json` | Documento OpenAPI |
| GET | `/docs` | Documentação interactiva (Scalar) |
| GET | `/health` | Health check |
Exemplos:
```bash
curl "http://localhost:5053/api/v1/generators/nif/generate?count=5&tipo=5"
curl "http://localhost:5053/api/v1/generators/iban/validate?value=PT50000201231234567890154"
curl "http://localhost:5053/api/v1/validate?value=123456789"
```
Erros de input (opção desconhecida, valor de opção inválido, `count` fora do intervalo) devolvem
`400` com `ValidationProblemDetails`; as restantes falhas devolvem `ProblemDetails`.
## Configuração
- **CORS**: qualquer origem pode fazer `GET` (API pública, sem credenciais).
- **Porta em container**: `8080` (`ASPNETCORE_HTTP_PORTS`).
- `InvariantGlobalization` está activo, por isso a imagem não precisa de ICU.
## Docker
O `Dockerfile` da raiz do repositório é o usado no `docker compose` e no Jenkins. O `Dockerfile` desta pasta é
equivalente, com o stage `base` que o Visual Studio espera. Em ambos o contexto de build é a raiz do repositório:
```bash
docker build -t geradoresws .
docker run -p 5050:8080 geradoresws
```
## Ficheiros
| Ficheiro | Conteúdo |
|---|---|
| `Program.cs` | Serviços (registry, OpenAPI, CORS, health checks) e pipeline |
| `GeneratorEndpoints.cs` | Rotas `/api/v1` e DTOs de resposta |

View File

@@ -1,13 +0,0 @@
namespace GeradoresWS
{
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
}

View File

@@ -5,9 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"GeradoresConnection": "server=127.0.0.1;uid=sa;pwd=mssqlAdmin!1;database=Geradores;TrustServerCertificate=True;"
}
"AllowedHosts": "*"
}

18
Jenkinsfile vendored
View File

@@ -6,8 +6,6 @@ pipeline {
DOCKER_TAG = "${env.BUILD_ID}"
REACT_DIR = 'geradoresfe'
SERVICE_DIR = 'GeradoresService'
API_DIR = 'GeradoresWS'
REACT_IMAGE = "${DOCKER_REGISTRY}/geradores/fe:${DOCKER_TAG}"
WS_IMAGE = "${DOCKER_REGISTRY}/geradores/ws:${DOCKER_TAG}"
@@ -36,21 +34,19 @@ pipeline {
}
}
stage('Build .NET Services') {
stage('Testes .NET') {
steps {
dir("${SERVICE_DIR}") {
sh 'dotnet build -c Release'
}
dir("${API_DIR}") {
sh 'dotnet build -c Release'
}
// Compila Service + WS e corre os testes (requer .NET 10 SDK no agente).
sh 'dotnet test UnitTest/UnitTest.csproj -c Release'
}
}
stage('Build React App') {
stage('Testes e Build React') {
steps {
dir("${REACT_DIR}") {
sh 'npm install'
sh 'npm ci'
sh 'npm run lint'
sh 'npm test'
sh 'npm run build'
}
}

View File

@@ -1,7 +1,65 @@
# Geradores
# Factory Id — Geradores
<code> docker build -t shini89/geradoresws ..</code>
<code> docker run --rm -p 44329:8080 shini89/geradoresws</code>
<code>npx swagger-typescript-api generate -p http://localhost:29191/swagger/v1/swagger.json -o src/service -n api.ts --enum-names-as-values</code>
Geradores e validadores de dados portugueses para testes de software: NIF, NIPC, NISS, Cartão de Cidadão,
IBAN/NIB, cartões de pagamento, referências Multibanco, telefones, códigos postais, emails, pessoas fictícias,
matrículas, EAN e UUID.
npx swagger-typescript-api generate -p http://localhost:29191/swagger/v1/swagger.json -o src/service -n api.ts --http-client axios --enum-names-as-values
- **Geração em lote** (até 1000 de cada vez) com exportação CSV, JSON e TXT
- **Validador livre**: cola-se um valor qualquer, o tipo é detectado e é explicado porque é (in)válido
- **API REST** documentada em `/docs`
## Estrutura
| Pasta | Conteúdo |
|---|---|
| [`GeradoresService/`](GeradoresService/README.md) | Biblioteca com os algoritmos. Cada tipo é uma classe `IGenerator` em `Generators/` |
| [`GeradoresWS/`](GeradoresWS/README.md) | API ASP.NET Core (.NET 10, minimal APIs, OpenAPI + Scalar) |
| [`UnitTest/`](UnitTest/README.md) | Testes xUnit (algoritmos, valores conhecidos e API) |
| [`geradoresfe/`](geradoresfe/README.md) | Frontend React + Vite + Tailwind. A UI é construída a partir dos metadados da API |
## Desenvolvimento
```bash
# API em http://localhost:5053 (docs em /docs)
dotnet run --project GeradoresWS --launch-profile http
# Frontend em http://localhost:3000 (faz proxy de /api para a API)
cd geradoresfe && npm install && npm run dev
```
Testes:
```bash
dotnet test UnitTest
cd geradoresfe && npm test && npm run lint
```
## Docker
```bash
docker compose up --build
# Frontend: http://localhost:3000 API directa: http://localhost:5050/docs
```
O nginx do frontend encaminha `/api`, `/docs` e `/openapi` para `API_UPSTREAM`
(por omissão `http://geradoresws:8080`), por isso a mesma imagem funciona em qualquer ambiente
sem rebuild.
## API
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/v1/generators` | Lista de geradores e opções |
| GET | `/api/v1/generators/{id}/generate?count=10&<opção>=<valor>` | Gera valores (`seed` opcional para resultados reprodutíveis) |
| GET | `/api/v1/generators/{id}/validate?value=...` | Valida um valor de um tipo |
| GET | `/api/v1/validate?value=...` | Detecta o tipo e valida |
| GET | `/health` | Health check |
Exemplo: `GET /api/v1/generators/nif/generate?count=5&tipo=5` gera 5 NIF de pessoa colectiva.
## Adicionar um gerador
1. Criar uma classe em `GeradoresService/Generators/<Categoria>/` que herde de `GeneratorBase`.
2. Registá-la em `GeneratorRegistry`.
3. Pronto: aparece na API, no frontend e é coberta automaticamente pelo teste de ida e volta
(`GeneratorRoundTripTests`), que gera valores com todas as opções e confirma que o validador os aceita.

66
UnitTest/ApiTests.cs Normal file
View File

@@ -0,0 +1,66 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
namespace UnitTest;
public class ApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task Lists_generators()
{
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/generators");
Assert.Contains(json.EnumerateArray(), g => g.GetProperty("id").GetString() == "nif");
}
[Fact]
public async Task Generates_batch_with_options()
{
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/generators/nif/generate?count=25&tipo=5&seed=1");
var items = json.GetProperty("items").EnumerateArray().ToList();
Assert.Equal(25, items.Count);
Assert.All(items, i => Assert.StartsWith("5", i.GetProperty("value").GetString()));
}
[Theory]
[InlineData("/api/v1/generators/nif/generate?count=0")]
[InlineData("/api/v1/generators/nif/generate?count=5000")]
[InlineData("/api/v1/generators/nif/generate?tipo=xpto")]
[InlineData("/api/v1/generators/nif/generate?desconhecida=1")]
public async Task Bad_requests(string url)
{
var response = await _client.GetAsync(url);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Unknown_generator_is_404()
{
var response = await _client.GetAsync("/api/v1/generators/xpto/generate");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Validates_value()
{
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/generators/nif/validate?value=123456789");
Assert.True(json.GetProperty("isValid").GetBoolean());
}
[Fact]
public async Task Detects_value()
{
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/validate?value=" + Uri.EscapeDataString("PT50 0002 0123 1234 5678 9015 4"));
Assert.Equal("iban", json.GetProperty("matches")[0].GetProperty("generatorId").GetString());
}
[Fact]
public async Task OpenApi_and_health_are_exposed()
{
Assert.Equal(HttpStatusCode.OK, (await _client.GetAsync("/openapi/v1.json")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await _client.GetAsync("/health")).StatusCode);
}
}

View File

@@ -1,38 +0,0 @@
using GeradoresService;
using GeradoresService.DAL;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace UnitTest
{
public class DB
{
private readonly GeradoresContext _context;
public DB()
{
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.Build();
// Obter a connection string a partir do arquivo de configuração
var connectionString = configuration.GetConnectionString("GeradoresConnection");
// Configurar o DbContext para usar o InMemoryDatabase
var options = new DbContextOptionsBuilder<GeradoresContext>()
.UseSqlServer(connectionString)
.Options;
_context = new GeradoresContext(options);
}
[Fact]
public void GenerateNIF()
{
var nif = new NIF(_context);
//var gerenatednif = nif.Generate("2");
//Assert.NotEmpty(gerenatednif);
}
}
}

47
UnitTest/DetectTests.cs Normal file
View File

@@ -0,0 +1,47 @@
using GeradoresService;
namespace UnitTest;
public class DetectTests
{
private static readonly GeneratorRegistry Registry = new();
[Fact]
public void Detects_nif_first()
{
var matches = Registry.Detect("123456789");
Assert.NotEmpty(matches);
Assert.Equal("nif", matches[0].Generator.Id);
Assert.True(matches[0].Result.IsValid);
}
[Fact]
public void Detects_iban()
{
var matches = Registry.Detect("PT50 0002 0123 1234 5678 9015 4");
Assert.Equal("iban", matches[0].Generator.Id);
}
[Fact]
public void Reports_near_miss_when_checksum_fails()
{
var matches = Registry.Detect("123456780");
Assert.Contains(matches, m => m.Generator.Id == "nif" && !m.Result.IsValid && m.Result.FormatMatch);
Assert.DoesNotContain(matches, m => m.Generator.Id == "nif" && m.Result.IsValid);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("isto não é nada")]
public void Nothing_detected(string value)
{
Assert.DoesNotContain(Registry.Detect(value), m => m.Result.IsValid);
}
[Fact]
public void Pessoa_is_never_detected()
{
Assert.DoesNotContain(Registry.Detect("João Silva"), m => m.Generator.Id == "pessoa");
}
}

View File

@@ -0,0 +1,107 @@
using GeradoresService;
using GeradoresService.Core;
namespace UnitTest;
/// <summary>
/// Para cada gerador e cada valor de cada opção, os valores gerados têm de passar no próprio validador.
/// </summary>
public class GeneratorRoundTripTests
{
private static readonly GeneratorRegistry Registry = new();
public static TheoryData<string, string, string> AllOptionValues()
{
var data = new TheoryData<string, string, string>();
foreach (var g in Registry.All)
{
if (g.Options.Count == 0)
data.Add(g.Id, "", "");
foreach (var o in g.Options)
foreach (var c in o.Choices)
data.Add(g.Id, o.Key, c.Value);
}
return data;
}
[Theory]
[MemberData(nameof(AllOptionValues))]
public void Generated_values_are_valid(string generatorId, string optionKey, string optionValue)
{
var generator = Registry.Find(generatorId)!;
var options = new Dictionary<string, string?>();
if (optionKey != "")
options[optionKey] = optionValue;
var items = Registry.Generate(generator, 300, options, seed: 42);
Assert.All(items, item => Assert.False(string.IsNullOrWhiteSpace(item.Value)));
if (!generator.CanValidate)
return;
foreach (var item in items)
{
var result = generator.Validate(item.Value);
Assert.True(result.IsValid, $"{generatorId} gerou '{item.Value}' que não valida: {result.Message}");
}
}
[Fact]
public void Pessoa_fields_are_all_valid()
{
var pessoa = Registry.Find("pessoa")!;
foreach (var item in Registry.Generate(pessoa, 200, new Dictionary<string, string?>(), seed: 7))
{
var f = item.Fields!;
Assert.True(Registry.Find("nif")!.Validate(f["NIF"]).IsValid);
Assert.True(Registry.Find("niss")!.Validate(f["NISS"]).IsValid);
Assert.True(Registry.Find("cc")!.Validate(f["Cartão de Cidadão"]).IsValid);
Assert.True(Registry.Find("iban")!.Validate(f["IBAN"]).IsValid);
Assert.True(Registry.Find("telefone")!.Validate(f["Telemóvel"]).IsValid);
Assert.True(Registry.Find("email")!.Validate(f["Email"]).IsValid);
Assert.True(Registry.Find("codigo-postal")!.Validate(f["Código postal"]).IsValid);
var age = DateTime.UtcNow.Year - DateOnly.Parse(f["Data de nascimento"]).Year;
Assert.InRange(age, 18, 81);
}
}
[Fact]
public void Same_seed_gives_same_values()
{
var nif = Registry.Find("nif")!;
var a = Registry.Generate(nif, 20, new Dictionary<string, string?>(), seed: 123);
var b = Registry.Generate(nif, 20, new Dictionary<string, string?>(), seed: 123);
Assert.Equal(a.Select(x => x.Value), b.Select(x => x.Value));
}
[Fact]
public void Generated_values_are_not_all_equal()
{
var items = Registry.Generate(Registry.Find("nif")!, 50, new Dictionary<string, string?>());
Assert.True(items.Select(i => i.Value).Distinct().Count() > 40);
}
[Fact]
public void Invalid_option_value_throws()
{
var nif = Registry.Find("nif")!;
Assert.Throws<ArgumentException>(() =>
Registry.Generate(nif, 1, new Dictionary<string, string?> { ["tipo"] = "4" }));
}
[Theory]
[InlineData(0)]
[InlineData(GeneratorRegistry.MaxBatch + 1)]
public void Count_out_of_range_throws(int count)
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
Registry.Generate(Registry.Find("nif")!, count, new Dictionary<string, string?>()));
}
[Fact]
public void Ids_are_unique()
{
Assert.Equal(Registry.All.Count, Registry.All.Select(g => g.Id).Distinct().Count());
}
}

View File

@@ -1,19 +0,0 @@
using GeradoresService;
using GeradoresService.DAL;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace UnitTest
{
public class Geradores
{
private readonly GeradoresContext _context;
[Fact]
public void GenerateCC()
{
var cc = CartaoCidadao.Generate();
Console.WriteLine(cc);
}
}
}

View File

@@ -0,0 +1,168 @@
using GeradoresService;
namespace UnitTest;
/// <summary>Valores conhecidos (exemplos oficiais / números de teste públicos).</summary>
public class KnownValuesTests
{
private static readonly GeneratorRegistry Registry = new();
private static void AssertValid(string id, string value) =>
Assert.True(Registry.Find(id)!.Validate(value).IsValid, $"{id}: '{value}' devia ser válido.");
private static void AssertInvalid(string id, string value) =>
Assert.False(Registry.Find(id)!.Validate(value).IsValid, $"{id}: '{value}' devia ser inválido.");
[Theory]
[InlineData("123456789")]
[InlineData("PT 123 456 789")]
[InlineData("100000010")] // resto 0 → dígito 0 (falhava na versão antiga)
[InlineData("500000000")] // resto 1 → dígito 0
[InlineData("451234561")] // prefixo 45
public void Nif_valid(string nif) => AssertValid("nif", nif);
[Theory]
[InlineData("123456780")]
[InlineData("100000011")]
[InlineData("12345678")]
[InlineData("412345678")] // prefixo 4 só é válido como 45
[InlineData("abcdefghi")]
public void Nif_invalid(string nif) => AssertInvalid("nif", nif);
[Fact]
public void Nipc_rejects_individual_nif()
{
AssertValid("nipc", "500000000");
AssertInvalid("nipc", "123456789");
}
[Fact]
public void Nif_reports_expected_check_digit()
{
var result = Registry.Find("nif")!.Validate("123456780");
Assert.True(result.FormatMatch);
Assert.Contains("esperado 9", result.Message);
}
[Theory]
[InlineData("12345678902")]
[InlineData("20000000001")]
public void Niss_valid(string niss) => AssertValid("niss", niss);
[Theory]
[InlineData("12345678903")]
[InlineData("32345678902")]
[InlineData("1234567890")]
public void Niss_invalid(string niss) => AssertInvalid("niss", niss);
[Theory]
[InlineData("00000000 0 ZZ4")] // exemplo da especificação da AMA
[InlineData("000000000zz4")]
public void Cc_valid(string cc) => AssertValid("cc", cc);
[Theory]
[InlineData("00000000 0 ZZ5")]
[InlineData("00000000 0 ZZ")]
public void Cc_invalid(string cc) => AssertInvalid("cc", cc);
[Theory]
[InlineData("PT50000201231234567890154")] // exemplo do Banco de Portugal
[InlineData("PT50 0002 0123 1234 5678 9015 4")]
[InlineData("GB82 WEST 1234 5698 7654 32")]
[InlineData("DE89370400440532013000")]
public void Iban_valid(string iban) => AssertValid("iban", iban);
[Theory]
[InlineData("PT50000201231234567890155")]
[InlineData("PT51000201231234567890154")]
[InlineData("PT5000020123123456789015")]
[InlineData("GB82WEST12345698765433")]
public void Iban_invalid(string iban) => AssertInvalid("iban", iban);
[Fact]
public void Nib_valid() => AssertValid("nib", "0002 0123 12345678901 54");
[Theory]
[InlineData("4111 1111 1111 1111")]
[InlineData("5555555555554444")]
[InlineData("2223003122003222")]
[InlineData("378282246310005")]
public void Card_valid(string card) => AssertValid("cartao-credito", card);
[Theory]
[InlineData("4111111111111112")]
[InlineData("411111")]
public void Card_invalid(string card) => AssertInvalid("cartao-credito", card);
[Theory]
[InlineData("4006381333931")]
[InlineData("96385074")]
public void Ean_valid(string ean) => AssertValid("ean", ean);
[Fact]
public void Ean_invalid() => AssertInvalid("ean", "4006381333932");
[Theory]
[InlineData("AA-00-AA")]
[InlineData("00 AB 00")]
[InlineData("12-34-XZ")]
[InlineData("ab0012")]
public void Matricula_valid(string m) => AssertValid("matricula", m);
[Theory]
[InlineData("AK-00-AA")]
[InlineData("A1-00-AA")]
[InlineData("AA-00-AA-00")]
public void Matricula_invalid(string m) => AssertInvalid("matricula", m);
[Theory]
[InlineData("+351 912 345 678")]
[InlineData("00351213456789")]
[InlineData("800 123 456")]
public void Telefone_valid(string t) => AssertValid("telefone", t);
[Theory]
[InlineData("942 345 678")]
[InlineData("91234567")]
public void Telefone_invalid(string t) => AssertInvalid("telefone", t);
[Theory]
[InlineData("1000-001")]
[InlineData("4000 123")]
public void CodigoPostal_valid(string cp) => AssertValid("codigo-postal", cp);
[Theory]
[InlineData("0100-001")]
[InlineData("1000-01")]
public void CodigoPostal_invalid(string cp) => AssertInvalid("codigo-postal", cp);
[Theory]
[InlineData("joao.silva@example.com")]
public void Email_valid(string e) => AssertValid("email", e);
[Theory]
[InlineData("joao.silva")]
[InlineData("joao@silva")]
[InlineData("jo ao@example.com")]
public void Email_invalid(string e) => AssertInvalid("email", e);
[Fact]
public void Multibanco_roundtrip_detects_tampering()
{
var mb = Registry.Find("multibanco")!;
var value = mb.Generate(new GeradoresService.Core.GeneratorContext(new Random(1), new Dictionary<string, string?>())).Value;
Assert.True(mb.Validate(value).IsValid);
var parts = value.Split(' ');
var amount = decimal.Parse(parts[^1], System.Globalization.CultureInfo.InvariantCulture) + 1;
var tampered = string.Join(' ', parts[..^1]) + " " + amount.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
Assert.False(mb.Validate(tampered).IsValid);
}
[Fact]
public void Uuid_versions()
{
Assert.Contains("versão 4", Registry.Find("uuid")!.Validate("f47ac10b-58cc-4372-a567-0e02b2c3d479").Message);
AssertInvalid("uuid", "not-a-uuid");
}
}

20
UnitTest/README.md Normal file
View File

@@ -0,0 +1,20 @@
# UnitTest
Testes xUnit da biblioteca [GeradoresService](../GeradoresService/README.md) e da API
[GeradoresWS](../GeradoresWS/README.md).
```bash
dotnet test UnitTest
```
| Ficheiro | O que cobre |
|---|---|
| `GeneratorRoundTripTests.cs` | Para cada gerador e cada valor de cada opção, gera valores e confirma que o validador os aceita. Também: campos da pessoa fictícia válidos, `seed` reprodutível, ids únicos, opções e quantidades inválidas |
| `KnownValuesTests.cs` | Valores reais/conhecidos válidos e inválidos (NIF, NIPC, NISS, CC, IBAN, …) e o dígito de controlo esperado |
| `DetectTests.cs` | Validador livre: ordem dos resultados, quase-acertos (formato certo, controlo errado) e valores sem correspondência |
| `ApiTests.cs` | Testes de integração com `WebApplicationFactory<Program>`: listagem, geração em lote, 400/404, validação, detecção, OpenAPI e `/health` |
Os testes de ida e volta são gerados a partir do `GeneratorRegistry`, por isso um gerador novo fica coberto sem
escrever testes. Os valores conhecidos devem ser acrescentados à mão sempre que se corrige um bug num algoritmo.
O Jenkins corre `dotnet test UnitTest/UnitTest.csproj -c Release` antes de construir as imagens.

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@@ -10,34 +10,14 @@
</PropertyGroup>
<ItemGroup>
<None Remove="appsettings.Development.json" />
<None Remove="appsettings.json" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -45,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="..\GeradoresService\GeradoresService.csproj" />
<ProjectReference Include="..\GeradoresWS\GeradoresWS.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,13 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"GeradoresConnection": "server=127.0.0.1;uid=sa;pwd=mssqlAdmin!1;database=Geradores;TrustServerCertificate=True;"
}
}

View File

@@ -1,32 +1,30 @@
version: "3.8"
services:
geradoresws:
build:
context: . # Caminho para a pasta do Dockerfile do backend
context: .
dockerfile: Dockerfile
image: docker.io/shini89/geradoresws:latest
ports:
- "5050:8080"
- "5050:8080" # API directa (opcional)
environment:
- ASPNETCORE_ENVIRONMENT=Production
networks:
- app-network
environment:
- ASPNETCORE_ENVIRONMENT=Production # Variavel de ambiente para o backend
geradoresfe:
build:
context: ./geradoresfe # Caminho para a pasta do Dockerfile do frontend
dockerfile: Dockerfile
context: ./geradoresfe
dockerfile: Dockerfile
image: docker.io/shini89/geradoresfe:latest
ports:
- "3000:80"
depends_on:
- geradoresws
environment:
- VITE_API_URL=http://localhost:5050/
- API_UPSTREAM=http://geradoresws:8080
networks:
- app-network
networks:
app-network:
driver: bridge

View File

@@ -1 +1,2 @@
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 +1,63 @@
# React + TypeScript + Vite
# geradoresfe
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Frontend React 19 + Vite + Tailwind CSS 4 + TypeScript. A UI não conhece os tipos de gerador: lê
`/api/v1/generators` e constrói as páginas e os formulários de opções a partir dos metadados da
[API](../GeradoresWS/README.md).
Currently, two official plugins are available:
## Desenvolvimento
- [@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...
},
},
])
```bash
npm install
npm run dev # http://localhost:3000
```
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:
O Vite faz proxy de `/api`, `/docs` e `/openapi` para `http://localhost:5053`
(`dotnet run --project GeradoresWS --launch-profile http`). Para outro backend:
`API_PROXY_TARGET=http://host:porta npm run dev`.
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
| Script | Descrição |
|---|---|
| `npm run dev` | Servidor de desenvolvimento |
| `npm run build` | Type-check (`tsc -b`) e build de produção em `dist/` |
| `npm run lint` | ESLint |
| `npm test` | Vitest |
| `npm run preview` | Serve o build localmente |
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...
},
},
])
## Páginas
| Rota | Página |
|---|---|
| `/` | Lista de geradores por categoria |
| `/gerador/:id` | Opções, geração em lote (até 1000), cópia e exportação CSV/JSON/TXT |
| `/validar` | Validador livre: detecta o tipo e explica porque é (in)válido |
| `/NIF`, `/NISS`, `/CC` | Redireccionam para as rotas novas |
## Estrutura
| Caminho | Conteúdo |
|---|---|
| `src/lib/api.ts` | Cliente da API e tipos |
| `src/lib/useGenerators.tsx` | Contexto que carrega a lista de geradores uma vez |
| `src/lib/export.ts` | Exportação CSV/JSON/TXT (testado em `export.test.ts`) |
| `src/pages/` | Páginas das rotas |
| `src/components/` | Cartões, formulário de opções, validador, resultados |
| `src/components/ui/` | Componentes base (botão, input, select, badge, …) |
| `src/layout/` | Cabeçalho, rodapé e layout |
| `src/context/ThemeContext.tsx` | Modo claro/escuro |
## API
Por omissão o cliente usa caminhos relativos (`/api/v1`). Em dev é o proxy do Vite; em produção é o nginx.
`VITE_API_URL` (em build time) permite apontar para outro host.
## Docker
```bash
docker build -t geradoresfe .
docker run -p 3000:80 -e API_UPSTREAM=http://geradoresws:8080 geradoresfe
```
A imagem serve o build com nginx. O `nginx.conf.template` é processado com `envsubst` no arranque, e
`/api`, `/docs` e `/openapi` são encaminhados para `API_UPSTREAM` (por omissão `http://geradoresws:8080`).
A mesma imagem serve em qualquer ambiente sem rebuild. Os ficheiros em `/assets/` têm cache longa e o resto
cai no `index.html` (SPA).

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>

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 *));
: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">
<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="flex-grow mx-auto w-full">
{children}
</main>
<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`;

Some files were not shown because too many files have changed in this diff Show More