.
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-04 15:10:29 +02:00
40 changed files with 1873 additions and 1688 deletions
+5
View File
@@ -119,3 +119,8 @@ INVESTMOZ_TAX_RULE_APPROVERS=
INVESTMOZ_TAX_RULE_PUBLISHERS=
INVESTMOZ_ZONE_TERRITORY_REVIEWERS=
# Local LibreTranslate service and installed language models.
LIBRETRANSLATE_URL=http://127.0.0.1:5000
LIBRETRANSLATE_TIMEOUT=60
LIBRETRANSLATE_API_KEY=
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Throwable;
class TranslationApiController extends Controller
{
public function languages(): JsonResponse
{
try {
$response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->get($this->url().'/languages');
if ($response->successful() && is_array($response->json())) {
return response()->json($response->json())->header('Cache-Control', 'no-store');
}
} catch (Throwable) {
}
return response()->json(['message' => 'Serviço de tradução temporariamente indisponível.'], 503);
}
public function translate(Request $request): JsonResponse
{
$data = $request->validate([
'text' => 'required|string|max:10000',
'source_lang' => 'required|string|max:20',
'target_lang' => 'required|string|max:20',
'is_html' => 'boolean',
]);
if ($data['source_lang'] === $data['target_lang']) {
return response()->json(['original_text' => $data['text'], 'translated_text' => $data['text'], 'source_lang' => $data['source_lang'], 'target_lang' => $data['target_lang']])->header('Cache-Control', 'no-store');
}
$payload = ['q' => $data['text'], 'source' => $data['source_lang'], 'target' => $data['target_lang'], 'format' => ($data['is_html'] ?? false) ? 'html' : 'text'];
if (config('services.translation.api_key')) {
$payload['api_key'] = config('services.translation.api_key');
}
try {
$response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->post($this->url().'/translate', $payload);
$translated = $response->json('translatedText');
if ($response->successful() && is_string($translated) && $translated !== '') {
return response()->json(['original_text' => $data['text'], 'translated_text' => $translated, 'source_lang' => $data['source_lang'], 'target_lang' => $data['target_lang']])->header('Cache-Control', 'no-store');
}
} catch (Throwable) {
}
return response()->json(['message' => 'Serviço de tradução temporariamente indisponível.'], 503)->header('Cache-Control', 'no-store');
}
private function url(): string
{
return rtrim((string) config('services.translation.url'), '/');
}
}
@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Services\EditorialReading;
use App\Services\FederatedSearch;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use App\Services\SearchTerms;
use App\Services\SectorOrganization;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class GlobalSearchController extends Controller
{
public function __invoke(Request $request, InvestmentCatalogue $catalogue, SearchTerms $search, FederatedSearch $federated): Response
{
$input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1']]);
$term = trim($input['q'] ?? '');
$tokens = $search->tokens($term);
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
if ($tokens !== []) {
$factories = [
'Páginas e serviços' => function () use ($search, $tokens, $term) {
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)));
usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
?: strcmp($a['title'], $b['title']) ?: strcmp($a['url'], $b['url']));
return ['count' => count($pages), 'fetch' => fn ($offset, $limit) => array_slice($pages, $offset, $limit)];
},
'Oportunidades' => function () use ($catalogue, $search, $tokens, $term) {
$query = $catalogue->query(['include_national' => true]);
foreach ($tokens as $token) {
$query->where(function ($match) use ($catalogue, $search, $token) {
$search->orFields($match, ['opportunities.title', 'opportunities.summary', 'opportunities.description', 'opportunities.reference', 'opportunities.province'], $token);
$match->orWhereHas('sector', fn ($sector) => $search->constrain($sector, ['name'], [$token]));
$match->orWhereExists(fn ($location) => $location->selectRaw('1')->from('geo_locations as search_location')
->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id'))
->where(fn ($name) => $search->orFields($name, ['search_location.name'], $token))
->where(fn ($direct) => $direct->whereColumn('search_location.id', 'opportunities.primary_territory_id')
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('opportunity_locations as search_link')
->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereColumn('search_link.geo_location_id', 'search_location.id'))));
if (preg_match('/^[a-z0-9]+$/', $token)) {
$match->orWhereIn('opportunities.id', $catalogue->query(['q' => $token, 'include_national' => true])->reorder()->select('opportunities.id'));
}
});
}
return $this->source($query, $search, $term, $tokens, 'opportunities.title', 'opportunities.id',
fn ($r) => ['title' => $r->title, 'description' => $r->summary, 'url' => '/oportunidades/'.rawurlencode($r->slug)]);
},
'Negócios' => function () use ($search, $tokens, $term) {
$query = ExistingBusiness::query()->published();
$search->constrain($query, ['public_title', 'activity_code', 'objective'], $tokens);
return $this->source($query, $search, $term, $tokens, 'public_title', 'existing_businesses.id',
fn ($r) => ['title' => $r->public_title, 'description' => $r->objective, 'url' => '/negocios/'.rawurlencode($r->public_slug)]);
},
'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term) {
$query = InvestmentPortfolioController::visible();
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens);
return $this->source($query, $search, $term, $tokens, 'investment_dossiers.title', 'investment_dossiers.id',
fn ($r) => ['title' => $r->title, 'description' => $r->corridor_id ? 'Corredor' : ($r->cluster_id ? 'Cluster' : 'Projecto âncora'), 'url' => '/carteira/'.$r->id]);
},
'Documentos e legislação' => function () use ($search, $tokens, $term) {
$query = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
$search->constrain($query, ['r.title', 'r.description', 'r.document_number', 'd.issuer', 'r.source_reference', 'r.category', 'r.topic'], $tokens);
$query->select('r.id', 'r.title', 'r.description');
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
fn ($r) => ['title' => $r->title, 'description' => $r->description, 'url' => '/documentos/'.$r->id]);
},
'Artigos' => function () use ($search, $tokens, $term) {
if (! Schema::hasColumn('portal_resources', 'editorial_content')) {
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
}
$query = app(EditorialReading::class)->publicResources()
->whereRaw("nullif(btrim(r.editorial_content->>'body'), '') is not null");
$search->constrain($query, ['r.title', "(r.editorial_content->>'body')"], $tokens);
$query->select('r.id', 'r.title')->selectRaw("r.editorial_content->>'body' as body");
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
fn ($r) => ['title' => $r->title, 'description' => Str::limit(trim(strip_tags($r->body)), 220), 'url' => '/artigos/'.$r->id]);
},
'Sectores' => function () use ($search, $tokens, $term) {
$query = Sector::query()->where('is_active', true)->whereNotNull('published_at');
$search->constrain($query, ['name', 'description', 'short_description'], $tokens);
return $this->source($query, $search, $term, $tokens, 'name', 'sectors.id',
fn ($r) => ['title' => $r->name, 'description' => $r->short_description, 'url' => SectorOrganization::href($r->slug)]);
},
'Localizações' => function () use ($search, $tokens, $term) {
$query = app(InvestmentGeography::class)->publicReferences()
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality']);
$search->constrain($query, ['geo_locations.name', 'geo_locations.source_code'], $tokens);
$query->select('geo_locations.id', 'geo_locations.name', 'geo_locations.location_type');
return $this->source($query, $search, $term, $tokens, 'geo_locations.name', 'geo_locations.id',
fn ($r) => ['title' => $r->name, 'description' => ['province' => 'Província', 'district' => 'Distrito', 'admin_post' => 'Posto administrativo', 'locality' => 'Localidade'][$r->location_type],
'url' => '/oportunidades?'.http_build_query([$r->location_type.'_id' => $r->id])]);
},
'Zonas de investimento' => function () use ($search, $tokens, $term) {
$query = app(InvestmentZones::class)->visible();
$search->constrain($query, ['name', 'code'], $tokens);
return $this->source($query, $search, $term, $tokens, 'name', 'investment_zones.id',
fn ($r) => ['title' => $r->name, 'description' => $r->code, 'url' => '/zonas/'.$r->id]);
},
'Parcerias' => function () use ($search, $tokens, $term) {
$query = DB::table('portal_partnerships as p')->where('p.status', 'published');
$search->constrain($query, ['p.title', 'p.sector', 'p.province'], $tokens);
$query->select('p.id', 'p.title', 'p.sector', 'p.province');
return $this->source($query, $search, $term, $tokens, 'p.title', 'p.id',
fn ($r) => ['title' => $r->title, 'description' => implode(' · ', array_filter([$r->sector, $r->province])), 'url' => '/parcerias/'.$r->id]);
},
];
$results = $federated->page($factories, (int) ($input['page'] ?? 1), 5,
fn ($item) => $search->score($item['title'], $term, $tokens));
}
return Inertia::render('portal/search', ['query' => $term, 'results' => $results]);
}
private function source($query, SearchTerms $search, string $term, array $tokens, string $title, string $id, callable $present): array
{
$count = (clone $query)->reorder()->count();
$query->reorder();
$search->orderByRelevance($query, $title, $term, $tokens, $id);
return ['count' => $count, 'fetch' => fn ($offset, $limit) => (clone $query)->offset($offset)->limit($limit)->get()->map($present)->all()];
}
/** Public service entry points and terms used to find them. */
private function pages(): array
{
return [
['title' => 'Como investir em Moçambique', 'description' => 'Procedimentos e etapas para investir', 'url' => '/como-investir'],
['title' => 'Iniciar ou continuar investimento', 'description' => 'Preparar uma ideia e dossier de investimento', 'url' => '/investimentos/iniciar'],
['title' => 'Oportunidades de investimento', 'description' => 'Pesquisar por sector, localização e maturidade', 'url' => '/oportunidades'],
['title' => 'Carteira de investimento', 'description' => 'Projectos âncora, clusters e corredores', 'url' => '/carteira'],
['title' => 'Negócios existentes', 'description' => 'Compra, venda e participação em negócios', 'url' => '/negocios'],
['title' => 'Sectores de investimento', 'description' => 'Actividades económicas e sectores prioritários', 'url' => '/sectores'],
['title' => 'Zonas de investimento', 'description' => 'Zonas económicas especiais, ZEE, ZFI e parques industriais', 'url' => '/zonas'],
['title' => 'Contextos de investimento', 'description' => 'Formas de iniciar e preparar um investimento', 'url' => '/contextos-de-investimento'],
['title' => 'Documentos e formulários', 'description' => 'Acervo documental e formulários para investidores e empresas', 'url' => '/documentos'],
['title' => 'Formulário do investidor', 'description' => 'Dados e contacto para investir', 'url' => '/formularios/investidor'],
['title' => 'Formulário de empresa', 'description' => 'Dados e contacto para empresas', 'url' => '/formularios/empresa'],
['title' => 'Legislação e incentivos', 'description' => 'Leis, decretos, benefícios e enquadramento legal', 'url' => '/legislacao'],
['title' => 'Simulador fiscal', 'description' => 'Simular impostos e cenários fiscais de investimento', 'url' => '/simulador-fiscal'],
['title' => 'Formulários fiscais', 'description' => 'Preparação dos formulários IVA e IRPC', 'url' => '/formularios/fiscais'],
['title' => 'Terra e DUAT', 'description' => 'Apoio ao uso e aproveitamento da terra', 'url' => '/terra-duat'],
['title' => 'Bolsa de parcerias', 'description' => 'Encontrar parceiros e propostas', 'url' => '/parcerias'],
['title' => 'Apoio ao investidor', 'description' => 'Contactos e pedido de apoio', 'url' => '/apoio'],
['title' => 'Educação para investimento', 'description' => 'Conteúdos educativos e orientação', 'url' => '/educacao'],
['title' => 'Sobre o InvesteMoz', 'description' => 'Informação institucional do portal', 'url' => '/sobre'],
['title' => 'Contacto', 'description' => 'Contactar o portal InvesteMoz', 'url' => '/contacto'],
['title' => 'Política de privacidade', 'description' => 'Privacidade e tratamento de dados pessoais', 'url' => '/politica-de-privacidade'],
['title' => 'Termos de uso', 'description' => 'Condições de utilização do portal', 'url' => '/termos-de-uso'],
];
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Services;
use Illuminate\Database\QueryException;
/** Combines independent, already-authorised sources without loading complete catalogues. */
final class FederatedSearch
{
/** @param array<string, callable(): array{count:int, fetch:callable(int,int): iterable}> $factories */
public function page(array $factories, int $requestedPage, int $size = 5, ?callable $score = null): array
{
$sources = [];
$unavailable = [];
foreach ($factories as $label => $factory) {
try {
$sources[$label] = $factory();
} catch (QueryException $exception) {
report($exception);
$unavailable[] = $label;
}
}
// If a source fails after its count, remove it and recompute the page offsets.
do {
$failed = false;
$total = array_sum(array_column($sources, 'count'));
$last = max(1, (int) ceil($total / $size));
$page = min(max(1, $requestedPage), $last);
$first = ($page - 1) * $size;
$items = [];
$positions = array_fill_keys(array_keys($sources), 0);
$buffers = array_fill_keys(array_keys($sources), []);
for ($position = 0; $position < min($total, $first + $size); $position++) {
$best = null;
$bestScore = PHP_INT_MIN;
foreach ($sources as $label => $source) {
if ($buffers[$label] === [] && $positions[$label] < $source['count']) {
try {
$batch = iterator_to_array((function () use ($source, $positions, $label, $size) {
yield from ($source['fetch'])($positions[$label], $size);
})(), false);
$positions[$label] += count($batch);
$buffers[$label] = $batch;
if ($batch === []) {
$positions[$label] = $source['count'];
}
} catch (QueryException $exception) {
report($exception);
$unavailable[] = $label;
unset($sources[$label]);
$failed = true;
break;
}
}
if ($buffers[$label] === []) {
continue;
}
$candidateScore = $score ? $score($buffers[$label][0]) : 0;
if ($best === null || $candidateScore > $bestScore) {
$best = $label;
$bestScore = $candidateScore;
}
}
if ($failed || $best === null) {
break;
}
$item = array_shift($buffers[$best]);
if ($position >= $first) {
$items[] = [...$item, 'category' => $best];
}
}
} while ($failed);
return ['data' => $items, 'total' => $total, 'current_page' => $page, 'last_page' => $last,
'per_page' => $size, 'unavailable' => $unavailable];
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
/** Text matching shared by the database and the built-in page catalogue. */
final class SearchTerms
{
/** @return list<string> */
public function tokens(string $query): array
{
$normalized = mb_strtolower(Str::ascii($query));
$tokens = array_values(array_filter(array_unique(array_map(
fn ($word) => trim($word, ".,;:!?\"'()[]{}"),
preg_split('/\s+/u', trim($normalized), -1, PREG_SPLIT_NO_EMPTY) ?: []
)), fn ($word) => $word !== ''));
if (count($tokens) > 1) {
$meaningful = array_values(array_diff($tokens, ['a', 'as', 'o', 'os', 'de', 'do', 'da', 'dos', 'das', 'em', 'no', 'na', 'nos', 'nas', 'para', 'por', 'e', 'como', 'qual', 'quais', 'um', 'uma']));
return $meaningful === [] ? $tokens : $meaningful;
}
return $tokens;
}
/** Known Portuguese spelling and number variants; do not infer semantic equivalence. */
public function variants(string $token): array
{
foreach ([['sector', 'sectores', 'setor', 'setores'], ['projecto', 'projectos', 'projeto', 'projetos'],
['investir', 'investimento', 'investimentos'], ['investidor', 'investidores'], ['oportunidade', 'oportunidades'], ['documento', 'documentos'],
['empresa', 'empresas'], ['parceria', 'parcerias'], ['zona', 'zonas'], ['formulario', 'formularios'],
['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas']] as $group) {
if (in_array($token, $group, true)) {
return $group;
}
}
return [$token];
}
public function pattern(string $token): string
{
return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%';
}
/** Columns are trusted application constants, never user input. */
public function orFields($query, array $columns, string $token): void
{
foreach ($columns as $column) {
foreach ($this->variants($token) as $variant) {
$query->orWhereRaw($this->normalizedColumn($column)." LIKE ? ESCAPE '!'", [$this->pattern($variant)]);
}
}
}
public function constrain($query, array $columns, array $tokens): void
{
foreach ($tokens as $token) {
$query->where(fn ($match) => $this->orFields($match, $columns, $token));
}
}
public function matches(string $text, array $tokens): bool
{
$text = mb_strtolower(Str::ascii($text));
foreach ($tokens as $token) {
if (! array_filter($this->variants($token), fn ($variant) => str_contains($text, $variant))) {
return false;
}
}
return true;
}
private function normalizedColumn(string $column): string
{
return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')";
}
public function score(string $title, string $phrase, array $tokens): int
{
$title = mb_strtolower(Str::ascii($title));
$phrase = mb_strtolower(Str::ascii(trim($phrase)));
$score = $title === $phrase ? 1000 : (str_contains($title, $phrase) ? 400 : 0);
foreach ($tokens as $token) {
if ($this->matches($title, [$token])) {
$score += 20;
}
}
return $score;
}
/** The same title score used for in-memory pages, with bound search terms. */
public function orderByRelevance($query, string $titleColumn, string $phrase, array $tokens, string $tieColumn): void
{
$column = $this->normalizedColumn($titleColumn);
$phrase = mb_strtolower(Str::ascii(trim($phrase)));
$sql = "CASE WHEN {$column} = ? THEN 1000 WHEN {$column} LIKE ? ESCAPE '!' THEN 400 ELSE 0 END";
$bindings = [$phrase, $this->pattern($phrase)];
foreach ($tokens as $token) {
$conditions = [];
foreach ($this->variants($token) as $variant) {
$conditions[] = "{$column} LIKE ? ESCAPE '!'";
$bindings[] = $this->pattern($variant);
}
$sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 20 ELSE 0 END';
}
$query->orderByRaw('('.$sql.') DESC', $bindings)->orderBy($titleColumn)->orderBy($tieColumn);
}
}
+6
View File
@@ -28,6 +28,12 @@ return [
|
*/
'translation' => [
'url' => env('LIBRETRANSLATE_URL', 'http://127.0.0.1:5000'),
'timeout' => (int) env('LIBRETRANSLATE_TIMEOUT', 60),
'api_key' => env('LIBRETRANSLATE_API_KEY'),
],
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,17 @@
# Roteiro executável de interface — I0–I3 QA
## Tradução transversal — levantamento e plano, 02/11/2026
O pedido actual determina a tradução nas quatro línguas já apresentadas no selector: português, inglês, francês e espanhol. A referência anterior MASTER-9dee08d0-0008 prevê pelo menos português e inglês; o pedido actual explicita as quatro línguas já expostas. O levantamento é de código, não comprova a situação no ambiente activo nem substitui os recibos de reposição ou a comparação com o último estado demonstrado.
- Existe um contexto de idioma persistido no navegador e um dicionário em `tempo-brand.tsx`; a sua cobertura anterior concentrava-se no menu e rodapé. A tradução devolve o original quando não encontra uma chave. Os componentes institucionais `Trans` recorrem ao mesmo dicionário, mas várias das suas frases não têm entrada.
- Há 125 ficheiros em `resources/js/pages` (incluindo ficheiros auxiliares): páginas públicas, autenticação, gestão, oportunidades, carteira, percurso, dossier, simulador, terra, parcerias e outras áreas contêm textos portugueses directos. Formulários e componentes partilhados em `resources/js/lib` e `resources/js/components` também contêm rótulos e mensagens directos. A IA possui selector e textos próprios em português/inglês, sem integração completa com o idioma global. Catálogos e questionários de API têm pontos fixados em `pt-MZ`; mensagens de validação Laravel estão em português. Documentos, dados cadastrados, fontes e exportações precisam de tratamento distinto da interface e preservação do original.
- Incremento de código preparado: idioma do documento HTML acompanha o selector; textos comuns do acesso ao conteúdo, caminho de navegação, aviso de homologação, contacto de apoio e selector passam pelo contexto; algumas etiquetas institucionais passam a ter entradas nas três línguas. Os títulos sem entrada continuam no original. Esta cobertura é parcial, não representa conclusão do requisito nem publicação no activo.
- Continuação: etiquetas e placeholders de acesso e registo, apresentação do respectivo título, controlo acessível de visualização da palavra-passe e duas mensagens exactas de autenticação usam o mesmo contexto. Mensagens variáveis, restantes validações, outros ecrãs de autenticação e conteúdos de QA continuam por traduzir. Valores submetidos e comportamento de autenticação não foram alterados. O `types:check` e o build do primeiro incremento passaram; este acréscimo requer nova verificação.
- Comparação com `D:\Projects\revistatempo` e `D:\Projects\Tempo_Investidor\tempo-api`: a Revista usa componentes/hooks explicitamente marcados, POST `/api/translate`, protecção de tokens e cache; a API chama LibreTranslate e devolve o original em caso de falha, inclusive com HTTP 200. Não se demonstrou cobertura integral automática na Revista. Adaptado o cliente para os textos institucionais públicos já marcados com `Trans`, com URL configurável por `VITE_TRANSLATION_API_URL`, preservação de tokens, deduplicação e cache em memória apenas para respostas diferentes do original. Este incremento não envia dados de formulários, dossiers ou outras informações privadas para o serviço. A conectividade real, cobertura restante, resultados e exportações continuam pendentes de verificação e implementação; não declarar a plataforma traduzida.
Sequência restante: (1) inventariar por rota e componente, comparando com o último estado demonstrado e reutilizando provas e recibos existentes; (2) consolidar catálogo versionado e detecção de chaves ausentes para as quatro línguas; (3) migrar navegação e páginas públicas, autenticação e formulários; (4) migrar oportunidades, carteira, percurso adaptativo, dossier, simulador e áreas privadas/gestão sem alterar regras nem dados; (5) tratar mensagens do servidor, catálogos, conteúdos cadastrados e relatórios, preservando origens e indicando quando não existe versão traduzida; (6) verificar alternância, persistência, regressão dos fluxos, acessibilidade e exportações em QA isolado e registar cobertura por requisito nesta matriz e neste manual. Traduções de documentos oficiais não substituem os originais.
## C37 — causa confirmada; seleção de conta QA corrigida — P0, visual pendente
O pedido de reabertura referia a conta esperada18. A captura manual21:22–21:24UTC demonstra que foi selecionada a conta13 restaurada. O MFA foi alcançado, mas a autenticação nunca se completou. Causa concreta: credenciais MFA da conta13 cifradas pela chave de origem, incompatíveis com a chave estávelQA; a conta18 sintética não foi desafiada nessa tentativa. Nenhuma chave, MFA ou conta13 foi alterada para contornar o problema.
+3 -1
View File
@@ -1,17 +1,19 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
import { useTempoUi } from '@/components/tempo-brand';
export default function InputError({
message,
className = '',
...props
}: HTMLAttributes<HTMLParagraphElement> & { message?: string }) {
const { t } = useTempoUi();
return message ? (
<p
{...props}
className={cn('text-sm text-red-600 dark:text-red-400', className)}
>
{message}
{t(message)}
</p>
) : null;
}
@@ -1,7 +1,54 @@
import { useEffect, useState } from 'react';
import { useTempoUi } from '@/components/tempo-brand';
import { translateText } from '@/translation/service';
import { protectTranslationTokens, restoreTranslationTokens } from '@/translation/utils';
// Bridge the original Trans calls to this platform's existing language context.
export function Trans({ children }: { children: string }) {
const { t } = useTempoUi();
return <span>{t(children)}</span>;
// Only the fixed public institutional texts using this component are sent to Tempo.
const cache = new Map<string, string>();
const pending = new Map<string, Promise<string>>();
function translateInstitutionalText(text: string, language: string): Promise<string> {
const key = JSON.stringify([language, text]);
const cached = cache.get(key);
if (cached) return Promise.resolve(cached);
const existing = pending.get(key);
if (existing) return existing;
const protectedText = protectTranslationTokens(text);
const request = translateText({
text: protectedText.text,
sourceLanguage: 'pt',
targetLanguage: language,
}).then(({ translatedText }) => {
// Do not accept a response that lost an identifier, URL or protected term.
for (const token of protectedText.replacements.keys()) {
if (!translatedText.includes(token)) throw new Error('Translation lost a protected token');
}
const result = restoreTranslationTokens(translatedText, protectedText.replacements);
// Tempo's API returns the input on upstream failure: do not cache it as success.
if (result !== text) cache.set(key, result);
return result;
}).finally(() => pending.delete(key));
pending.set(key, request);
return request;
}
export function Trans({ children }: { children: string }) {
const { language, t } = useTempoUi();
const localText = t(children);
const [result, setResult] = useState<{ key: string; text: string } | null>(null);
const key = JSON.stringify([language, children]);
useEffect(() => {
if (language === 'pt' || localText !== children || !children.trim()) return;
let active = true;
translateInstitutionalText(children, language).then((text) => {
if (active) setResult({ key, text });
}).catch(() => {
if (active) setResult(null);
});
return () => { active = false; };
}, [children, key, language, localText]);
return <span data-no-translate>{result?.key === key ? result.text : localText}</span>;
}
+3 -5
View File
@@ -3,6 +3,7 @@ import type { ComponentProps, Ref } from 'react';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { useTempoUi } from '@/components/tempo-brand';
export default function PasswordInput({
className,
@@ -10,6 +11,7 @@ export default function PasswordInput({
...props
}: Omit<ComponentProps<'input'>, 'type'> & { ref?: Ref<HTMLInputElement> }) {
const [showPassword, setShowPassword] = useState(false);
const { t } = useTempoUi();
return (
<div className="relative">
@@ -23,11 +25,7 @@ export default function PasswordInput({
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:ring-[3px] focus-visible:outline-none"
aria-label={
showPassword
? 'Ocultar palavra-passe'
: 'Mostrar palavra-passe'
}
aria-label={showPassword ? 'Ocultar palavra-passe' : 'Mostrar palavra-passe'}
tabIndex={-1}
>
{showPassword ? (
+3 -3
View File
@@ -241,14 +241,14 @@ export default function PortalHero({
});
}}
role="search"
aria-label="Pesquisar oportunidades"
aria-label="Pesquisar no InvesteMoz"
className="mt-7 w-full max-w-4xl"
>
<label
htmlFor="portal-opportunity-search"
className="mb-2 block text-left text-sm font-medium text-white/90"
>
Pesquisa rápida de oportunidades
Pesquisa rápida no InvesteMoz
</label>
<div className="flex min-w-0 flex-col gap-2 rounded-2xl border border-white/30 bg-white p-1.5 shadow-2xl shadow-black/20 focus-within:ring-2 focus-within:ring-white focus-within:ring-offset-4 focus-within:ring-offset-[#101820] min-[400px]:flex-row">
<input
@@ -259,7 +259,7 @@ export default function PortalHero({
aria-describedby="portal-opportunity-search-help"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Ex.: agronegócio, Nampula, energia..."
placeholder="Ex.: agronegócio, Nampula, legislação..."
className="h-12 w-full min-w-0 flex-1 rounded-xl border-0 bg-white px-4 text-base text-slate-900 outline-none placeholder:text-slate-500"
/>
<button
+9
View File
@@ -0,0 +1,9 @@
import { createContext, useContext } from 'react';
import { PortalGroupedMenu } from '@/components/portal-grouped-menu';
export const MenuPlacement = createContext(false);
export function PortalMenu({ primary = false }: { primary?: boolean }) {
const placed = useContext(MenuPlacement);
return placed && !primary ? null : <PortalGroupedMenu />;
}
+47 -1
View File
@@ -241,6 +241,48 @@ const translations: Record<string, Record<string, string>> = {
},
};
const sharedTranslations: Record<string, [string, string, string]> = {
'Criar conta': ['Create account', 'Créer un compte', 'Crear cuenta'],
'Entrar na sua conta': ['Sign in to your account', 'Connectez-vous à votre compte', 'Inicie sesión en su cuenta'],
'Crie uma conta simples para continuar e acompanhar a sua operação. Não é necessário configurar MFA para participar.': ['Create an account to continue and follow your operation. You do not need to set up MFA to participate.', 'Créez un compte pour poursuivre et suivre votre opération. La configuration de l’authentification multifacteur n’est pas nécessaire pour participer.', 'Cree una cuenta para continuar y seguir su operación. No necesita configurar la autenticación multifactor para participar.'],
'Entre para continuar a operação que escolheu. Os dados preenchidos permanecem guardados neste navegador.': ['Sign in to continue the operation you chose. Your entered data remains saved in this browser.', 'Connectez-vous pour poursuivre l’opération choisie. Les données saisies restent enregistrées dans ce navigateur.', 'Inicie sesión para continuar la operación elegida. Los datos introducidos permanecen guardados en este navegador.'],
'Palavra-passe': ['Password', 'Mot de passe', 'Contraseña'],
'Mostrar palavra-passe': ['Show password', 'Afficher le mot de passe', 'Mostrar contraseña'],
'Ocultar palavra-passe': ['Hide password', 'Masquer le mot de passe', 'Ocultar contraseña'],
'Confirmar palavra-passe': ['Confirm password', 'Confirmer le mot de passe', 'Confirmar contraseña'],
'Nome': ['Name', 'Nom', 'Nombre'],
'Nome completo': ['Full name', 'Nom complet', 'Nombre completo'],
'Esqueceu a palavra-passe?': ['Forgot your password?', 'Mot de passe oublié ?', '¿Olvidó su contraseña?'],
'Manter sessão iniciada': ['Keep me signed in', 'Rester connecté', 'Mantener la sesión iniciada'],
'Ainda não tem conta?': ['Don’t have an account?', 'Vous n’avez pas de compte ?', '¿Aún no tiene cuenta?'],
'Já tem conta?': ['Already have an account?', 'Vous avez déjà un compte ?', '¿Ya tiene cuenta?'],
'Entrar com chave de acesso': ['Sign in with a passkey', 'Se connecter avec une clé d’accès', 'Iniciar sesión con clave de acceso'],
'A autenticar…': ['Signing in…', 'Connexion en cours…', 'Iniciando sesión…'],
'Ou continuar com email': ['Or continue with email', 'Ou continuer avec l’e-mail', 'O continuar con correo electrónico'],
'O email ou a palavra-passe não correspondem aos nossos registos.': ['The email or password does not match our records.', 'L’adresse e-mail ou le mot de passe ne correspond pas à nos informations.', 'El correo electrónico o la contraseña no coinciden con nuestros registros.'],
'A palavra-passe está incorrecta.': ['The password is incorrect.', 'Le mot de passe est incorrect.', 'La contraseña es incorrecta.'],
'Atalho para o conteúdo': ['Skip link', 'Lien d’accès rapide', 'Enlace de acceso rápido'],
'Saltar para o conteúdo': ['Skip to content', 'Aller au contenu', 'Ir al contenido'],
'Caminho de navegação': ['Breadcrumb', 'Fil d’Ariane', 'Ruta de navegación'],
'Contacto de apoio ao investidor': ['Investor support contact', 'Contact de l’assistance aux investisseurs', 'Contacto de apoyo al inversor'],
'Contactar Apoio ao Investidor': ['Contact investor support', 'Contacter l’assistance aux investisseurs', 'Contactar con apoyo al inversor'],
'Ambiente de homologação': ['Review environment', 'Environnement de validation', 'Entorno de validación'],
'Homologação reservada à revisão da Tempo. Consulte o conteúdo público; entre na sua conta quando iniciar uma operação.': ['This review environment is reserved for Tempo. Browse public content; sign in when starting an operation.', 'Cet environnement de validation est réservé à Tempo. Consultez le contenu public ; connectez-vous pour commencer une opération.', 'Este entorno de validación está reservado para Tempo. Consulte el contenido público; inicie sesión al comenzar una operación.'],
'Idioma da navegação': ['Navigation language', 'Langue de navigation', 'Idioma de navegación'],
'Navegação institucional': ['Language', 'Langue', 'Idioma'],
'Valores': ['Values', 'Valeurs', 'Valores'],
'Ver menos': ['Show less', 'Voir moins', 'Ver menos'],
'Ver mais': ['Show more', 'Voir plus', 'Ver más'],
'Voltar': ['Back', 'Retour', 'Volver'],
'Anos de Experiência': ['Years of experience', 'Années d’expérience', 'Años de experiencia'],
'Projetos Realizados': ['Projects completed', 'Projets réalisés', 'Proyectos realizados'],
};
for (const [source, values] of Object.entries(sharedTranslations)) {
(['en', 'fr', 'es'] as const).forEach((locale, index) => {
translations[locale][source] = values[index];
});
}
const TempoUi = createContext({
language: 'pt',
setLanguage: (_language: string) => {},
@@ -255,13 +297,17 @@ export function TempoUiProvider({ children }: { children: ReactNode }) {
useEffect(() => {
try {
const saved = localStorage.getItem('tempo-navigation-language');
if (saved && ['pt', 'en', 'fr', 'es'].includes(saved))
if (saved && /^[a-zA-Z][a-zA-Z0-9-]{0,19}$/.test(saved))
setCurrentLanguage(saved);
} catch {
/* Navigation remains usable without browser storage. */
}
}, []);
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
const setLanguage = (next: string) => {
if (!/^[a-zA-Z][a-zA-Z0-9-]{0,19}$/.test(next)) return;
setCurrentLanguage(next);
try {
localStorage.setItem('tempo-navigation-language', next);
+24 -9
View File
@@ -8,6 +8,9 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTempoUi } from '@/components/tempo-brand';
import { getLanguages } from '@/translation/service';
import { withPortugueseFirst } from '@/translation/utils';
import type { TranslationLanguage } from '@/translation/types';
type Weather = { temp: number; description: string; humidity?: number };
@@ -97,12 +100,28 @@ export function TempoWeather() {
}
export function TempoLanguageSelector() {
const { language, setLanguage } = useTempoUi();
const { language, setLanguage, t } = useTempoUi();
const [languages, setLanguages] = useState<TranslationLanguage[]>([
{ code: 'pt', name: 'Português' },
{ code: 'en', name: 'English' },
{ code: 'fr', name: 'Français' },
{ code: 'es', name: 'Español' },
]);
const [available, setAvailable] = useState(true);
useEffect(() => {
const controller = new AbortController();
getLanguages(controller.signal).then((items) => {
const portuguese = items.find((item) => item.code === 'pt');
if (!portuguese) throw new Error('Portuguese model unavailable');
setLanguages(withPortugueseFirst(items.filter((item) => item.code === 'pt' || !portuguese.targets || portuguese.targets.includes(item.code))));
}).catch(() => { if (!controller.signal.aborted) setAvailable(false); });
return () => controller.abort();
}, []);
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-utility-button"
aria-label="Idioma da navegação"
aria-label={t('Idioma da navegação')}
>
<Globe size={16} aria-hidden="true" />
<span className="text-xs font-semibold uppercase">
@@ -116,14 +135,10 @@ export function TempoLanguageSelector() {
className="tempo-dropdown min-w-44"
>
<DropdownMenuLabel className="text-xs text-slate-500">
Navegação institucional
{t('Navegação institucional')}
</DropdownMenuLabel>
{[
['pt', 'Português'],
['en', 'English'],
['fr', 'Français'],
['es', 'Español'],
].map(([code, name]) => (
{!available && <DropdownMenuLabel className="text-xs text-slate-500">Tradução temporariamente indisponível</DropdownMenuLabel>}
{languages.map(({ code, name }) => (
<DropdownMenuItem
key={code}
onSelect={() => setLanguage(code)}
@@ -3,7 +3,7 @@ import { AppShell } from '@/components/app-shell';
import { AppSidebar } from '@/components/app-sidebar';
import { AppSidebarHeader } from '@/components/app-sidebar-header';
import { NavigationContextSummary } from '@/components/navigation-context';
import { PortalMenu } from '@/layouts/portal-navigation';
import { PortalMenu } from '@/components/portal-menu';
import type { AppLayoutProps } from '@/types';
export default function AppSidebarLayout({
+3 -1
View File
@@ -1,4 +1,5 @@
import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout';
import { useTempoUi } from '@/components/tempo-brand';
export default function AuthLayout({
title = '',
@@ -9,8 +10,9 @@ export default function AuthLayout({
description?: string;
children: React.ReactNode;
}) {
const { t } = useTempoUi();
return (
<AuthLayoutTemplate title={title} description={description}>
<AuthLayoutTemplate title={t(title)} description={t(description)}>
{children}
</AuthLayoutTemplate>
);
+18 -26
View File
@@ -1,9 +1,9 @@
import SearchFeedback from '@/components/search-feedback';
import { searchTerritoryHref } from '@/lib/search-territory';
import { Link, usePage } from '@inertiajs/react';
import { createContext, useContext } from 'react';
import { PortalGroupedMenu } from '@/components/portal-grouped-menu';
import { TempoUiProvider } from '@/components/tempo-brand';
import { MenuPlacement, PortalMenu } from '@/components/portal-menu';
import { TempoUiProvider, useTempoUi } from '@/components/tempo-brand';
import { PageTranslator } from '@/translation/page-translator';
import { TempoFooter } from '@/components/tempo-footer';
import type { ReactNode } from 'react';
import {
@@ -11,8 +11,6 @@ import {
NavigationContextSummary,
} from '@/components/navigation-context';
const MenuPlacement = createContext(false);
export const portalLinks = [
['/', 'Início'],
['/oportunidades', 'Oportunidades de Investimento'],
@@ -67,6 +65,11 @@ export default function PortalNavigation({
}: {
children: ReactNode;
}) {
return <TempoUiProvider><PageTranslator /><PortalNavigationContent>{children}</PortalNavigationContent></TempoUiProvider>;
}
function PortalNavigationContent({ children }: { children: ReactNode }) {
const { t } = useTempoUi();
const { url, props, component } = usePage();
const printable = component === 'land-assistance/print';
const portalShell =
@@ -108,29 +111,23 @@ export default function PortalNavigation({
? 'Definições da conta'
: 'Detalhes');
return (
<TempoUiProvider>
<NavigationContextProvider>
<MenuPlacement.Provider value={true}>
<div className="min-h-screen [&_button]:scroll-mb-24 [&_input]:scroll-mb-24 [&_select]:scroll-mb-24 [&_textarea]:scroll-mb-24">
<nav
aria-label="Atalho para o conteúdo"
aria-label={t('Atalho para o conteúdo')}
className={printable ? 'print:hidden' : undefined}
>
<a
href="#portal-content"
className="sr-only focus:not-sr-only focus:block focus:bg-white focus:p-4 focus:text-emerald-900"
>
Saltar para o conteúdo
{t('Saltar para o conteúdo')}
</a>
</nav>
{props.homologationReserved === true && (
<aside
aria-label="Ambiente de homologação"
className="border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-950 print:hidden"
>
Homologação reservada à revisão da Tempo.
Consulte o conteúdo público; entre na sua conta
quando iniciar uma operação.
<aside aria-label="Ambiente de homologação" className="border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-950 print:hidden">
Homologação reservada à revisão da Tempo. Consulte o conteúdo público; entre na sua conta quando iniciar uma operação.
</aside>
)}
{portalShell && (
@@ -144,12 +141,12 @@ export default function PortalNavigation({
)}
{path !== '/' && (
<nav
aria-label="Caminho de navegação"
aria-label={t('Caminho de navegação')}
className={`bg-background text-foreground flex flex-wrap items-center gap-2 border-b px-5 py-3 text-sm ${printable ? 'print:hidden' : ''}`}
>
<>
<Link href="/" className="underline">
Início
{t('Início')}
</Link>
<span aria-hidden="true">/</span>
{parent && (
@@ -161,12 +158,12 @@ export default function PortalNavigation({
)}
className="underline"
>
{names[parent]}
{t(names[parent])}
</Link>
<span aria-hidden="true">/</span>
</>
)}
<span aria-current="page">{title}</span>
<span aria-current="page">{t(title)}</span>
</>
</nav>
)}
@@ -184,14 +181,14 @@ export default function PortalNavigation({
{children}
</div>
<aside
aria-label="Contacto de apoio ao investidor"
aria-label={t('Contacto de apoio ao investidor')}
className={`mx-auto flex max-w-6xl justify-center px-5 pt-4 ${printable ? 'print:hidden' : ''}`}
>
<a
href="/apoio"
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-lg bg-[#ed1c24] px-4 py-2 text-center text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]"
>
Contactar Apoio ao Investidor
{t('Contactar Apoio ao Investidor')}
</a>
</aside>
<div className={printable ? 'print:hidden' : undefined}>
@@ -200,11 +197,6 @@ export default function PortalNavigation({
</div>
</MenuPlacement.Provider>
</NavigationContextProvider>
</TempoUiProvider>
);
}
export function PortalMenu({ primary = false }: { primary?: boolean }) {
const placed = useContext(MenuPlacement);
return placed && !primary ? null : <PortalGroupedMenu />;
}
+6 -1
View File
@@ -11,7 +11,7 @@ import SearchState from '@/components/search-state';
import { useInterestDraft } from '@/hooks/use-interest-draft';
import { createContext, useContext, useRef, useState } from 'react';
import { router } from '@inertiajs/react';
import { PortalMenu } from '@/layouts/portal-navigation';
import { PortalMenu } from '@/components/portal-menu';
import { ProvinceSelect } from '@/components/investment-territory';
import { MATURITY_LEVELS } from '@/lib/investment-maturity';
import CountrySelect, { type Country } from '@/components/country-select';
@@ -971,6 +971,11 @@ export default function PortalInvestidorApp({
router.get('/oportunidades');
}
function handleHeroSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
router.get('/oportunidades', { q: heroQuery.trim() });
}
function handleView(opportunity: Opportunity) {
router.get('/oportunidades/' + encodeURIComponent(opportunity.slug));
}
+13 -11
View File
@@ -1,4 +1,5 @@
import { participationIdentity } from '@/lib/participation-identity';
import { useTempoUi } from '@/components/tempo-brand';
import { Form, Head, usePage } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
@@ -24,11 +25,12 @@ type Props = {
};
export default function Login({ status, canResetPassword, qaAccount }: Props) {
const { t } = useTempoUi();
// A redirect from another page (e.g. MFA) creates a new Form with empty local errors.
const pageErrors = usePage<{ errors: { email?: string } }>().props.errors;
return (
<>
<Head title="Entrar" />
<Head title={t('Entrar')} />
{qaAccount && (
<aside
@@ -48,9 +50,9 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
{/* @chisel-passkeys */}
<PasskeyVerify
label="Entrar com chave de acesso"
loadingLabel="A autenticar…"
separator="Ou continuar com email"
label={t('Entrar com chave de acesso')}
loadingLabel={t('A autenticar…')}
separator={t('Ou continuar com email')}
/>
{/* @end-chisel-passkeys */}
@@ -85,7 +87,7 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">
Palavra-passe
{t('Palavra-passe')}
</Label>
{canResetPassword && (
<TextLink
@@ -93,7 +95,7 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
className="ml-auto text-sm"
tabIndex={5}
>
Esqueceu a palavra-passe?
{t('Esqueceu a palavra-passe?')}
</TextLink>
)}
</div>
@@ -103,7 +105,7 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
required
tabIndex={2}
autoComplete="current-password"
placeholder="Palavra-passe"
placeholder={t('Palavra-passe')}
/>
<InputError message={errors.password} />
</div>
@@ -115,7 +117,7 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
tabIndex={3}
/>
<Label htmlFor="remember">
Manter sessão iniciada
{t('Manter sessão iniciada')}
</Label>
</div>
@@ -127,15 +129,15 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
data-test="login-button"
>
{processing && <Spinner />}
Entrar
{t('Entrar')}
</Button>
</div>
{/* @chisel-registration */}
<div className="text-muted-foreground text-center text-sm">
Ainda não tem conta?{' '}
{t('Ainda não tem conta?')}{' '}
<TextLink href={register()} tabIndex={5}>
Criar conta
{t('Criar conta')}
</TextLink>
</div>
{/* @end-chisel-registration */}
+12 -10
View File
@@ -1,4 +1,5 @@
import { participationIdentity } from '@/lib/participation-identity';
import { useTempoUi } from '@/components/tempo-brand';
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
@@ -15,9 +16,10 @@ type Props = {
};
export default function Register({ passwordRules }: Props) {
const { t } = useTempoUi();
return (
<>
<Head title="Criar conta" />
<Head title={t('Criar conta')} />
<Form
{...store.form()}
resetOnSuccess={['password', 'password_confirmation']}
@@ -28,7 +30,7 @@ export default function Register({ passwordRules }: Props) {
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="name">Nome</Label>
<Label htmlFor="name">{t('Nome')}</Label>
<Input
id="name"
defaultValue={participationIdentity().name}
@@ -37,7 +39,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={1}
autoComplete="name"
name="name"
placeholder="Nome completo"
placeholder={t('Nome completo')}
/>
<InputError
message={errors.name}
@@ -61,14 +63,14 @@ export default function Register({ passwordRules }: Props) {
</div>
<div className="grid gap-2">
<Label htmlFor="password">Palavra-passe</Label>
<Label htmlFor="password">{t('Palavra-passe')}</Label>
<PasswordInput
id="password"
required
tabIndex={3}
autoComplete="new-password"
name="password"
placeholder="Palavra-passe"
placeholder={t('Palavra-passe')}
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
@@ -76,7 +78,7 @@ export default function Register({ passwordRules }: Props) {
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirmar palavra-passe
{t('Confirmar palavra-passe')}
</Label>
<PasswordInput
id="password_confirmation"
@@ -84,7 +86,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={4}
autoComplete="new-password"
name="password_confirmation"
placeholder="Confirmar palavra-passe"
placeholder={t('Confirmar palavra-passe')}
passwordrules={passwordRules}
/>
<InputError
@@ -99,14 +101,14 @@ export default function Register({ passwordRules }: Props) {
data-test="register-user-button"
>
{processing && <Spinner />}
Criar conta
{t('Criar conta')}
</Button>
</div>
<div className="text-muted-foreground text-center text-sm">
Já tem conta?{' '}
{t('Já tem conta?')}{' '}
<TextLink href={login()} tabIndex={6}>
Entrar
{t('Entrar')}
</TextLink>
</div>
</>
+1 -1
View File
@@ -1,6 +1,6 @@
import { Head, Link, usePage } from '@inertiajs/react';
import type { ReactNode } from 'react';
import { PortalMenu } from '@/layouts/portal-navigation';
import { PortalMenu } from '@/components/portal-menu';
import { cn } from '@/lib/utils';
import type { TerritorialSourceEvidence } from '@/components/territorial-source-evidence';
import type {
+1 -1
View File
@@ -1,5 +1,5 @@
import { Head, Link } from '@inertiajs/react';
import { PortalMenu } from '@/layouts/portal-navigation';
import { PortalMenu } from '@/components/portal-menu';
import EditorialReading, {
type EditorialArticle,
} from '@/components/editorial-reading';
+1 -1
View File
@@ -1,5 +1,5 @@
import { Head, Link } from '@inertiajs/react';
import { PortalMenu } from '@/layouts/portal-navigation';
import { PortalMenu } from '@/components/portal-menu';
import {
PublicDocumentContent,
type PublicDocument,
+1 -9
View File
@@ -1,15 +1,7 @@
import { Head, Link, router } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { PortalMenu } from '@/layouts/portal-navigation';
import {
fiscalForms,
fiscalFormFields,
fiscalPreparationIssues,
newFiscalPreparation,
readFiscalPreparation,
type FiscalFormKind,
type FiscalPreparation,
} from '@/lib/fiscal-form-preparation';
import { fiscalForms, fiscalFormFields, fiscalPreparationIssues, newFiscalPreparation, readFiscalPreparation, type FiscalFormKind, type FiscalPreparation } from '@/lib/fiscal-form-preparation';
const input =
'mt-1 block w-full rounded border border-slate-400 bg-white p-3 text-slate-900';
+54
View File
@@ -0,0 +1,54 @@
import { Head, Link } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { Search as SearchIcon } from 'lucide-react';
type Result = { title: string; description: string | null; url: string; category: string };
type Results = { data: Result[]; total: number; current_page: number; last_page: number; per_page: number; unavailable: string[] };
function pageNumbers(current: number, last: number): (number | string)[] {
const pages = [...new Set([1, last, ...Array.from({ length: 5 }, (_, i) => current - 2 + i)])]
.filter((page) => page >= 1 && page <= last).sort((a, b) => a - b);
return pages.flatMap((page, i) => i > 0 && page - pages[i - 1] > 1 ? [`gap-${page}`, page] : [page]);
}
export default function Search({ query, results }: { query: string; results: Results }) {
const [input, setInput] = useState(query);
useEffect(() => setInput(query), [query]);
const { data, total, current_page: current, last_page: last, per_page: perPage, unavailable } = results;
const pageUrl = (page: number) => `/pesquisa?${new URLSearchParams({ q: query, page: String(page) })}`;
const start = total ? (current - 1) * perPage + 1 : 0;
const end = Math.min(start + data.length - 1, total);
return <main className="min-h-[65vh] bg-white text-[#101820]">
<Head title="Pesquisa" />
<div className="border-b border-slate-200 bg-[#101820] text-white">
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6">
<h1 className="text-3xl font-bold tracking-tight">Pesquisa no InvesteMoz</h1>
<form action="/pesquisa" role="search" className="mt-5 flex max-w-3xl flex-col gap-2 sm:flex-row">
<label htmlFor="search-query" className="sr-only">Pesquisar no InvesteMoz</label>
<input id="search-query" type="search" name="q" value={input} onChange={(event) => setInput(event.target.value)} maxLength={120} className="min-h-12 min-w-0 flex-1 rounded-lg border border-slate-300 bg-white px-4 text-base text-[#101820] outline-none focus:ring-2 focus:ring-[#ed1c24]" placeholder="Oportunidades, documentos, sectores..." />
<button className="inline-flex min-h-12 items-center justify-center gap-2 rounded-lg bg-[#ed1c24] px-6 font-semibold text-white hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"><SearchIcon size={18} aria-hidden="true" />Pesquisar</button>
</form>
</div>
</div>
<div className="mx-auto max-w-5xl px-4 py-7 sm:px-6">
<p className="max-w-3xl text-sm leading-relaxed text-slate-600">Pesquise por palavras em qualquer ordem. Pode escrever com ou sem acentos. Os documentos são pesquisados pelos seus dados descritivos.</p>
{unavailable.length > 0 && <p role="alert" className="mt-4 rounded-lg border border-amber-400 bg-amber-50 p-3 text-sm">Não foi possível consultar: {unavailable.join(', ')}. Os restantes resultados continuam disponíveis; tente novamente mais tarde.</p>}
{query && <p className="mt-6 text-sm text-slate-600" aria-live="polite">{total > 0 ? `Resultados ${start}–${end} de ${total}` : 'Nenhum resultado nas fontes consultadas'} para “{query}”.</p>}
{query && total === 0 && <p className="mt-4 text-slate-600">Experimente outras palavras ou uma expressão mais curta.</p>}
<ol start={start || 1} className="mt-2 max-w-3xl divide-y divide-slate-200">
{data.map((item) => <li key={`${item.category}:${item.url}`} className="py-5">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{item.category}</p>
<Link href={item.url} className="text-lg font-semibold text-[#b5121b] underline-offset-4 hover:underline focus-visible:rounded focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]">{item.title}</Link>
{item.description && <p className="mt-1 line-clamp-3 text-sm leading-relaxed text-slate-600">{item.description}</p>}
</li>)}
</ol>
{last > 1 && <nav className="mt-8 flex flex-wrap items-center gap-2" aria-label="Páginas de resultados">
{current > 1 && <Link href={pageUrl(current - 1)} rel="prev" className="rounded-md px-3 py-2 text-sm font-semibold text-[#b5121b] hover:bg-red-50">Anterior</Link>}
{pageNumbers(current, last).map((page) => typeof page === 'string' ? <span key={page} className="px-1 text-slate-500" aria-hidden="true">…</span> :
<Link key={page} href={pageUrl(page)} aria-label={`Página ${page}`} aria-current={page === current ? 'page' : undefined} className={page === current ? 'rounded-md bg-[#101820] px-3 py-2 text-sm font-bold text-white' : 'rounded-md px-3 py-2 text-sm font-semibold text-[#b5121b] hover:bg-red-50'}>{page}</Link>)}
{current < last && <Link href={pageUrl(current + 1)} rel="next" className="rounded-md px-3 py-2 text-sm font-semibold text-[#b5121b] hover:bg-red-50">Seguinte</Link>}
</nav>}
</div>
</main>;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from '@/components/public-document-content';
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { PortalMenu } from '@/layouts/portal-navigation';
import { PortalMenu } from '@/components/portal-menu';
import { newSubmissionKey } from '@/lib/portal-api';
import { ProvinceSelect } from '@/components/investment-territory';
import { SimulationForm } from '@/components/simulation-form';
@@ -0,0 +1,119 @@
import { useEffect } from 'react';
import { useTempoUi } from '@/components/tempo-brand';
import { translateText } from '@/translation/service';
import { protectTranslationTokens, restoreTranslationTokens } from '@/translation/utils';
// Never change the structure of React's DOM or the value of a form control.
const excluded = 'script,style,noscript,code,pre,textarea,[contenteditable]:not([contenteditable="false"]),[translate="no"],[data-no-translate],.no-translate';
const attributes = ['placeholder', 'title', 'aria-label', 'alt'] as const;
type Target = { node: Text | Element; attribute?: string; original: string; translated?: string };
function read(target: Target): string | null {
return target.attribute ? (target.node as Element).getAttribute(target.attribute) : target.node.nodeValue;
}
function write(target: Target, value: string): void {
if (target.attribute) (target.node as Element).setAttribute(target.attribute, value);
else target.node.nodeValue = value;
}
export function PageTranslator() {
const { language } = useTempoUi();
useEffect(() => {
if (language === 'pt') return;
let active = true;
const abort = new AbortController();
const targets = new Map<Node, Map<string, Target>>();
const cache = new Map<string, Promise<string>>();
const queue: Array<() => void> = [];
let running = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let unavailable = false;
const run = () => {
if (!active || unavailable || timer || running >= 2 || !queue.length) return;
running++;
queue.shift()?.();
// Stay below the API's per-minute limit on large pages.
timer = setTimeout(() => { timer = undefined; run(); }, 650);
};
const translate = (value: string): Promise<string> => {
const previous = cache.get(value);
if (previous) return previous;
const promise = new Promise<string>((resolve, reject) => {
queue.push(() => {
const protectedText = protectTranslationTokens(value);
translateText({ text: protectedText.text, sourceLanguage: 'auto', targetLanguage: language }, abort.signal)
.then(({ translatedText }) => {
for (const token of protectedText.replacements.keys()) {
if (!translatedText.includes(token)) throw new Error('Translation lost a protected token');
}
resolve(restoreTranslationTokens(translatedText, protectedText.replacements));
})
.catch((error) => {
if (!abort.signal.aborted && (error?.status === 503 || error?.status === null)) unavailable = true;
reject(error);
})
.finally(() => { running--; run(); });
});
run();
});
cache.set(value, promise);
promise.catch(() => cache.delete(value));
return promise;
};
const consider = (node: Text | Element, attribute?: string) => {
const element = node instanceof Element ? node : node.parentElement;
if (!element || element.closest(excluded)) return;
// Without an explicit value, translating an option changes the submitted value.
if (element.closest('option:not([value])')) return;
const key = attribute ?? '';
const value = attribute ? (node as Element).getAttribute(attribute) : node.nodeValue;
if (!value || value.length > 10000 || !/\p{L}/u.test(value)) return;
let entries = targets.get(node);
if (!entries) { entries = new Map(); targets.set(node, entries); }
const old = entries.get(key);
if (old && (value === old.original || value === old.translated)) return;
const target: Target = { node, attribute, original: value };
entries.set(key, target);
translate(value).then((translated) => {
if (!active || !node.isConnected || entries?.get(key) !== target || read(target) !== value) return;
target.translated = translated;
write(target, translated);
}).catch(() => { /* Keep the original visible when translation fails. */ });
};
const scan = (root: Node) => {
if (root instanceof Text) { consider(root); return; }
if (!(root instanceof Element)) return;
if (root.closest(excluded)) return;
for (const name of attributes) if (root.hasAttribute(name)) consider(root, name);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode;
if (node instanceof Text) consider(node);
else if (node instanceof Element) for (const name of attributes) if (node.hasAttribute(name)) consider(node, name);
}
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'characterData') scan(mutation.target);
else if (mutation.type === 'attributes' && mutation.target instanceof Element && mutation.attributeName) consider(mutation.target, mutation.attributeName);
else mutation.addedNodes.forEach(scan);
}
});
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
scan(document.body);
return () => {
active = false;
abort.abort();
if (timer) clearTimeout(timer);
observer.disconnect();
for (const entries of targets.values()) for (const target of entries.values()) {
if (target.translated !== undefined && read(target) === target.translated) write(target, target.original);
}
};
}, [language]);
return null;
}
+162
View File
@@ -0,0 +1,162 @@
import type {
TranslateRequest,
TranslateResponse,
TranslationLanguage,
} from "./types";
import { readLanguageList } from "./utils";
const translationApiBaseUrl = "/api";
const REQUEST_TIMEOUT_MS = 65_000;
const MAX_ATTEMPTS = 3;
export class TranslationRequestError extends Error {
status: number | null;
retryable: boolean;
constructor(message: string, options?: { status?: number | null; retryable?: boolean }) {
super(message);
this.name = "TranslationRequestError";
this.status = options?.status ?? null;
this.retryable = options?.retryable ?? false;
}
}
function isRetryableStatus(status: number): boolean {
return [429, 500, 502, 503, 504].includes(status);
}
async function wait(ms: number): Promise<void> {
await new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function fetchJson<T>(
input: RequestInfo | URL,
init: RequestInit = {},
attempt = 1,
maxAttempts = MAX_ATTEMPTS,
): Promise<T> {
const parentSignal = init.signal;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const forwardAbort = () => controller.abort();
parentSignal?.addEventListener("abort", forwardAbort, { once: true });
try {
const response = await fetch(input, {
...init,
signal: controller.signal,
headers: {
Accept: "application/json",
...(init.headers ?? {}),
},
});
if (!response.ok) {
const retryable = isRetryableStatus(response.status);
if (retryable && attempt < maxAttempts) {
await wait(300 * attempt);
return fetchJson<T>(input, init, attempt + 1, maxAttempts);
}
let message = `Translation request failed with status ${response.status}`;
try {
const payload = (await response.json()) as { message?: string };
if (payload?.message) {
message = payload.message;
}
} catch {
// Ignore JSON parse issues on error responses.
}
throw new TranslationRequestError(message, {
status: response.status,
retryable,
});
}
return (await response.json()) as T;
} catch (error) {
const aborted = controller.signal.aborted;
const isDomAbort = error instanceof DOMException && error.name === "AbortError";
const parentAborted = parentSignal?.aborted ?? false;
const timedOut = aborted && !parentAborted;
const retryable = timedOut || error instanceof TypeError;
if (retryable && attempt < maxAttempts) {
await wait(300 * attempt);
return fetchJson<T>(input, init, attempt + 1, maxAttempts);
}
if (error instanceof TranslationRequestError) {
throw error;
}
if (parentAborted || isDomAbort) {
throw error;
}
if (timedOut) {
throw new TranslationRequestError("Translation request timed out", {
status: null,
retryable,
});
}
throw new TranslationRequestError("Translation request failed", {
status: null,
retryable,
});
} finally {
window.clearTimeout(timeoutId);
parentSignal?.removeEventListener("abort", forwardAbort);
}
}
export async function getLanguages(signal?: AbortSignal): Promise<TranslationLanguage[]> {
const payload = await fetchJson<unknown>(`${translationApiBaseUrl}/translate/languages`, {
method: "GET",
signal,
}, 1, 1);
return readLanguageList(payload);
}
export async function translateText(
request: TranslateRequest,
signal?: AbortSignal,
): Promise<TranslateResponse> {
const payload = await fetchJson<{
original_text?: string;
translated_text?: string;
source_lang?: string;
target_lang?: string;
}>(`${translationApiBaseUrl}/translate`, {
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text: request.text,
source_lang: request.sourceLanguage,
target_lang: request.targetLanguage,
is_html: request.isHtml ?? false,
}),
}, 1, 1);
if (typeof payload.translated_text !== "string" || payload.translated_text.trim() === "") {
throw new TranslationRequestError("Translation response is missing translated_text", {
status: 200,
retryable: false,
});
}
return {
originalText: payload.original_text ?? request.text,
translatedText: payload.translated_text,
sourceLanguage: payload.source_lang ?? request.sourceLanguage,
targetLanguage: payload.target_lang ?? request.targetLanguage,
};
}
+41
View File
@@ -0,0 +1,41 @@
export interface TranslationLanguage {
code: string;
name: string;
targets?: string[];
}
export interface TranslateRequest {
text: string;
sourceLanguage: string;
targetLanguage: string;
isHtml?: boolean;
}
export interface TranslateResponse {
originalText: string;
translatedText: string;
sourceLanguage: string;
targetLanguage: string;
}
export interface TranslationCacheEntry {
value: string;
createdAt: number;
expiresAt: number;
}
export interface ProtectedTranslationTokens {
text: string;
replacements: Map<string, string>;
}
export interface TranslationPayloadHealth {
shouldTranslate: boolean;
normalizedText: string;
}
export interface TranslateOptions {
isHtml?: boolean;
signal?: AbortSignal;
protectedTerms?: string[];
}
+615
View File
@@ -0,0 +1,615 @@
import type {
ProtectedTranslationTokens,
TranslationPayloadHealth,
TranslationCacheEntry,
TranslationLanguage,
} from "./types";
const CACHE_PREFIX = "tempo_translation_v1";
const CACHE_VERSION = "v1";
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
const CACHE_MAX_ENTRIES = 200;
const CACHE_CLEANUP_INTERVAL = 12 * 60 * 60 * 1000;
const CLEANUP_MARKER_KEY = `${CACHE_PREFIX}:cleanup`;
const memoryCache = new Map<string, TranslationCacheEntry>();
const LANGUAGE_ALIASES: Record<string, string> = {
ar: "ar",
"ar-sa": "ar",
en: "en",
"en-gb": "en",
"en-us": "en",
es: "es",
"es-es": "es",
fr: "fr",
"fr-fr": "fr",
hi: "hi",
"hi-in": "hi",
pt: "pt",
"pt-mz": "pt",
"pt-pt": "pt",
zh: "zh-Hans",
"zh-cn": "zh-Hans",
"zh-hans": "zh-Hans",
};
const apiUnsafeNodes = "script, iframe, object, embed, link, meta";
const excludedTranslationNodes =
"code, pre, script, style, textarea, noscript, .no-translate, [data-no-translate], [translate='no']";
const INTERNAL_PLACEHOLDER_PREFIX = "¤TRX";
const BATCH_MAX_ITEMS = 8;
const BATCH_MAX_CHARS = 2200;
const TRANSLATION_PLACEHOLDER_PATTERN = /¤TRX[A-Z]+\d+¤/g;
const HTML_BATCH_ROOT_ATTR = "data-trx-batch-root";
const HTML_BATCH_NODE_ATTR = "data-trx-node";
export const fallbackTranslationLanguages: TranslationLanguage[] = [
{ code: "pt", name: "Português" },
{ code: "en", name: "English" },
{ code: "fr", name: "French" },
{ code: "es", name: "Spanish" },
{ code: "ar", name: "Arabic" },
{ code: "hi", name: "Hindi" },
{ code: "zh-Hans", name: "Chinese" },
];
export const DEFAULT_PROTECTED_TERMS = [
"Revista TEMPO",
"Grupo TEMPO",
"Tempo TV",
"Tempo Connect",
"Informação Pública",
"InvesteMoz",
"TEMPO",
] as const;
function getStorage(): Storage | null {
if (typeof window === "undefined") return null;
return window.localStorage;
}
function normalizeAlias(code: string): string {
return LANGUAGE_ALIASES[code.trim().toLowerCase()] ?? code.trim();
}
export function normalizeLanguageCode(
code: string,
availableLanguages: TranslationLanguage[],
): string | null {
if (!code) return null;
const normalized = normalizeAlias(code);
const supportedCodes = new Set(
availableLanguages.map((language) => normalizeAlias(language.code)),
);
return supportedCodes.has(normalized) ? normalized : null;
}
export function getBrowserLanguageCode(code: string): string {
switch (code) {
case "zh-Hans":
return "zh-CN";
default:
return code;
}
}
export function getLocaleForLanguage(code: string): string {
switch (normalizeAlias(code)) {
case "pt":
return "pt-MZ";
case "zh-Hans":
return "zh-CN";
default:
return normalizeAlias(code);
}
}
export function isRtlLanguage(code: string): boolean {
return normalizeAlias(code) === "ar";
}
export function readLanguageList(payload: unknown): TranslationLanguage[] {
const source = Array.isArray(payload)
? payload
: Array.isArray((payload as { value?: unknown[] })?.value)
? (payload as { value: unknown[] }).value
: Array.isArray((payload as { data?: unknown[] })?.data)
? (payload as { data: unknown[] }).data
: [];
return source
.filter((item): item is TranslationLanguage => {
return (
Boolean(item) &&
typeof item === "object" &&
typeof item.code === "string" &&
typeof item.name === "string"
);
})
.map((language) => ({
code: normalizeAlias(language.code),
name: language.name,
targets: Array.isArray(language.targets)
? language.targets.map((target) => normalizeAlias(target))
: undefined,
}));
}
export function withPortugueseFirst(
languages: TranslationLanguage[],
): TranslationLanguage[] {
const unique = new Map<string, TranslationLanguage>();
for (const language of languages) {
const code = normalizeAlias(language.code);
if (!unique.has(code)) {
unique.set(code, {
...language,
code,
name: code === "pt" ? "Português" : language.name,
});
}
}
const items = Array.from(unique.values()).sort((a, b) =>
a.name.localeCompare(b.name),
);
const portuguese =
items.find((language) => language.code === "pt") ?? fallbackTranslationLanguages[0];
return [portuguese, ...items.filter((language) => language.code !== "pt")];
}
export function hashTranslationText(value: string): string {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16);
}
export function createTranslationCacheKey(params: {
sourceLanguage: string;
targetLanguage: string;
isHtml: boolean;
text: string;
protectedTerms?: string[];
}): string {
return [
CACHE_PREFIX,
CACHE_VERSION,
params.sourceLanguage,
params.targetLanguage,
params.isHtml ? "html" : "text",
hashTranslationText(params.text),
hashTranslationText(
JSON.stringify(
collectProtectedTerms(params.protectedTerms).map((term) => term.toLowerCase()),
),
),
].join(":");
}
export function getMemoryCacheEntry(key: string): TranslationCacheEntry | null {
const entry = memoryCache.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
memoryCache.delete(key);
return null;
}
return entry;
}
export function setMemoryCacheEntry(key: string, value: string): TranslationCacheEntry {
const entry: TranslationCacheEntry = {
value,
createdAt: Date.now(),
expiresAt: Date.now() + CACHE_TTL,
};
memoryCache.set(key, entry);
return entry;
}
export function getPersistentCacheEntry(key: string): TranslationCacheEntry | null {
const storage = getStorage();
if (!storage) return null;
const raw = storage.getItem(key);
if (!raw) return null;
try {
const entry = JSON.parse(raw) as TranslationCacheEntry;
if (entry.expiresAt <= Date.now()) {
storage.removeItem(key);
return null;
}
return entry;
} catch {
storage.removeItem(key);
return null;
}
}
export function setPersistentCacheEntry(key: string, entry: TranslationCacheEntry): void {
const storage = getStorage();
if (!storage) return;
storage.setItem(key, JSON.stringify(entry));
}
export function cleanupTranslationCache(): void {
const storage = getStorage();
if (!storage) return;
const lastCleanup = Number(storage.getItem(CLEANUP_MARKER_KEY) ?? "0");
if (lastCleanup && Date.now() - lastCleanup < CACHE_CLEANUP_INTERVAL) {
return;
}
const translationKeys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key && key.startsWith(`${CACHE_PREFIX}:${CACHE_VERSION}:`)) {
translationKeys.push(key);
}
}
const validEntries: Array<{ key: string; entry: TranslationCacheEntry }> = [];
for (const key of translationKeys) {
const entry = getPersistentCacheEntry(key);
if (entry) {
validEntries.push({ key, entry });
}
}
validEntries
.sort((a, b) => a.entry.createdAt - b.entry.createdAt)
.slice(0, Math.max(0, validEntries.length - CACHE_MAX_ENTRIES))
.forEach(({ key }) => storage.removeItem(key));
storage.setItem(CLEANUP_MARKER_KEY, String(Date.now()));
}
function createPlaceholder(prefix: string, index: number): string {
return `${INTERNAL_PLACEHOLDER_PREFIX}${prefix}${index}¤`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function collectProtectedTerms(terms?: Array<string | null | undefined>): string[] {
const combined = [...DEFAULT_PROTECTED_TERMS, ...(terms ?? [])]
.map((term) => String(term ?? "").trim())
.filter(Boolean);
return Array.from(new Set(combined)).sort((a, b) => b.length - a.length);
}
function protectMatches(
input: string,
pattern: RegExp,
replacements: Map<string, string>,
prefix: string,
): string {
return input.replace(pattern, (match) => {
const placeholder = createPlaceholder(prefix, replacements.size);
replacements.set(placeholder, match);
return placeholder;
});
}
function protectExcludedHtmlBlocks(
input: string,
replacements: Map<string, string>,
): string {
if (typeof window === "undefined") return input;
const documentFragment = new DOMParser().parseFromString(input, "text/html");
const nodes = Array.from(documentFragment.body.querySelectorAll(excludedTranslationNodes));
nodes.forEach((node) => {
const placeholder = createPlaceholder("HTML", replacements.size);
replacements.set(placeholder, node.outerHTML);
node.replaceWith(documentFragment.createTextNode(placeholder));
});
return documentFragment.body.innerHTML;
}
export function protectTranslationTokens(
input: string,
options?: { isHtml?: boolean; protectedTerms?: string[] },
): ProtectedTranslationTokens {
const replacements = new Map<string, string>();
let output = String(input ?? "");
if (options?.isHtml) {
output = protectExcludedHtmlBlocks(output, replacements);
}
output = protectMatches(
output,
/https?:\/\/[^\s"'<>]+/gi,
replacements,
"URL",
);
output = protectMatches(
output,
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
replacements,
"EMAIL",
);
output = protectMatches(
output,
/\{\{[^{}]+\}\}|\{[a-zA-Z0-9_.-]+\}|%(\d+\$)?[sd]|:[a-zA-Z_][a-zA-Z0-9_]*/g,
replacements,
"TOKEN",
);
for (const term of collectProtectedTerms(options?.protectedTerms)) {
const escapedTerm = escapeRegExp(term);
const pattern = new RegExp(`(^|[^\\p{L}\\p{N}])(${escapedTerm})(?=$|[^\\p{L}\\p{N}])`, "giu");
output = output.replace(pattern, (match, prefix, protectedValue) => {
const placeholder = createPlaceholder("TERM", replacements.size);
replacements.set(placeholder, protectedValue);
return `${prefix}${placeholder}`;
});
}
return { text: output, replacements };
}
export function restoreTranslationTokens(
input: string,
replacements: Map<string, string>,
): string {
let output = String(input ?? "");
for (const [placeholder, original] of replacements.entries()) {
output = output.split(placeholder).join(original);
}
return output;
}
export function getTranslationPayloadHealth(
input: string,
options?: { isHtml?: boolean; protectedTerms?: string[] },
): TranslationPayloadHealth {
const source = String(input ?? "");
if (!source.trim()) {
return {
shouldTranslate: false,
normalizedText: source,
};
}
if (options?.isHtml && typeof window !== "undefined") {
const documentFragment = new DOMParser().parseFromString(source, "text/html");
const walker = document.createTreeWalker(
documentFragment.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
return isSkippableTextNode(node as Text)
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
},
);
if (!walker.nextNode()) {
return {
shouldTranslate: false,
normalizedText: source,
};
}
}
const protectedPayload = protectTranslationTokens(source, options);
const remainder = protectedPayload.text
.replace(TRANSLATION_PLACEHOLDER_PATTERN, "")
.trim();
if (!remainder) {
return {
shouldTranslate: false,
normalizedText: source,
};
}
const hasLetters = /[\p{L}]/u.test(remainder);
if (!hasLetters) {
return {
shouldTranslate: false,
normalizedText: source,
};
}
return {
shouldTranslate: true,
normalizedText: source,
};
}
export function sanitizeTranslatedHtml(html: string): string {
if (typeof window === "undefined" || !html) return "";
const documentFragment = new DOMParser().parseFromString(String(html), "text/html");
documentFragment.querySelectorAll(apiUnsafeNodes).forEach((node) => node.remove());
documentFragment.querySelectorAll("*").forEach((node) => {
for (const attribute of Array.from(node.attributes)) {
const name = attribute.name.toLowerCase();
const value = attribute.value.trim().toLowerCase();
if (
name.startsWith("on") ||
value.startsWith("javascript:") ||
value.startsWith("data:text/html") ||
value.startsWith("vbscript:")
) {
node.removeAttribute(attribute.name);
}
}
});
return documentFragment.body.innerHTML;
}
function isSkippableTextNode(node: Text): boolean {
const parent = node.parentElement;
const value = node.textContent ?? "";
if (!value.trim()) return true;
if (!parent) return true;
if (parent.closest(excludedTranslationNodes)) return true;
return false;
}
function createBatchPayload(values: string[]): {
payload: string;
} {
if (typeof window === "undefined") {
return {
payload: values.join(" "),
};
}
const documentFragment = document.implementation.createHTMLDocument("");
const root = documentFragment.createElement("div");
root.setAttribute(HTML_BATCH_ROOT_ATTR, "1");
values.forEach((value, index) => {
const node = documentFragment.createElement("span");
node.setAttribute(HTML_BATCH_NODE_ATTR, String(index));
node.textContent = value;
root.appendChild(node);
});
documentFragment.body.appendChild(root);
return { payload: root.outerHTML };
}
function parseBatchResponse(
translated: string,
originalValues: string[],
): string[] {
if (typeof window === "undefined") {
return originalValues;
}
const documentFragment = new DOMParser().parseFromString(translated, "text/html");
const root = documentFragment.body.querySelector(`[${HTML_BATCH_ROOT_ATTR}]`);
if (!root) {
return originalValues;
}
const nodes = Array.from(root.querySelectorAll(`[${HTML_BATCH_NODE_ATTR}]`));
if (nodes.length !== originalValues.length) {
return originalValues;
}
return originalValues.map((originalValue, index) => {
const node = nodes.find(
(candidate) => candidate.getAttribute(HTML_BATCH_NODE_ATTR) === String(index),
);
return node?.textContent?.trim() ? node.textContent : originalValue;
});
}
function buildTextBatches(nodes: Text[]): Text[][] {
const batches: Text[][] = [];
let currentBatch: Text[] = [];
let currentLength = 0;
for (const node of nodes) {
const textLength = (node.textContent ?? "").length;
const shouldFlush =
currentBatch.length >= BATCH_MAX_ITEMS ||
currentLength + textLength > BATCH_MAX_CHARS;
if (shouldFlush && currentBatch.length > 0) {
batches.push(currentBatch);
currentBatch = [];
currentLength = 0;
}
currentBatch.push(node);
currentLength += textLength;
}
if (currentBatch.length > 0) {
batches.push(currentBatch);
}
return batches;
}
export async function translateHtmlByTextNodes(params: {
html: string;
protectedTerms?: string[];
translateText: (text: string, options?: { protectedTerms?: string[]; isHtml?: boolean }) => Promise<string>;
}): Promise<string> {
if (typeof window === "undefined" || !params.html.trim()) {
return params.html;
}
const documentFragment = new DOMParser().parseFromString(params.html, "text/html");
const walker = document.createTreeWalker(
documentFragment.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
return isSkippableTextNode(node as Text)
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
},
);
const textNodes: Text[] = [];
let currentNode = walker.nextNode();
while (currentNode) {
textNodes.push(currentNode as Text);
currentNode = walker.nextNode();
}
if (textNodes.length === 0) {
return sanitizeTranslatedHtml(documentFragment.body.innerHTML);
}
for (const batch of buildTextBatches(textNodes)) {
const originalValues = batch.map((node) => node.textContent ?? "");
const { payload } = createBatchPayload(originalValues);
const translatedPayload = await params.translateText(payload, {
protectedTerms: params.protectedTerms,
isHtml: true,
});
const translatedValues = parseBatchResponse(translatedPayload, originalValues);
batch.forEach((node, index) => {
node.textContent = translatedValues[index];
});
}
return sanitizeTranslatedHtml(documentFragment.body.innerHTML);
}
+4
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\TranslationApiController;
use App\Http\Controllers\Api\V1\HomeController;
use App\Http\Controllers\Api\V1\InterestRequestController;
use App\Http\Controllers\Api\V1\LegislationSourceController;
@@ -17,6 +18,9 @@ use App\Http\Middleware\EnsureSimulationRecoveryCookie;
use App\Services\SimulationPublishedFiscal;
use Illuminate\Support\Facades\Route;
Route::get('translate/languages', [TranslationApiController::class, 'languages'])->middleware('throttle:60,1');
Route::post('translate', [TranslationApiController::class, 'translate'])->middleware('throttle:120,1');
Route::prefix('v1')->middleware(['throttle:api', CorrelationId::class])->group(function () {
Route::get('/catalogs/{catalog}', [ReferenceCatalogController::class, 'index'])
->whereIn('catalog', ['authorities', 'jurisdictions', 'economic-activities', 'countries']);
+1 -1
View File
@@ -114,7 +114,7 @@ Route::middleware(['auth', 'verified', EnsureDocumentManagement::class])->group(
Route::get('/artigos/{id}', [EditorialArticleController::class, 'show'])->whereNumber('id')->name('editorial.show');
Route::get('/carteira', [InvestmentPortfolioController::class, 'index'])->name('portfolio.index');
Route::get('/carteira/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
Route::get('/carteira/{id}/interesse', [InvestmentPortfolioController::class, 'resumeInterest'])->whereUuid('id')->middleware(['auth', VerifiedOrSimpleParticipation::class])->name('portfolio.interest');
Route::get('/carteira/{id}/interesse', [InvestmentPortfolioController::class, 'resumeInterest'])->whereUuid('id')->middleware(['auth', \App\Http\Middleware\VerifiedOrSimpleParticipation::class])->name('portfolio.interest');
Route::get('/oportunidades', [InvestmentDiscoveryController::class, 'index'])->name('portal.opportunities');
Route::get('/oportunidades/{slug}', [InvestmentDiscoveryController::class, 'detail'])->name('portal.opportunity');
Route::get('/negocios', [PublicBusinessOpportunityController::class, 'index'])->name('business-marketplace.index');
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Services\FederatedSearch;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
final class GlobalSearchTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
public function test_empty_and_overlong_queries(): void
{
$this->get('/pesquisa')->assertOk()->assertInertia(fn (Assert $page) => $page->component('portal/search')->where('results.total', 0));
$this->get('/pesquisa?q='.str_repeat('a', 121))->assertSessionHasErrors('q');
}
public function test_source_failure_recalculates_the_page_without_hiding_the_other_results(): void
{
$sources = [
'Documentos' => fn () => ['count' => 1, 'fetch' => fn () => throw new QueryException('testing', 'select 1', [], new \RuntimeException('Source unavailable'))],
'Páginas' => fn () => ['count' => 1, 'fetch' => fn ($offset, $limit) => array_slice([['title' => 'Como investir', 'url' => '/como-investir']], $offset, $limit)],
];
$result = app(FederatedSearch::class)->page($sources, 1);
$this->assertSame(1, $result['total']);
$this->assertSame(['Documentos'], $result['unavailable']);
$this->assertSame('Como investir', $result['data'][0]['title']);
}
public function test_public_search_uses_the_existing_portfolio_visibility_scope(): void
{
if (getenv('GLOBAL_SEARCH_QA') !== '1') {
$this->markTestSkipped('A prova com dados exige a base QA explicitamente seleccionada.');
}
$identity = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
$record = InvestmentPortfolioController::visible()->first();
if (! $record) {
$this->markTestSkipped('No published portfolio records in isolated QA.');
}
$term = mb_substr($record->title, 0, 15);
$this->get('/pesquisa?q='.urlencode($term))->assertOk()->assertInertia(fn (Assert $page) => $page
->component('portal/search')->where('query', $term)
->where('results.total', fn ($total) => $total >= 1));
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class TranslationApiTest extends TestCase
{
public function test_it_lists_languages_from_the_configured_engine(): void
{
config(['services.translation.url' => 'http://translator.test']);
Http::fake(['translator.test/languages' => Http::response([['code' => 'pt', 'name' => 'Portuguese', 'targets' => ['en']]])]);
$this->getJson('/api/translate/languages')->assertOk()->assertJsonPath('0.targets.0', 'en');
}
public function test_it_translates_and_reports_engine_failures(): void
{
config(['services.translation.url' => 'http://translator.test']);
Http::fake(['translator.test/translate' => Http::sequence()->push(['translatedText' => 'Hello'])->push([], 500)]);
$input = ['text' => 'Olá', 'source_lang' => 'pt', 'target_lang' => 'en'];
$this->postJson('/api/translate', $input)->assertOk()->assertJsonPath('translated_text', 'Hello');
$this->postJson('/api/translate', $input)->assertStatus(503);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Tests\Unit;
use App\Services\FederatedSearch;
use PHPUnit\Framework\TestCase;
final class FederatedSearchTest extends TestCase
{
public function test_global_pagination_spans_sources_without_loading_the_complete_sets(): void
{
$calls = [];
$factory = function (string $prefix, int $count) use (&$calls) {
return function () use (&$calls, $prefix, $count) {
return ['count' => $count, 'fetch' => function ($offset, $limit) use (&$calls, $prefix, $count) {
$calls[] = [$prefix, $offset, $limit];
return array_map(fn ($i) => ['title' => $prefix.$i, 'url' => '/'.$prefix.$i], array_slice(range(1, $count), $offset, $limit));
}];
};
};
$sources = ['Páginas' => $factory('p', 3), 'Oportunidades' => $factory('o', 7), 'Documentos' => $factory('d', 2)];
$service = new FederatedSearch;
$first = $service->page($sources, 1);
$this->assertSame(['p1', 'p2', 'p3', 'o1', 'o2'], array_column($first['data'], 'title'));
$this->assertSame(['Páginas', 'Páginas', 'Páginas', 'Oportunidades', 'Oportunidades'], array_column($first['data'], 'category'));
$this->assertSame(12, $first['total']);
$this->assertSame(3, $first['last_page']);
$calls = [];
$second = $service->page($sources, 2);
$this->assertSame(['o3', 'o4', 'o5', 'o6', 'o7'], array_column($second['data'], 'title'));
$this->assertSame([['p', 0, 5], ['o', 0, 5], ['d', 0, 5], ['o', 5, 5]], $calls);
$third = $service->page($sources, 99);
$this->assertSame(3, $third['current_page']);
$this->assertSame(['d1', 'd2'], array_column($third['data'], 'title'));
}
public function test_more_relevant_titles_appear_first_across_sources(): void
{
$factories = [
'Serviços' => fn () => ['count' => 1, 'fetch' => fn ($offset, $limit) => array_slice([['title' => 'Informação', 'url' => '/info', 'score' => 10]], $offset, $limit)],
'Sectores' => fn () => ['count' => 1, 'fetch' => fn ($offset, $limit) => array_slice([['title' => 'Sectores', 'url' => '/sectores', 'score' => 100]], $offset, $limit)],
];
$result = (new FederatedSearch)->page($factories, 1, 5, fn ($item) => $item['score']);
$this->assertSame(['Sectores', 'Informação'], array_column($result['data'], 'title'));
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Tests\Unit;
use App\Http\Controllers\GlobalSearchController;
use App\Services\SearchTerms;
use Illuminate\Database\PostgresConnection;
use PHPUnit\Framework\TestCase;
final class SearchTermsTest extends TestCase
{
public function test_matches_all_words_in_any_order_without_case_or_accents(): void
{
$search = new SearchTerms;
$tokens = $search->tokens(' NAMPULA agronegócio Agronegócio ');
$this->assertSame(['nampula', 'agronegocio'], $tokens);
$this->assertTrue($search->matches('Agronegócio em Nampula', $tokens));
$this->assertFalse($search->matches('Agronegócio em Gaza', $tokens));
$this->assertSame([], $search->tokens(' '));
}
public function test_sql_matches_each_word_across_fields_with_bound_literal_patterns(): void
{
$connection = new PostgresConnection(fn () => throw new \LogicException('A database must not be opened.'));
$query = $connection->table('records');
$search = new SearchTerms;
$search->constrain($query, ['title', 'description'], $search->tokens('100% a_b'));
$sql = $query->toSql();
$this->assertStringContainsString('translate(lower(coalesce(title::text', $sql);
$this->assertStringContainsString(' or ', $sql);
$this->assertStringContainsString(' and ', $sql);
$this->assertSame(['%100!%%', '%100!%%', '%a!_b%', '%a!_b%'], $query->getBindings());
$this->assertStringNotContainsString('100%', $sql);
}
public function test_static_services_can_be_found_without_a_database(): void
{
$pages = (new \ReflectionMethod(GlobalSearchController::class, 'pages'))->invoke(new GlobalSearchController);
$search = new SearchTerms;
$matches = array_values(array_filter($pages, fn ($page) => $search->matches(implode(' ', $page), $search->tokens('FISCAL simulador'))));
$this->assertCount(1, $matches);
$this->assertSame('/simulador-fiscal', $matches[0]['url']);
}
public function test_spelling_variants_common_words_and_title_relevance(): void
{
$search = new SearchTerms;
$tokens = $search->tokens('setores de investimento');
$this->assertTrue($search->matches('Sectores de investimento', $tokens));
$this->assertFalse($search->matches('Sector de saúde', $tokens));
$this->assertTrue($search->matches('Projectos âncora', $search->tokens('projeto')));
$this->assertTrue($search->matches('Oportunidades de investimento em energia', $search->tokens('Como investir em energia?')));
$this->assertGreaterThan($search->score('Como investir', 'investir', ['investir']), $search->score('Investir', 'investir', ['investir']));
$this->assertSame(['de'], $search->tokens('de'));
}
}
+8 -47
View File
@@ -7,53 +7,14 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
import * as forms from '../../resources/js/lib/fiscal-form-preparation.ts';
const require = createRequire(import.meta.url);
const source = fs.readFileSync(
new URL(
'../../resources/js/pages/portal/fiscal-forms.tsx',
import.meta.url,
),
'utf8',
);
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
const module = { exports: {} };
compileFunction(compiled, ['require', 'module', 'exports'])(
(name) =>
name === '@inertiajs/react'
? {
Head: () => null,
Link: ({ href, children, ...props }) =>
React.createElement('a', { href, ...props }, children),
router: { on: () => () => {} },
}
: name === '@/layouts/portal-navigation'
? { PortalMenu: () => null }
: name === '@/lib/fiscal-form-preparation'
? forms
: require(name),
module,
module.exports,
);
void test('fiscal entry renders labelled editable fields and a guided preparation rather than legislation', () => {
const html = renderToStaticMarkup(
React.createElement(module.exports.default),
);
for (const label of [
'Identificação e período',
'Nome ou designação social',
'NUIT',
'Período — ano e mês',
'Conferir e guardar',
'Guardar ficheiro para continuar',
'Recuperar ficheiro guardado',
'Confirmar recuperação',
]) {
const require=createRequire(import.meta.url);
const source=fs.readFileSync(new URL('../../resources/js/pages/portal/fiscal-forms.tsx',import.meta.url),'utf8');
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const module={exports:{}};
new Function('require','module','exports',compiled)(name=>name==='@inertiajs/react'?{Head:()=>null,Link:({href,children,...props})=>React.createElement('a',{href,...props},children),router:{on:()=>()=>{}}}:name==='@/layouts/portal-navigation'?{PortalMenu:()=>null}:name==='@/lib/fiscal-form-preparation'?forms:require(name),module,module.exports);
test('fiscal entry renders labelled editable fields and a guided preparation rather than legislation',()=>{
const html=renderToStaticMarkup(React.createElement(module.exports.default));
for(const label of ['Identificação e período','Nome ou designação social','NUIT','Período — ano e mês','Conferir e guardar','Guardar ficheiro para continuar','Recuperar ficheiro guardado','Confirmar recuperação']) {
// Confirmation appears only after a compatible file is selected.
if (label !== 'Confirmar recuperação')
assert.ok(html.includes(label), label);
+36
View File
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const source = fs.readFileSync(new URL('../../resources/js/pages/portal/search.tsx', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText;
const module = { exports: {} };
new Function('require', 'module', 'exports', compiled)(name => name === '@inertiajs/react' ? {
Head: () => null,
Link: ({ href, children, ...props }) => React.createElement('a', { href, ...props }, children),
} : name === 'lucide-react' ? { Search: () => null } : require(name), module, module.exports);
const Search = module.exports.default;
test('shows five results in one sequence with numbered pagination and the query preserved', () => {
const results = { data: [{ title: 'Energia em Nampula', description: 'Projecto público', url: '/oportunidades/energia', category: 'Oportunidades' }], total: 12, current_page: 1, last_page: 3, per_page: 5, unavailable: [] };
const html = renderToStaticMarkup(React.createElement(Search, { query: 'energia', results }));
assert.match(html, /Oportunidades/);
assert.match(html, /Resultados 1–1 de 12/);
assert.match(html, /href="\/oportunidades\/energia"/);
assert.match(html, /q=energia&amp;page=2/);
assert.match(html, /aria-current="page"/);
assert.doesNotMatch(html, /emerald/);
});
test('makes source failures explicit without hiding other results', () => {
const results = { data: [], total: 0, current_page: 1, last_page: 1, per_page: 5, unavailable: ['Documentos e legislação'] };
const html = renderToStaticMarkup(React.createElement(Search, { query: 'terra', results }));
assert.match(html, /role="alert"/);
assert.match(html, /Não foi possível consultar: Documentos e legislação/);
assert.match(html, /Nenhum resultado nas fontes consultadas/);
});