Merge origin/main preservando reconciliacao da matriz
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-20 09:14:24 +02:00
36 changed files with 5567 additions and 3820 deletions
+3
View File
@@ -1,5 +1,8 @@
* text=auto eol=lf
# Preserve the exact bytes pinned by EconomicReferenceCatalog::MANIFEST_SHA256.
database/data/economic-references-lot-01.json text eol=crlf
*.blade.php diff=html
*.css diff=css
*.html diff=html
+13 -1
View File
@@ -1,4 +1,16 @@
# Portal do Investidor — esqueleto Laravel
# Portal do Investidor — InvesteMoz
## Executar esta plataforma no Windows
Consulte [Execução local](deploy/local/README.md). Com Docker Desktop iniciado,
execute `./deploy/local/start.ps1`. Depois da preparação, `composer run dev`
inicia a plataforma em http://127.0.0.1:8000.
O ambiente local usa uma base PostgreSQL/PostGIS de demonstração isolada.
Não execute os exemplos históricos de criação de um projecto novo sobre esta
instalação ou sobre dados existentes.
## Referência histórica do pacote inicial
Estes ficheiros correspondem ao domínio de **sectores**, **oportunidades** e
**manifestações de interesse** do Portal do Investidor. Foram desenhados
@@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CheckDevelopmentEnvironment extends Command
{
protected $signature = 'portal:dev-check';
protected $description = 'Verifica o arranque local sem alterar dados ou mostrar credenciais';
public function handle(): int
{
$checks = [
'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='),
'Driver PostgreSQL activo' => extension_loaded('pdo_pgsql'),
'APP_KEY definida' => filled(config('app.key')),
'Base PostgreSQL configurada' => config('database.default') === 'pgsql',
'Cache de vistas gravável' => is_dir(config('view.compiled') ?: '') && is_writable(config('view.compiled') ?: ''),
'Sessões e logs graváveis' => is_writable(storage_path('framework/sessions')) && is_writable(storage_path('logs')),
'Dependências frontend instaladas' => is_file(base_path('node_modules/.bin/vp')) || is_file(base_path('node_modules/.bin/vp.cmd')),
];
if ($checks['Driver PostgreSQL activo'] && $checks['Base PostgreSQL configurada']) {
try {
DB::selectOne('SELECT 1');
$checks['Ligação à base de dados'] = true;
$checks['Extensão PostGIS instalada'] = (bool) DB::selectOne("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS installed")->installed;
$checks['Tabelas principais instaladas'] = collect(['migrations', 'users', 'sectors', 'opportunities', 'sessions', 'cache', 'jobs'])
->every(fn (string $table): bool => Schema::hasTable($table));
} catch (\Throwable) {
$checks['Ligação à base de dados'] = false;
}
}
foreach ($checks as $label => $passed) {
$this->line(($passed ? '[OK] ' : '[FALHA] ').$label);
}
if (in_array(false, $checks, true)) {
$this->error('Arranque interrompido. Consulte deploy/local/README.md e corrija os itens acima.');
return self::FAILURE;
}
return self::SUCCESS;
}
}
@@ -25,6 +25,11 @@ class EditorialArticleController extends Controller
: response()->view('management.editorial-installation', ['plan' => $installation->preview($request->user()), 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store');
}
public function showMfa(Request $request)
{
return $this->confirmMfa($request);
}
public function confirmMfa(Request $request)
{
$actor = $request->user();
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\DB;
final class IsolatedQaDatabase
{
public static function allowsSchemaChanges(): bool
{
$connection = config('database.connections.pgsql');
if (! app()->environment('testing') || config('database.default') !== 'pgsql'
|| ! in_array($connection['host'], ['127.0.0.1', 'localhost'], true)
|| (string) $connection['port'] !== '55441' || ! empty($connection['url'])
|| ! str_ends_with($connection['database'], '_test')) {
return false;
}
$identity = DB::selectOne('SELECT current_database() AS database, inet_server_port() AS port');
return $identity->database === $connection['database'] && (int) $identity->port === 55441;
}
}
+5 -1
View File
@@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
return Application::configure(basePath: dirname(__DIR__))
$app = Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
@@ -106,3 +106,7 @@ return Application::configure(basePath: dirname(__DIR__))
return $response;
});
})->create();
(require __DIR__.'/storage.php')($app->basePath(), $app->storagePath());
return $app;
+17
View File
@@ -0,0 +1,17 @@
<?php
// Runtime directories are not distributed with application data. Prepare them
// before Laravel loads view configuration (which resolves the compiled path).
return static function (string $basePath, string $storagePath): void {
foreach (['app/private', 'app/public', 'framework/cache/data', 'framework/sessions', 'framework/views', 'logs'] as $directory) {
$path = $storagePath.'/'.$directory;
if (! is_dir($path) && ! @mkdir($path, 0775, true) && ! is_dir($path)) {
throw new RuntimeException('Cannot create Laravel runtime directory: '.$path);
}
}
$cachePath = $basePath.'/bootstrap/cache';
if (! is_dir($cachePath) && ! @mkdir($cachePath, 0775, true) && ! is_dir($cachePath)) {
throw new RuntimeException('Cannot create Laravel bootstrap cache: '.$cachePath);
}
};
+1
View File
@@ -52,6 +52,7 @@
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"@php artisan portal:dev-check",
"@php artisan dev"
],
"lint": [
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (!config('investment-zone-profile.authenticated_installation')) { throw new RuntimeException('Use a ação autenticada de instalação nominal da ficha.'); }
if (!config('investment-zone-profile.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()) { throw new RuntimeException('Use a ação autenticada de instalação nominal da ficha.'); }
Schema::table('investment_zones',fn(Blueprint $table)=>$table->text('editorial_draft')->nullable());
}
public function down(): void
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if(!config('investment-zone-details.authenticated_installation')){throw new RuntimeException('Use a instalação autenticada nominal L1.');}
if(!config('investment-zone-details.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()){throw new RuntimeException('Use a instalação autenticada nominal L1.');}
if(!Schema::hasColumn('investment_zones','editorial_draft') || !DB::table('migrations')->where('migration','2026_10_16_130000_add_investment_zone_editorial_draft')->exists()){throw new RuntimeException('Instale/verifique primeiro a base 130000.');}
Schema::table('investment_zones',fn(Blueprint $table)=>$table->jsonb('operational_details')->nullable());
}
@@ -8,7 +8,7 @@ return new class extends Migration
{
public function up(): void
{
if (! config('investor-context.authenticated_installation')) {
if (! config('investor-context.authenticated_installation') && ! \App\Support\IsolatedQaDatabase::allowsSchemaChanges()) {
throw new RuntimeException('Use a ação autenticada nominal de instalação do contexto.');
}
Schema::create('investor_navigation_preferences', function (Blueprint $t) {
@@ -9,7 +9,7 @@ return new class extends Migration
{
public function up(): void
{
if (!config('editorial.authenticated_installation')) { throw new RuntimeException('Use a instalação editorial autenticada.'); }
if (!config('editorial.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()) { throw new RuntimeException('Use a instalação editorial autenticada.'); }
Schema::table('portal_resources', fn (Blueprint $t) => $t->jsonb('editorial_content')->nullable());
}
+86
View File
@@ -0,0 +1,86 @@
# Execução local no Windows
Ambiente de demonstração novo autorizado em 19/10/2026. PostgreSQL/PostGIS
isolado em `127.0.0.1:55441`, base `investmoz_local_test`, volume Docker
`investmoz-local-qa_postgres-qa`. Não contém os dados do portal activo.
O MySQL existente não serve este esquema PostgreSQL e é preservado.
## Arrancar
Inicie Docker Desktop. Na raiz do projecto:
```powershell
./deploy/local/start.ps1
```
Com a base já iniciada e preparada, basta:
```powershell
composer run dev
```
Abra http://127.0.0.1:8000. O comando inicia Laravel, o worker da fila e Vite.
Use Ctrl+C para terminar. A base conserva os dados entre arranques.
O script de arranque executa estes passos, parando no primeiro erro:
```powershell
docker compose --env-file .env -f deploy/local/compose.yaml up -d --wait
php tools/local-qa-bootstrap.php
composer run dev
```
O preparador verifica configuração e identidade real da base antes de migrar.
Só admite `investmoz_local_test`, TCP local e porta real 55441, sem DB_URL nem
configuração em cache. Executa migrations em `testing`, como exigem as guardas
existentes. Só cria os dados iniciais quando sectores e utilizadores estão vazios;
um novo arranque não redefine contas. Nunca usa `migrate:fresh`.
As quatro migrations mais recentes também admitem QA com identidade verificada
por `IsolatedQaDatabase`. A alternativa de instalação autenticada no activo é
preservada. A excepção QA é uma escolha técnica consistente com as migrations
anteriores, sem activar funcionalidades ou publicar dados.
Conta do seeder existente, exclusivamente para esta demonstração:
`test@example.com` / `password`. Não é um administrador do portal.
As regras existentes de autenticação, MFA e instalação autenticada mantêm-se.
## Dependências e configuração
- PHP >= 8.4.1, com `pdo_pgsql` e `pgsql` activos no PHP do terminal.
- Node 24, Composer, dependências de `composer.lock` e `package-lock.json`.
- Docker Desktop com contentores Linux.
- `.env` local com PostgreSQL, porta 55441, base e utilizador do Compose e
`DB_PASSWORD` privado. A chave da aplicação existente deve ser preservada.
- O primeiro build obtém as fontes configuradas de fonts.bunny.net; precisa de rede.
O bootstrap cria as pastas de cache, vistas, sessões e logs antes da configuração
Laravel. `php artisan portal:dev-check` verifica os requisitos essenciais sem
alterar registos nem mostrar credenciais. Falhas interrompem `composer run dev`
antes de iniciar os três processos.
```powershell
php artisan portal:dev-check
php artisan wayfinder:generate --with-form
npm run types:check
npm run build
```
Os testes que usam RefreshDatabase devem usar outra base `investmoz_test` no
mesmo servidor QA; nunca a base de demonstração nem o activo. `tests/TestCase.php`
mantém as guardas e o bloqueio contra testes concorrentes.
## Origem e limites
Origem: pedido expresso de correcção e configuração local e escolha do responsável
por uma base nova de demonstração em 19/10/2026. Portas, Compose, pastas automáticas
e verificação de arranque são escolhas técnicas, sem novas regras organizacionais.
Aplicam-se as decisões Tempo de 12/10/2026 e a separação QA/activo de AGENTS.md.
Não constitui implantação em produção nem aceitação funcional global da matriz
existente em `docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv`.
Não remover o volume Docker para actualizar. O `.env` anterior e o `php.ini`
anterior à activação PostgreSQL foram conservados em `storage/app/private/`.
O correio local usa o canal log. Integrações externas e serviços opcionais precisam
das configurações próprias já previstas no projecto; este arranque não lhes
atribui autorização, publicação ou credenciais.
+62
View File
@@ -0,0 +1,62 @@
# Verificação local — 19/10/2026
Pedido: identificar e corrigir o arranque, configurar a plataforma e testar.
O responsável escolheu uma base nova de demonstração local.
## Plano executado e causas corrigidas
1. Reproduzido `Please provide a valid cache path`: faltavam as pastas
`storage/framework`. O bootstrap agora cria as pastas antes da configuração.
2. Substituída a configuração local MySQL incompatível por PostgreSQL/PostGIS
QA em `investmoz_local_test:55441`; activados `pdo_pgsql` e `pgsql` no PHP
usado pelo terminal. Configurações anteriores preservadas privadamente.
3. Preparado Docker Desktop e um contentor/volume próprios, ligado só a
`127.0.0.1`. O MySQL existente não foi removido nem alterado.
4. Aplicadas as 88 migrations e os seeders existentes. Quatro migrations
recentes não admitiam QA: passaram a reconhecer exclusivamente o alvo real
isolado, mantendo a alternativa autenticada para o activo. Guardas testadas
contra ambiente de produção, host/porta/base/URL indevidos e identidade real
diferente da configuração. Nenhuma flag foi apresentada como autenticação.
5. Completadas as dependências JavaScript em falta (`docx`, `jszip`) e separado
o destino GET da confirmação MFA editorial, evitando uma propriedade
duplicada no TypeScript gerado. O fluxo MFA existente foi preservado.
6. Corrigida a distribuição do manifesto económico: o hash existente corresponde
a CRLF, enquanto o checkout convertia o ficheiro para LF. Repostos os bytes
que correspondem ao SHA-256 já fixado, sem alterar conteúdo ou hash esperado;
`.gitattributes` preserva CRLF para esse ficheiro específico.
7. Criada a ligação `public/storage`, acrescentada a verificação prévia de
`composer run dev` e documentado o arranque repetível.
## Resultado observado
- `composer check-platform-reqs`: passou.
- `composer validate --no-check-publish`: válido; aviso preexistente sobre
versão exacta de `opis/json-schema`.
- Wayfinder com `--with-form`: passou.
- `npm run types:check`: passou.
- `npm run build`: passou, 2481 módulos; avisos de tamanho de chunks e
optimização opcional de fontes não interrompem o build.
- PHPUnit seleccionado: **13 testes, 39 asserções, todos passaram**, na base
separada `investmoz_test:55441`. Inclui login, página inicial, guardas QA,
criação idempotente das pastas e integridade do manifesto.
- Pint dos novos componentes PHP: passou. `git diff --check`: passou.
- Segundo `local-qa-bootstrap.php`: nada por migrar, dados e contas preservados,
seed omitido. `migrate:status`: 88 migrations aplicadas.
- `composer run dev`: Laravel em 8000, Vite em 5173 e fila a funcionar.
- HTTP 200: saúde, início, login, registo, catálogo, página de sector e APIs
públicas de início, sectores e pesquisa. Vite client: 200.
- Chrome: início, catálogo, sector, login, registo e painel após autenticação
renderizados; **zero erros JavaScript e zero respostas 5xx observadas**.
Provas: `outputs/local-startup-20261019/browser.json`, `home.png`, `dashboard.png`.
Origem das alterações: pedido expresso e opção do responsável por demonstração;
pastas, Compose, portos e excepção de migração em QA são escolhas técnicas.
Aplicam-se as decisões Tempo de 12/10/2026 e a separação QA/activo em AGENTS.md.
Referência na matriz existente: `MASTER-e1169a77-0376`, apenas como prova do
ambiente local. Não encerra os critérios globais da cláusula.
O catálogo novo pode apresentar colecções vazias: os dados do portal activo não
foram importados. A conta local é `test@example.com` / `password`; não recebe
privilégios administrativos. As integrações externas, o envio real de correio,
o scan de uploads e a aceitação funcional integral não foram demonstrados por
este ensaio de arranque. Mantêm-se as configurações e autorizações próprias.
+21
View File
@@ -0,0 +1,21 @@
name: investmoz-local-qa
services:
postgres:
image: postgis/postgis:17-3.5
command: ['postgres', '-p', '55441']
restart: unless-stopped
environment:
POSTGRES_DB: investmoz_local_test
POSTGRES_USER: investmoz_test
POSTGRES_PASSWORD: '${DB_PASSWORD:?Configure DB_PASSWORD in the local .env first}'
ports:
- '127.0.0.1:55441:55441'
volumes:
- postgres-qa:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -p 55441 -U investmoz_test -d investmoz_local_test']
interval: 5s
timeout: 5s
retries: 20
volumes:
postgres-qa:
+13
View File
@@ -0,0 +1,13 @@
$ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
Push-Location $projectRoot
try {
docker compose --progress plain --env-file .env -f deploy/local/compose.yaml up -d --wait --wait-timeout 120
if ($LASTEXITCODE -ne 0) { throw 'Inicie Docker Desktop e verifique o serviço PostgreSQL QA.' }
php tools/local-qa-bootstrap.php
if ($LASTEXITCODE -ne 0) { throw 'Preparação QA interrompida; veja a mensagem acima.' }
composer run dev
if ($LASTEXITCODE -ne 0) { throw 'Arranque interrompido; veja a mensagem acima.' }
} finally {
Pop-Location
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+294
View File
@@ -7,6 +7,300 @@
@custom-variant dark (&:is(.dark *));
/* Institutional shell adapted from Tempo; page content keeps its own theme. */
.tempo-header {
position: relative;
z-index: 40;
background: #090909;
color: #fff;
}
.tempo-logo-link {
display: inline-flex;
align-items: center;
border-radius: 0.25rem;
}
.tempo-nav-trigger {
display: flex;
min-height: 44px;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #eee;
transition:
color 0.2s,
background 0.2s;
}
.tempo-nav-trigger:hover,
.tempo-nav-trigger[data-state='open'] {
background: #ffffff0d;
color: #fff;
}
.tempo-nav-trigger[data-current-group] {
color: #fff;
box-shadow: inset 0 -2px #ed1c24;
}
.tempo-nav-trigger[data-state='open'] > svg {
transform: rotate(180deg);
}
@media (max-width: 640px) {
.tempo-nav-trigger {
gap: 0.25rem;
padding-inline: 0.25rem;
font-size: 0.75rem;
}
.tempo-nav-trigger > svg {
width: 12px;
height: 12px;
}
}
.tempo-utility-button {
display: flex;
min-height: 40px;
align-items: center;
gap: 0.4rem;
padding: 0.4rem;
border-radius: 0.5rem;
font-size: 0.75rem;
color: #e2e8f0;
}
.tempo-utility-button:hover {
background: #ffffff10;
}
.tempo-header-social {
display: flex;
width: 30px;
height: 40px;
align-items: center;
justify-content: center;
color: var(--social-color);
transition:
transform 0.2s,
opacity 0.2s;
border-radius: 0.4rem;
}
.tempo-header-social:hover {
transform: translateY(-2px);
opacity: 0.8;
}
@media (max-width: 360px) {
.tempo-header-social {
width: 26px;
}
}
.tempo-login-button {
display: inline-flex;
min-height: 40px;
align-items: center;
gap: 0.5rem;
border: 1px solid #ffffff50;
border-radius: 0.4rem;
padding: 0.5rem 0.85rem;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
transition:
background 0.2s,
border-color 0.2s;
}
.tempo-login-button:hover {
background: #ed1c24;
border-color: #ed1c24;
}
.tempo-header-actions {
order: 2;
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.tempo-interest-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0.5rem 0.75rem;
border: 1px solid #ed1c24;
border-radius: 0.4rem;
background: #ed1c24;
color: white;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
transition: background 0.2s;
}
.tempo-interest-button:hover {
background: #c9151c;
}
.tempo-panel-summary {
order: 4;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
min-height: 44px;
padding-block: 0.5rem;
border-top: 1px solid #ffffff1a;
font-size: 0.75rem;
}
.tempo-panel-summary a {
color: #fff;
text-underline-offset: 3px;
}
.tempo-panel-summary a:hover {
text-decoration: underline;
}
.tempo-panel-summary span {
color: #cbd5e1;
font-size: 0.6875rem;
white-space: nowrap;
}
@media (max-width: 480px) {
.tempo-header-actions {
flex-direction: column;
align-items: flex-end;
padding-block: 0.65rem;
}
}
@media (min-width: 1280px) {
.tempo-header-actions {
order: 6;
margin-left: 0;
}
.tempo-header-inner .tempo-nav-trigger {
padding-inline: 0.5rem;
font-size: 0.8125rem;
}
.tempo-panel-summary {
order: 0;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.1rem;
width: auto;
min-width: 102px;
border-top: 0;
border-left: 1px solid #ffffff1a;
padding-left: 0.75rem;
}
}
.tempo-header :is(a, button):focus-visible {
outline: 2px solid #fff;
outline-offset: 4px;
}
.tempo-dropdown {
z-index: 60;
border: 1px solid #e2e8f0;
border-radius: 0.8rem;
background: #fff;
color: #0f172a;
box-shadow: 0 18px 50px #00000026;
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
animation-duration: 160ms;
}
.tempo-menu-item {
min-height: 42px;
gap: 0.5rem;
border-radius: 0.4rem;
padding: 0.6rem 0.7rem;
font-size: 0.8125rem;
white-space: normal;
line-height: 1.5;
}
.tempo-menu-item[data-highlighted],
.tempo-menu-item:focus,
.tempo-menu-item[aria-current='page'] {
background: #fef2f2;
color: #b91c1c;
outline: none;
}
.tempo-footer {
margin-top: 2rem;
background: #050505;
color: #fff;
}
.tempo-footer-heading {
margin-bottom: 1.4rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.tempo-footer-link {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
font-size: 0.8125rem;
line-height: 1.5;
color: #e2e8f0;
transition:
color 0.2s,
transform 0.2s;
}
.tempo-footer-link > svg {
align-self: center;
color: #ed1c24;
flex-shrink: 0;
}
.tempo-footer-link:hover {
color: #fb7185;
transform: translateX(3px);
}
.tempo-contact-link {
display: flex;
align-items: center;
gap: 0.7rem;
transition: color 0.2s;
}
.tempo-contact-link:hover {
color: #fb7185;
}
.tempo-contact-icon {
display: flex;
width: 36px;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid #ffffff12;
border-radius: 0.65rem;
background: #ffffff08;
}
.tempo-footer-social {
display: flex;
width: 40px;
height: 44px;
align-items: center;
justify-content: center;
border: 1px solid #ffffff15;
border-radius: 0.65rem;
color: #fff;
transition:
color 0.2s,
border-color 0.2s,
transform 0.2s;
}
.tempo-footer-social:hover {
color: var(--social-color);
border-color: var(--social-color);
transform: translateY(-3px);
}
.tempo-footer :is(a, button):focus-visible {
outline: 2px solid #fb7185;
outline-offset: 4px;
}
@media (prefers-reduced-motion: reduce) {
.tempo-header *,
.tempo-footer *,
.tempo-dropdown {
animation-duration: 0ms !important;
transition-duration: 0ms !important;
}
}
@theme {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
+7 -16
View File
@@ -1,20 +1,11 @@
import { usePage } from '@inertiajs/react';
import AppLogoIcon from '@/components/app-logo-icon';
export default function AppLogo() {
const { name } = usePage().props;
return (
<>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md">
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">
{name}
</span>
</div>
</>
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo"
width="1787"
height="564"
className="h-8 w-auto max-w-full object-contain group-data-[collapsible=icon]:h-auto group-data-[collapsible=icon]:w-8"
/>
);
}
+187 -247
View File
@@ -1,16 +1,32 @@
import { useNavigationContext } from '@/components/navigation-context';
import type { ProfileAccess } from '@/types/profile-access';
import { Link, router, usePage } from '@inertiajs/react';
import { LogOut } from 'lucide-react';
import { logout } from '@/routes';
import { useEffect, useId, useRef, useState } from 'react';
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
import { ChevronDown, LogOut, UserRound } from 'lucide-react';
import { useState } from 'react';
import { useNavigationContext } from '@/components/navigation-context';
import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
import {
TempoLanguageSelector,
TempoWeather,
} from '@/components/tempo-header-tools';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
portalNavigation,
currentNavigationGroup,
currentNavigationItem,
type PortalNavItem,
} from '@/lib/portal-navigation';
import type { PortalNavItem } from '@/lib/portal-navigation';
import type { ProfileAccess } from '@/types/profile-access';
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
import { logout } from '@/routes';
export function PortalGroupedMenu() {
const { props, url } = usePage<{
@@ -23,10 +39,10 @@ export function PortalGroupedMenu() {
educationEnabled?: boolean;
aiEnabled?: boolean;
legacyContentEnabled?: boolean;
portalOperator?: string | null;
profileAccess?: ProfileAccess | null;
opportunityManagement?: OpportunityManagementAccess | null;
}>();
const { t, language, requestCount } = useTempoUi();
const navigation = useNavigationContext();
const groups = portalNavigation({
context: navigation.view
@@ -44,159 +60,77 @@ export function PortalGroupedMenu() {
});
const current = currentNavigationGroup(groups, url);
const currentItem = currentNavigationItem(groups, url);
const [open, setOpen] = useState<string | null>(null);
const [branch, setBranch] = useState<string | null>(null);
const [offset, setOffset] = useState(0);
const [loggingOut, setLoggingOut] = useState(false);
const root = useRef<HTMLElement>(null);
const id = useId();
useEffect(() => {
const position = () => {
const button = open
? document.getElementById(`${id}-${open}-button`)
: null;
if (!button || window.innerWidth < 640) {
setOffset(0);
return;
}
const rect = button.getBoundingClientRect();
const width = Math.min(288, window.innerWidth - 32);
setOffset(
Math.max(
16,
Math.min(rect.left, window.innerWidth - width - 16),
) - rect.left,
);
};
position();
window.addEventListener('resize', position);
return () => window.removeEventListener('resize', position);
}, [open, id]);
useEffect(() => {
const outside = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!root.current?.contains(event.target)
) {
setOpen(null);
setBranch(null);
}
};
document.addEventListener('pointerdown', outside);
return () => document.removeEventListener('pointerdown', outside);
}, []);
const close = () => {
setOpen(null);
setBranch(null);
};
const items = (children: PortalNavItem[]) =>
children.map((item) => (
<li key={item.id}>
{item.children ? (
<>
<button
type="button"
id={`${id}-${item.id}-button`}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left font-semibold hover:bg-emerald-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700"
aria-expanded={branch === item.id}
aria-controls={`${id}-${item.id}`}
onClick={() =>
setBranch(branch === item.id ? null : item.id)
}
>
{item.label}
<span aria-hidden="true">
{branch === item.id ? '−' : '+'}
</span>
</button>
{branch === item.id && (
<ul
id={`${id}-${item.id}`}
className="ml-3 space-y-1 border-l-2 border-emerald-200 pl-2"
>
{items(item.children)}
</ul>
)}
</>
) : (
children.map((item) =>
item.children ? (
<DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
{t(item.label)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={12}
className="tempo-dropdown max-h-[min(70vh,34rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
{items(item.children)}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem
key={item.id}
asChild
className="tempo-menu-item"
>
<Link
href={item.href!}
onClick={close}
aria-current={
currentItem === item.id ? 'page' : undefined
}
className="block rounded-lg px-3 py-2 hover:bg-emerald-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700"
>
{item.label}
{t(item.label)}
</Link>
)}
</li>
));
</DropdownMenuItem>
),
);
const accountGroups = groups.filter(
(group) =>
!['home', 'discovery', 'realization', 'support'].includes(group.id),
);
return (
<header
ref={root}
className="relative z-40 border-b border-slate-200 bg-white text-slate-900"
onKeyDown={(event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
if (branch) {
document.getElementById(`${id}-${branch}-button`)?.focus();
setBranch(null);
} else if (open) {
document.getElementById(`${id}-${open}-button`)?.focus();
close();
}
}}
>
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<Link
href="/"
className="text-xl font-extrabold tracking-tight text-emerald-900"
>
InvesteMoz
</Link>
<p className="max-w-2xl text-xs leading-relaxed text-slate-600">
Iniciativa privada e independente de apoio ao
investimento. Orientação e acompanhamento internos;
decisões oficiais cabem às instituições competentes.
{props.portalOperator && (
<> Operador: {props.portalOperator}.</>
)}
</p>
</div>
{props.auth?.user && (
<nav
aria-label="Sessão da conta"
className="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-lg bg-slate-50 px-3 py-2"
>
<p className="min-w-0 text-sm [overflow-wrap:anywhere] break-words text-slate-700">
Sessão:{' '}
{props.auth.user.name || props.auth.user.email}
</p>
<Link
href={logout()}
as="button"
type="button"
disabled={loggingOut}
onClick={() => {
close();
router.flushAll();
}}
onStart={() => setLoggingOut(true)}
onFinish={() => setLoggingOut(false)}
data-test="portal-logout-button"
className="inline-flex min-h-11 shrink-0 items-center gap-2 rounded-lg border border-slate-400 bg-white px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 disabled:cursor-wait disabled:opacity-60"
>
<LogOut aria-hidden="true" className="size-4" />
{loggingOut
? 'A terminar sessão…'
: 'Terminar sessão'}
</Link>
</nav>
)}
<nav aria-label="Áreas do portal">
<ul className="flex flex-wrap gap-2">
<header className="tempo-header" lang={language}>
<div className="tempo-header-inner mx-auto flex max-w-[1440px] flex-wrap items-center justify-between gap-x-3 gap-y-0 px-4 sm:px-6 xl:min-h-22">
<Link
href="/"
aria-label="Tempo — início"
className="tempo-logo-link my-4 shrink-0"
>
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo"
width="1787"
height="564"
className="h-9 w-auto object-contain sm:h-10"
/>
</Link>
<nav
aria-label="Áreas do portal"
className="order-3 w-full border-t border-white/10 xl:order-none xl:w-auto xl:flex-1 xl:border-0"
>
<ul className="flex items-center justify-between gap-0 py-2 xl:justify-center xl:py-0">
<li>
<Link
href="/"
className="tempo-nav-trigger"
aria-current={
current === 'home' ? 'page' : undefined
}
data-current-group={
current === 'home' || undefined
}
>
{t('Início')}
</Link>
</li>
{groups
.filter((group) =>
[
@@ -206,109 +140,115 @@ export function PortalGroupedMenu() {
].includes(group.id),
)
.map((group) => (
<li
key={group.id}
className="relative w-full sm:w-auto"
>
<button
id={`${id}-${group.id}-button`}
type="button"
className={`flex min-h-11 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-left text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 ${current === group.id ? 'border-emerald-700 bg-emerald-50 text-emerald-950' : 'border-slate-200 hover:bg-slate-50'}`}
aria-expanded={open === group.id}
aria-controls={`${id}-${group.id}`}
data-current-group={
current === group.id || undefined
}
onClick={() => {
setOpen(
open === group.id
? null
: group.id,
);
setBranch(null);
}}
>
{group.label}
<span aria-hidden="true">
{open === group.id ? '▴' : '▾'}
</span>
{current === group.id && (
<span className="sr-only">
Grupo actual
</span>
)}
</button>
{open === group.id && (
<ul
id={`${id}-${group.id}`}
style={{
transform: `translateX(${offset}px)`,
}}
className="mt-2 max-h-[min(70vh,32rem)] w-full space-y-1 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2 text-sm shadow-xl sm:absolute sm:top-full sm:left-0 sm:z-40 sm:w-72 sm:max-w-[calc(100vw-2rem)]"
<li key={group.id}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-nav-trigger"
data-current-group={
current === group.id ||
undefined
}
>
{t(group.label)}
<ChevronDown
size={14}
aria-hidden="true"
/>
{current === group.id && (
<span className="sr-only">
Grupo actual
</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
<li className="border-b border-slate-100 px-3 pb-2 text-xs leading-relaxed text-slate-600">
{group.id === 'discovery'
? 'Conhecer possibilidades, sectores e localizações.'
: group.id === 'realization'
? 'Preparar o investimento e os seus requisitos.'
: 'Encontrar serviços, acompanhar e pedir apoio.'}
</li>
{items(group.children ?? [])}
</ul>
)}
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
</nav>
<nav
aria-label="Conta e trabalho por papel"
className="mt-3 grid gap-3 sm:grid-cols-2"
>
<details className="rounded border p-3">
<summary className="cursor-pointer font-semibold">
A minha conta e atividade
</summary>
<ul className="mt-2 space-y-1">
{items(
groups.filter((g) =>
['account', 'personal-activity'].includes(
g.id,
),
),
)}
</ul>
</details>
{groups.some((g) =>
[
'management',
'profile-opportunities',
'business-work',
].includes(g.id),
) && (
<details className="rounded border p-3">
<summary className="cursor-pointer font-semibold">
Área de trabalho — criação, revisão, publicação
e administração
</summary>
<p className="my-2 text-xs">
Destinos disponíveis segundo o papel e âmbito
existentes; não exige percorrer a jornada do
investidor.
</p>
<ul className="space-y-1">
{items(
groups.filter((g) =>
[
'management',
'profile-opportunities',
'business-work',
].includes(g.id),
),
)}
</ul>
</details>
<div className="tempo-panel-summary">
<Link href="/investidor/manifestacoes">
{t('O meu painel')}
</Link>
<span aria-live="polite" aria-atomic="true">
{requestCount}{' '}
{t(
requestCount === 1
? 'pedido recebido'
: 'pedidos recebidos',
)}
</span>
</div>
<div className="order-5 flex w-full items-center justify-between gap-2 border-t border-white/10 py-2 xl:order-none xl:w-auto xl:justify-end xl:border-0 xl:py-0">
<div className="flex items-center gap-1 sm:gap-2">
<TempoWeather />
<TempoLanguageSelector />
</div>
<TempoSocialLinks />
</div>
<div className="tempo-header-actions">
{props.auth?.user ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="tempo-login-button">
<UserRound size={16} aria-hidden="true" />
<span className="max-w-24 truncate">
{props.auth.user.name || t('A minha área')}
</span>
<ChevronDown size={12} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[75vh] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
<DropdownMenuLabel className="truncate text-xs text-slate-500">
{props.auth.user.email}
</DropdownMenuLabel>
{items(accountGroups)}
<DropdownMenuSeparator />
<DropdownMenuItem
asChild
className="tempo-menu-item text-red-700"
>
<Link
href={logout()}
as="button"
type="button"
disabled={loggingOut}
data-test="portal-logout-button"
onClick={() => router.flushAll()}
onStart={() => setLoggingOut(true)}
onFinish={() => setLoggingOut(false)}
>
<LogOut size={16} aria-hidden="true" />
{loggingOut
? 'A terminar sessão…'
: t('Terminar sessão')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/login" className="tempo-login-button">
<UserRound size={15} aria-hidden="true" />
{t('Entrar')}
</Link>
)}
</nav>
<Link
href="/manifestar-interesse"
className="tempo-interest-button"
>
{t('Manifestar interesse')}
</Link>
</div>
</div>
</header>
);
+288
View File
@@ -0,0 +1,288 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { Facebook, Instagram, Linkedin, Youtube } from 'lucide-react';
import type { CSSProperties, ReactNode } from 'react';
export const tempoSocialLinks = [
{
name: 'Facebook',
href: 'https://www.facebook.com/revistatempo.mz/',
icon: Facebook,
color: '#1877f2',
},
{
name: 'Instagram',
href: 'https://www.instagram.com/revistatempo.mz/',
icon: Instagram,
color: '#e1306c',
},
{
name: 'LinkedIn',
href: 'https://www.linkedin.com/company/revistatempo/',
icon: Linkedin,
color: '#38a5df',
},
{
name: 'YouTube',
href: 'https://www.youtube.com/@RevistaTempoMz/',
icon: Youtube,
color: '#ff3333',
},
];
const menuTranslations = [
['O meu painel', 'My dashboard', 'Mon espace', 'Mi panel'],
[
'Manifestar interesse',
'Express interest',
'Manifester un intérêt',
'Manifestar interés',
],
[
'pedido recebido',
'request received',
'demande reçue',
'solicitud recibida',
],
[
'pedidos recebidos',
'requests received',
'demandes reçues',
'solicitudes recibidas',
],
[
'Porquê investir em Moçambique',
'Why invest in Mozambique',
'Pourquoi investir au Mozambique',
'Por qué invertir en Mozambique',
],
[
'Oportunidades de investimento',
'Investment opportunities',
'Opportunités d’investissement',
'Oportunidades de inversión',
],
[
'Sectores de investimento',
'Investment sectors',
'Secteurs d’investissement',
'Sectores de inversión',
],
['Localizações', 'Locations', 'Localisations', 'Ubicaciones'],
[
'Iniciar investimento',
'Start an investment',
'Démarrer un investissement',
'Iniciar una inversión',
],
[
'Incentivos e simulador fiscal',
'Incentives and tax simulator',
'Incitations et simulateur fiscal',
'Incentivos y simulador fiscal',
],
['Licenciamento', 'Licensing', 'Autorisations', 'Licencias'],
['Acesso à terra', 'Land access', 'Accès à la terre', 'Acceso a la tierra'],
[
'Serviços ao investidor',
'Investor services',
'Services aux investisseurs',
'Servicios al inversor',
],
[
'Acompanhamento do projecto',
'Project follow-up',
'Suivi du projet',
'Seguimiento del proyecto',
],
[
'Apoio ao investidor',
'Investor support',
'Assistance aux investisseurs',
'Apoyo al inversor',
],
[
'Dossiers e percursos',
'Investment files and journeys',
'Dossiers et parcours',
'Expedientes y recorridos',
],
[
'Perfil e empresas',
'Profile and companies',
'Profil et entreprises',
'Perfil y empresas',
],
[
'Pedidos e tarefas de acompanhamento',
'Requests and follow-up tasks',
'Demandes et tâches de suivi',
'Solicitudes y tareas de seguimiento',
],
[
'Manifestações e favoritos',
'Expressions of interest and favourites',
'Manifestations d’intérêt et favoris',
'Manifestaciones de interés y favoritos',
],
['Notificações', 'Notifications', 'Notifications', 'Notificaciones'],
['A minha atividade', 'My activity', 'Mes activités', 'Mi actividad'],
[
'As minhas parcerias',
'My partnerships',
'Mes partenariats',
'Mis alianzas',
],
['Os meus negócios', 'My businesses', 'Mes entreprises', 'Mis negocios'],
];
const translatedMenu = (column: number) =>
Object.fromEntries(menuTranslations.map((row) => [row[0], row[column]]));
const translations: Record<string, Record<string, string>> = {
en: {
...translatedMenu(1),
Descoberta: 'Discover',
Realização: 'Invest',
Suporte: 'Support',
Entrar: 'Sign in',
'A minha área': 'My account',
'Terminar sessão': 'Sign out',
Contactos: 'Contacts',
Navegação: 'Navigation',
Início: 'Home',
Oportunidades: 'Opportunities',
Sectores: 'Sectors',
Legislações: 'Legislation',
'Guia do investidor': 'Investor guide',
Contacto: 'Contact',
'Política de Privacidade': 'Privacy Policy',
'Termos de Uso': 'Terms of Use',
'Todos os direitos reservados.': 'All rights reserved.',
'Siga a Revista Tempo para actualizações exclusivas.':
'Follow Revista Tempo for exclusive updates.',
'Número de registo': 'Registration number',
'A minha conta e atividade': 'My account and activity',
'Área de trabalho': 'Workspace',
},
fr: {
...translatedMenu(2),
Descoberta: 'Découvrir',
Realização: 'Investir',
Suporte: 'Assistance',
Entrar: 'Connexion',
'A minha área': 'Mon compte',
'Terminar sessão': 'Déconnexion',
Contactos: 'Contacts',
Navegação: 'Navigation',
Início: 'Accueil',
Oportunidades: 'Opportunités',
Sectores: 'Secteurs',
Legislações: 'Législation',
'Guia do investidor': 'Guide de l’investisseur',
Contacto: 'Contact',
'Política de Privacidade': 'Politique de confidentialité',
'Termos de Uso': 'Conditions d’utilisation',
'Todos os direitos reservados.': 'Tous droits réservés.',
'Siga a Revista Tempo para actualizações exclusivas.':
'Suivez Revista Tempo pour des actualités exclusives.',
'Número de registo': 'Numéro d’enregistrement',
'A minha conta e atividade': 'Mon compte et mes activités',
'Área de trabalho': 'Espace de travail',
},
es: {
...translatedMenu(3),
Descoberta: 'Descubrir',
Realização: 'Invertir',
Suporte: 'Soporte',
Entrar: 'Entrar',
'A minha área': 'Mi cuenta',
'Terminar sessão': 'Cerrar sesión',
Contactos: 'Contactos',
Navegação: 'Navegación',
Início: 'Inicio',
Oportunidades: 'Oportunidades',
Sectores: 'Sectores',
Legislações: 'Legislación',
'Guia do investidor': 'Guía del inversor',
Contacto: 'Contacto',
'Política de Privacidade': 'Política de privacidad',
'Termos de Uso': 'Términos de uso',
'Todos os direitos reservados.': 'Todos los derechos reservados.',
'Siga a Revista Tempo para actualizações exclusivas.':
'Siga Revista Tempo para recibir novedades exclusivas.',
'Número de registo': 'Número de registro',
'A minha conta e atividade': 'Mi cuenta y actividad',
'Área de trabalho': 'Área de trabajo',
},
};
const TempoUi = createContext({
language: 'pt',
setLanguage: (_language: string) => {},
t: (text: string) => text,
requestCount: 0,
registerSubmittedInterest: () => {},
});
export function TempoUiProvider({ children }: { children: ReactNode }) {
const [language, setCurrentLanguage] = useState('pt');
const [requestCount, setRequestCount] = useState(0);
useEffect(() => {
try {
const saved = localStorage.getItem('tempo-navigation-language');
if (saved && ['pt', 'en', 'fr', 'es'].includes(saved))
setCurrentLanguage(saved);
} catch {
/* Navigation remains usable without browser storage. */
}
}, []);
const setLanguage = (next: string) => {
setCurrentLanguage(next);
try {
localStorage.setItem('tempo-navigation-language', next);
} catch {
/* Optional persistence. */
}
};
return (
<TempoUi.Provider
value={{
language,
setLanguage,
t: (text) => translations[language]?.[text] ?? text,
requestCount,
registerSubmittedInterest: () =>
setRequestCount((count) => count + 1),
}}
>
{children}
</TempoUi.Provider>
);
}
export const useTempoUi = () => useContext(TempoUi);
export function TempoSocialLinks({ footer = false }: { footer?: boolean }) {
return (
<div
className="flex items-center gap-1"
aria-label="Redes sociais da Revista Tempo"
>
{tempoSocialLinks.map(({ name, href, icon: Icon, color }) => (
<a
key={name}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={`Revista Tempo — ${name}`}
title={name}
className={
footer ? 'tempo-footer-social' : 'tempo-header-social'
}
style={{ '--social-color': color } as CSSProperties}
>
<Icon size={footer ? 19 : 16} aria-hidden="true" />
</a>
))}
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { Link } from '@inertiajs/react';
import { ChevronRight, Mail, MapPin, Phone } from 'lucide-react';
import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
const navigation = [
['/', 'Início'],
['/apoio', 'Contacto'],
];
export function TempoFooter() {
const { t, language } = useTempoUi();
return (
<footer className="tempo-footer" lang={language}>
<div className="mx-auto grid max-w-[1440px] gap-9 px-5 py-12 sm:grid-cols-2 sm:px-8 lg:grid-cols-[1.25fr_1.2fr_1fr_1fr_1.1fr] lg:gap-8 lg:py-14">
<div className="space-y-6">
<Link href="/" aria-label="Tempo — início">
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo"
width="1787"
height="564"
className="h-11 w-auto object-contain"
loading="lazy"
/>
</Link>
<div className="flex items-start gap-3">
<MapPin
className="mt-1 size-5 shrink-0 text-[#ed1c24]"
aria-hidden="true"
/>
<address className="border-l border-white/15 pl-3 text-sm leading-7 text-slate-300 not-italic">
Av. Ahmed Sekou Touré, 1126 RC
<br />
Maputo Cidade, Moçambique
</address>
</div>
</div>
<div>
<h2 className="tempo-footer-heading">{t('Contactos')}</h2>
<div className="space-y-3">
{[
['+258868010880', '+258 86 801 0880'],
['+258833219644', '+258 83 321 9644'],
].map(([number, label]) => (
<a
key={number}
href={`tel:${number}`}
className="tempo-contact-link"
>
<span className="tempo-contact-icon">
<Phone size={16} aria-hidden="true" />
</span>
<strong className="text-sm font-medium">
{label}
</strong>
</a>
))}
<a
href="mailto:info@tempo.co.mz"
className="tempo-contact-link"
>
<span className="tempo-contact-icon">
<Mail size={16} aria-hidden="true" />
</span>
<strong className="text-sm font-medium [overflow-wrap:anywhere]">
comercial@tempo.co.mz
</strong>
</a>
</div>
</div>
<nav aria-label="Navegação do rodapé">
<h2 className="tempo-footer-heading">{t('Navegação')}</h2>
<ul className="space-y-3">
{navigation.map(([href, label]) => (
<li key={href}>
<Link href={href} className="tempo-footer-link">
<ChevronRight
size={13}
aria-hidden="true"
/>
{t(label)}
</Link>
</li>
))}
</ul>
</nav>
<nav aria-label="Informação legal">
<h2 className="tempo-footer-heading">Legal</h2>
<ul className="space-y-3">
{[
[
'politica-de-privacidade',
'Política de Privacidade',
],
['termos-de-uso', 'Termos de Uso'],
].map(([path, label]) => (
<li key={path}>
<Link
href={`/${path}`}
className="tempo-footer-link"
>
<ChevronRight
size={13}
aria-hidden="true"
/>
{t(label)}
</Link>
</li>
))}
</ul>
</nav>
<div>
<h2 className="tempo-footer-heading">Social</h2>
<p className="mb-5 text-sm leading-6 text-slate-300">
{t(
'Siga a Revista Tempo para actualizações exclusivas.',
)}
</p>
<TempoSocialLinks footer />
</div>
</div>
<div className="border-t border-white/10">
<div className="mx-auto flex max-w-[1440px] flex-col justify-between gap-3 px-5 py-5 text-[11px] tracking-wide text-slate-400 sm:flex-row sm:px-8">
<p>
© {new Date().getFullYear()}{' '}
<span className="text-[#ed1c24]">Tempo</span>.{' '}
{t('Todos os direitos reservados.')}
</p>
<p>{t('Número de registo')}: 13/GABINFO-DE/: 2003</p>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import { ChevronDown, CloudSun, Globe, MapPin } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTempoUi } from '@/components/tempo-brand';
type Weather = { temp: number; description: string; humidity?: number };
export function TempoWeather() {
const { language } = useTempoUi();
const [weather, setWeather] = useState<Weather | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
fetch(`https://teste-info.tempo.co.mz/api/weather?lang=${language}`, {
signal: controller.signal,
credentials: 'omit',
})
.then((response) => (response.ok ? response.json() : null))
.then((data: { current?: Weather } | null) => {
if (data?.current && Number.isFinite(data.current.temp))
setWeather(data.current);
})
.catch(() => {
/* No guessed temperatures when the source is unavailable. */
})
.finally(() => {
clearTimeout(timer);
setLoading(false);
});
return () => {
clearTimeout(timer);
controller.abort();
};
}, [language]);
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-utility-button"
aria-label="Temperatura em Maputo"
>
<CloudSun
size={19}
aria-hidden="true"
className="text-amber-300"
/>
<span>
{weather
? `${Math.round(weather.temp)}°C`
: loading
? '…'
: 'Maputo'}
</span>
<ChevronDown size={12} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={12}
className="tempo-dropdown w-60 p-4"
>
<p className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-500">
<MapPin size={14} /> Maputo, Moçambique
</p>
{weather ? (
<>
<p className="text-3xl font-semibold text-slate-950">
{Math.round(weather.temp)}°C
</p>
<p className="mt-1 text-sm text-slate-600">
{weather.description}
</p>
{weather.humidity !== undefined && (
<p className="mt-3 text-xs text-slate-500">
Humidade: {weather.humidity}%
</p>
)}
</>
) : (
<p className="text-sm text-slate-600">
{loading
? 'A consultar a temperatura…'
: 'Temperatura temporariamente indisponível.'}
</p>
)}
<p className="mt-3 text-[11px] text-slate-400">
Meteorologia · Tempo
</p>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function TempoLanguageSelector() {
const { language, setLanguage } = useTempoUi();
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-utility-button"
aria-label="Idioma da navegação"
>
<Globe size={16} aria-hidden="true" />
<span className="text-xs font-semibold uppercase">
{language}
</span>
<ChevronDown size={12} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={12}
className="tempo-dropdown min-w-44"
>
<DropdownMenuLabel className="text-xs text-slate-500">
Navegação institucional
</DropdownMenuLabel>
{[
['pt', 'Português'],
['en', 'English'],
['fr', 'Français'],
['es', 'Español'],
].map(([code, name]) => (
<DropdownMenuItem
key={code}
onSelect={() => setLanguage(code)}
className={`tempo-menu-item ${language === code ? 'bg-red-50 text-red-700' : ''}`}
>
<span className="w-6 text-[10px] font-bold uppercase">
{code}
</span>
{name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+73 -64
View File
@@ -1,6 +1,8 @@
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 { TempoFooter } from '@/components/tempo-footer';
import type { ReactNode } from 'react';
import {
NavigationContextProvider,
@@ -21,6 +23,8 @@ export const portalLinks = [
['/apoio', 'Apoio ao Investidor'],
] as const;
const names: Record<string, string> = {
'/politica-de-privacidade': 'Política de Privacidade',
'/termos-de-uso': 'Termos de Uso',
'/porque-investir': 'Porquê investir em Moçambique',
'/investimentos/iniciar': 'Iniciar ou continuar investimento',
'/explorar/agronegocio': 'Explorar agronegócio',
@@ -99,80 +103,85 @@ export default function PortalNavigation({
? 'Definições da conta'
: 'Detalhes');
return (
<NavigationContextProvider>
<MenuPlacement.Provider value={true}>
<div
className={`pb-8 lg:pb-24 [&_button]:scroll-mb-24 [&_input]:scroll-mb-24 [&_select]:scroll-mb-24 [&_textarea]:scroll-mb-24 ${printable ? 'print:pb-0' : ''}`}
>
<nav
aria-label="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"
<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"
className={printable ? 'print:hidden' : undefined}
>
Saltar para o conteúdo
</a>
</nav>
{portalShell && (
<div className={printable ? 'print:hidden' : undefined}>
<PortalMenu primary />
</div>
)}
<nav
aria-label="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' : ''}`}
>
{path === '/' ? (
<span aria-current="page">Início</span>
) : (
<>
<Link href="/" className="underline">
Início
</Link>
<span aria-hidden="true">/</span>
{parent && (
<>
<Link
href={parent}
className="underline"
>
{names[parent]}
</Link>
<span aria-hidden="true">/</span>
</>
)}
<span aria-current="page">{title}</span>
</>
)}
</nav>
<div id="portal-content" tabIndex={-1}>
<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
</a>
</nav>
{portalShell && (
<div
className={
printable ? 'print:hidden' : undefined
}
>
<NavigationContextSummary />
<PortalMenu primary />
</div>
)}
{children}
</div>
<aside
aria-label="Contacto de apoio ao investidor"
className={printable ? 'print:hidden' : undefined}
>
<a
href="/apoio"
className="mx-3 mt-4 block max-w-[calc(100%-1.5rem)] rounded-xl bg-emerald-800 px-5 py-3 text-center text-sm font-semibold text-white shadow-xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-600"
{path !== '/' && (
<nav
aria-label="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
</Link>
<span aria-hidden="true">/</span>
{parent && (
<>
<Link
href={parent}
className="underline"
>
{names[parent]}
</Link>
<span aria-hidden="true">/</span>
</>
)}
<span aria-current="page">{title}</span>
</>
</nav>
)}
<div id="portal-content" tabIndex={-1}>
{portalShell && (
<div
className={
printable ? 'print:hidden' : undefined
}
>
<NavigationContextSummary />
</div>
)}
{children}
</div>
<aside
aria-label="Contacto de apoio ao investidor"
className={printable ? 'print:hidden' : undefined}
>
Contactar Apoio ao Investidor
</a>
</aside>
</div>
</MenuPlacement.Provider>
</NavigationContextProvider>
<a
href="/apoio"
className="mx-3 mt-4 block max-w-[calc(100%-1.5rem)] rounded-xl bg-emerald-800 px-5 py-3 text-center text-sm font-semibold text-white shadow-xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-600"
>
Contactar Apoio ao Investidor
</a>
</aside>
<div className={printable ? 'print:hidden' : undefined}>
<TempoFooter />
</div>
</div>
</MenuPlacement.Provider>
</NavigationContextProvider>
</TempoUiProvider>
);
}
+1 -1
View File
@@ -440,11 +440,11 @@ export function currentNavigationItem(
export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
const previous = legacyPortalNavigation(access);
return uniqueNavigationDestinations([
link('home', 'Início', '/'),
{
id: 'discovery',
label: 'Descoberta',
children: [
link('home', 'Início', '/'),
link(
'why',
'Porquê investir em Moçambique',
+16 -72
View File
@@ -1,6 +1,7 @@
import { createContext, useContext, useRef, useState } from 'react';
import { router } from '@inertiajs/react';
import { PortalMenu } from '@/layouts/portal-navigation';
import { useTempoUi } from '@/components/tempo-brand';
import { ProvinceSelect } from '@/components/investment-territory';
import { MATURITY_LEVELS } from '@/lib/investment-maturity';
import CountrySelect, { type Country } from '@/components/country-select';
@@ -94,48 +95,6 @@ function QueryStatus({
/* Componentes */
/* ------------------------------------------------------------------ */
function TopBar({
onNavigate,
requestCount,
}: {
onNavigate: (view: View) => void;
requestCount: number;
}) {
return (
<nav
aria-label="Navegação das manifestações"
className="sticky top-0 z-30 border-b border-slate-200 bg-white/95 backdrop-blur"
>
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-5 py-4">
<button
onClick={() => onNavigate('home')}
className="flex items-baseline gap-1 font-black tracking-tight text-slate-900"
>
<span className="text-lg">INVESTE</span>
<span className="text-lg text-emerald-700">Moçambique</span>
</button>
<div className="flex items-center gap-3 text-xs text-slate-500">
<a href="/investidor/manifestacoes" className="underline">
O meu painel
</a>
<span className="hidden sm:inline">
{requestCount} pedido{requestCount === 1 ? '' : 's'}{' '}
recebido
{requestCount === 1 ? '' : 's'}
</span>
<button
onClick={() => onNavigate('interest')}
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-bold text-white hover:bg-slate-700"
>
Manifestar interesse
</button>
</div>
</div>
</nav>
);
}
function Hero({
query,
setQuery,
@@ -151,26 +110,30 @@ function Hero({
}) {
return (
<section className="border-b border-slate-200 bg-[#0B2038] text-white">
<div className="mx-auto grid max-w-6xl gap-10 px-5 py-16 md:grid-cols-[1.2fr,1fr] md:py-24">
<div>
<p className="text-xs font-bold tracking-[0.2em] text-emerald-300 uppercase">
Portal do Investidor
<div className="mx-auto grid max-w-6xl gap-10 px-5 py-16 md:py-24">
<div className="text-center">
<p className="text-4xl leading-tight font-black tracking-tight text-emerald-300 uppercase md:text-6xl">
INVISTA EM MOÇAMBIQUE
</p>
<h1 className="mt-4 max-w-xl text-4xl leading-[1.05] font-black tracking-tight md:text-5xl">
Oportunidades reais de investimento em Moçambique,
<h1 className="mx-auto mt-4 max-w-4xl text-4xl leading-[1.05] font-black tracking-tight md:text-5xl">
Transforme informação em decisões
<span className="text-amber-400">
{' '}
organizadas por sector.
e ideias em negócios reais.
</span>
</h1>
<p className="mt-5 max-w-lg text-slate-300">
<p className="mx-auto mt-6 max-w-3xl text-lg font-semibold text-slate-200">
Oportunidades reais de investimento em Moçambique,
organizadas por sector.
</p>
<p className="mx-auto mt-5 max-w-2xl text-slate-300">
Consulte projectos por província e estágio de
maturidade, compreenda os requisitos legais e fiscais, e
manifeste interesse directamente junto da entidade
promotora.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<div className="mt-8 flex flex-wrap justify-center gap-3">
<button
onClick={() => onNavigate('opportunities')}
className="inline-flex items-center gap-2 rounded-lg bg-amber-400 px-5 py-3 font-bold text-[#0B2038] hover:bg-amber-300"
@@ -1048,23 +1011,6 @@ function Field({
);
}
function Footer() {
return (
<footer className="border-t border-slate-200 bg-slate-950 py-10 text-slate-400">
<div className="mx-auto max-w-6xl px-5 text-sm">
<p className="font-bold text-white">INVESTE Moçambique</p>
<p className="mt-2 max-w-md">
Portal do Investidor — conheça as oportunidades de
investimento em Moçambique e manifeste o seu interesse.{' '}
<a href="/acesso-gestao" className="underline">
Acesso à gestão
</a>
</p>
</div>
</footer>
);
}
/* ------------------------------------------------------------------ */
/* App */
/* ------------------------------------------------------------------ */
@@ -1092,7 +1038,7 @@ export default function PortalInvestidorApp({
const selected = initialOpportunity
? toOpportunity(initialOpportunity)
: null;
const [requestCount, setRequestCount] = useState(0);
const { registerSubmittedInterest } = useTempoUi();
const [heroQuery, setHeroQuery] = useState('');
const [filters, setFilters] = useState<Filters>({
query: initialQuery,
@@ -1137,7 +1083,6 @@ export default function PortalInvestidorApp({
return (
<SectorContext.Provider value={sectors}>
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
<TopBar onNavigate={navigate} requestCount={requestCount} />
<PortalMenu />
<main>
{view === 'home' && (
@@ -1376,11 +1321,10 @@ export default function PortalInvestidorApp({
key={selected?.reference ?? 'general'}
preselected={selected}
countries={countries}
onSubmitted={() => setRequestCount((c) => c + 1)}
onSubmitted={registerSubmittedInterest}
/>
)}
</main>
<Footer />
<style>{`
.input {
@@ -0,0 +1,192 @@
import { Head } from '@inertiajs/react';
const PrivacyPolicy = () => {
return (
<main className="bg-background">
<Head title="Política de Privacidade" />
<article className="mx-auto max-w-4xl px-4 py-10 sm:px-6 sm:py-14 lg:px-8">
<header className="border-border mb-10 border-b pb-8">
<h1 className="text-foreground font-serif text-[clamp(1.75rem,1.35rem+1.5vw,2.5rem)] leading-tight font-semibold tracking-tight">
Política de Privacidade do Portal Tempo
</h1>
</header>
<div className="text-muted-foreground space-y-8 font-sans text-base leading-relaxed md:text-lg">
<p className="text-muted-foreground leading-relaxed font-normal">
A Tempo Lda valoriza a privacidade dos utilizadores do
Portal Tempo e compromete-se a tratar os dados pessoais
de forma transparente, segura e responsável. A presente
Política de Privacidade explica quais informações podem
ser recolhidas, como são utilizadas, em que
circunstâncias podem ser partilhadas e quais são os
direitos dos utilizadores.
</p>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
1. Enquadramento legal e princípios
</h2>
<p className="text-muted-foreground leading-relaxed">
O tratamento de dados pessoais no Portal Tempo
observa a legislação aplicável em Moçambique,
incluindo os princípios previstos na Constituição da
República, na Lei n.º 3/2017, de 9 de Janeiro, sobre
Transacções Electrónicas, e as demais normas
nacionais relevantes em matéria de privacidade,
segurança da informação e protecção de dados
pessoais.
</p>
<p className="text-muted-foreground leading-relaxed">
A Tempo Lda orienta o tratamento de dados pelos
princípios da licitude, transparência, finalidade
determinada, proporcionalidade, exactidão,
confidencialidade, segurança e conservação pelo
período estritamente necessário.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
2. Dados pessoais que podem ser recolhidos
</h2>
<p className="text-muted-foreground leading-relaxed">
Dependendo da forma de utilização do Portal Tempo,
poderão ser recolhidos dados como nome, endereço de
correio electrónico, número de telefone, entidade
representada, cargo ou função, mensagens enviadas
através de formulários, preferências de comunicação,
endereço IP, dados de navegação, tipo de
dispositivo, navegador utilizado e informações
relacionadas com cookies ou tecnologias semelhantes.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
3. Finalidades do tratamento
</h2>
<p className="text-muted-foreground leading-relaxed">
Os dados pessoais poderão ser utilizados para
permitir o acesso e a navegação no portal, responder
a pedidos de informação, gerir contactos
institucionais, enviar comunicações solicitadas pelo
utilizador, melhorar os conteúdos e serviços
digitais, garantir a segurança da plataforma,
prevenir acessos indevidos, cumprir obrigações
legais e produzir estatísticas agregadas sobre a
utilização do portal.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
4. Cookies e tecnologias semelhantes
</h2>
<p className="text-muted-foreground leading-relaxed">
O Portal Tempo pode utilizar cookies para assegurar
o funcionamento adequado da plataforma, melhorar a
experiência de navegação, memorizar as preferências
do utilizador, analisar o desempenho do portal e
reforçar a segurança. O utilizador pode configurar o
seu navegador para bloquear ou eliminar cookies,
podendo, contudo, algumas funcionalidades ficarem
limitadas.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
5. Partilha de dados com terceiros
</h2>
<p className="text-muted-foreground leading-relaxed">
A Tempo Lda não vende, aluga nem comercializa dados
pessoais dos utilizadores. Os dados poderão ser
partilhados apenas quando necessário para a
prestação de serviços técnicos, alojamento,
manutenção, segurança informática, cumprimento de
obrigações legais, resposta às autoridades
competentes ou protecção dos direitos e interesses
legítimos da Tempo Lda e dos utilizadores.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
6. Conservação dos dados
</h2>
<p className="text-muted-foreground leading-relaxed">
Os dados pessoais serão conservados apenas durante o
período necessário para cumprir as finalidades que
motivaram a sua recolha, salvo quando a lei exigir
prazo superior ou quando a conservação seja
necessária à defesa de direitos, ao cumprimento de
obrigações legais, à auditoria, à segurança ou à
resolução de litígios.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
7. Segurança da informação
</h2>
<p className="text-muted-foreground leading-relaxed">
A Tempo Lda adopta medidas técnicas e
organizacionais adequadas para proteger os dados
pessoais contra acesso não autorizado, perda,
alteração, divulgação indevida, destruição ou
qualquer forma de tratamento ilícito. Apesar dos
esforços de segurança, nenhum sistema digital é
totalmente imune a riscos, pelo que os utilizadores
devem também adoptar práticas seguras de navegação.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
8. Direitos dos utilizadores
</h2>
<p className="text-muted-foreground leading-relaxed">
Nos termos da legislação aplicável, o utilizador
pode solicitar informação sobre o tratamento dos
seus dados pessoais, bem como requerer o acesso, a
rectificação, a actualização, a oposição, a
limitação do tratamento ou a eliminação dos seus
dados, quando legalmente admissível. Para o efeito,
poderá contactar a Tempo Lda através dos canais
indicados nesta política.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
9. Contactos
</h2>
<p className="text-muted-foreground leading-relaxed">
Para questões relacionadas com esta Política de
Privacidade ou com o tratamento de dados pessoais, o
utilizador poderá contactar a Tempo Lda através do
endereço de correio electrónico institucional
indicado no portal, ou por outro meio de contacto
oficialmente disponibilizado pela empresa.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
10. Alterações à Política de Privacidade
</h2>
<p className="text-muted-foreground leading-relaxed">
A Tempo Lda poderá actualizar esta Política de
Privacidade sempre que necessário, designadamente
para reflectir alterações legais, técnicas,
operacionais ou de serviços. A versão actualizada
será publicada no Portal Tempo, com indicação da
data da última revisão, quando aplicável.
</p>
</section>
</div>
</article>
</main>
);
};
export default PrivacyPolicy;
-10
View File
@@ -1307,16 +1307,6 @@ export default function Services(p: Props) {
</>
)}
</main>
<footer className="border-t px-5 py-8 text-center text-sm">
Investe Moçambique ·{' '}
<Link href="/apoio" className="underline">
Apoio ao Investidor
</Link>{' '}
·{' '}
<Link href="/documentos" className="underline">
Documentos e Formulários
</Link>
</footer>
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import { Head, Link } from '@inertiajs/react';
const TermsOfUse = () => {
return (
<main className="bg-background">
<Head title="Termos de Uso" />
<article className="mx-auto max-w-4xl px-4 py-10 sm:px-6 sm:py-14 lg:px-8">
<header className="border-border mb-10 border-b pb-8">
<h1 className="text-foreground font-serif text-[clamp(1.75rem,1.35rem+1.5vw,2.5rem)] leading-tight font-semibold tracking-tight">
Termos de Uso do Portal Tempo
</h1>
<p className="text-muted-foreground mt-3 font-sans text-sm">
<time dateTime="2026-08-03">
Última actualização: 03 de Agosto de 2026
</time>
</p>
</header>
<div className="text-muted-foreground space-y-8 font-sans text-base leading-relaxed md:text-lg">
<p className="text-muted-foreground leading-relaxed font-normal">
Bem-vindo ao Portal Tempo. Estes Termos de Uso regulam o
acesso e a utilização do portal, dos seus conteúdos,
funcionalidades, serviços digitais e demais recursos
disponibilizados pela Tempo, Lda. Ao aceder ou utilizar
o Portal Tempo, o utilizador declara que leu,
compreendeu e aceita cumprir estes termos.
</p>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
1. Definições
</h2>
<p className="text-muted-foreground leading-relaxed">
Para efeitos destes Termos de Uso, entende-se por
"Portal Tempo" a plataforma digital operada pela
Tempo, Lda., incluindo páginas, revista Tempo, bases
de dados, conteúdos, ferramentas, formulários e
serviços disponibilizados ao público.
</p>
<p className="text-muted-foreground leading-relaxed">
"Utilizador" é qualquer pessoa singular ou colectiva
que aceda, navegue, consulte, submeta informação ou
utilize qualquer funcionalidade do portal.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
2. Aceitação dos Termos
</h2>
<p className="text-muted-foreground leading-relaxed">
O acesso e a utilização do Portal Tempo pressupõem a
aceitação integral destes Termos de Uso e, quando
aplicável, da Política de Privacidade, da Política
de Cookies e das demais regras complementares
publicadas no portal. Caso o utilizador não concorde
com alguma disposição, deverá abster-se de utilizar
o portal.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
3. Objecto e Finalidade do Portal
</h2>
<p className="text-muted-foreground leading-relaxed">
O Portal Tempo tem por finalidade disponibilizar
informação, conteúdos digitais, serviços de
consulta, ferramentas de apoio, formulários,
publicações, notícias, legislação, oportunidades,
dados institucionais e outros recursos relacionados
com as áreas de actuação da Tempo, Lda., incluindo
comunicação, informação, tecnologia, consultoria,
publicidade, conteúdos digitais e serviços
associados.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
4. Condições de Utilização
</h2>
<p className="text-muted-foreground leading-relaxed">
O utilizador compromete-se a utilizar o Portal Tempo
de forma lícita, responsável, ética e compatível com
estes Termos de Uso, abstendo-se de praticar actos
que possam comprometer a segurança, a integridade, a
disponibilidade, a reputação ou o funcionamento do
Portal, bem como os direitos de terceiros ou as
normas legais aplicáveis.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
5. Conteúdos e Exactidão da Informação
</h2>
<p className="text-muted-foreground leading-relaxed">
A Tempo, Lda. procura assegurar que os conteúdos
publicados no Portal Tempo sejam correctos,
actualizados e provenientes de fontes fiáveis.
Contudo, a informação disponibilizada tem natureza
informativa e pode estar sujeita a alterações,
actualizações, correcções ou substituições, sem
aviso prévio.
</p>
<p className="text-muted-foreground leading-relaxed">
O utilizador é responsável por confirmar, junto das
entidades competentes ou de fontes oficiais, a
validade, vigência, aplicabilidade e actualidade das
informações legais, regulatórias, económicas,
comerciais, institucionais ou administrativas
disponibilizadas no portal, antes de tomar decisões
com base nelas.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
6. Registo, Conta e Submissão de Informação
</h2>
<p className="text-muted-foreground leading-relaxed">
Algumas funcionalidades do Portal Tempo poderão
exigir registo, preenchimento de formulários ou
submissão de dados. O utilizador compromete-se a
fornecer informações verdadeiras, completas, exactas
e actualizadas, sendo responsável por qualquer
informação que submeter através do portal.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
7. Protecção de Dados Pessoais
</h2>
<p className="text-muted-foreground leading-relaxed">
O tratamento de dados pessoais realizado no âmbito
do Portal Tempo observará a legislação aplicável em
Moçambique, incluindo os princípios de licitude,
transparência, finalidade, confidencialidade,
segurança e proporcionalidade. A recolha e o
tratamento de dados pessoais serão limitados às
finalidades informadas ao utilizador, tais como
contacto, registo, prestação de serviços, resposta a
pedidos, melhoria da experiência de navegação e
cumprimento de obrigações legais.
</p>
<p className="text-muted-foreground leading-relaxed">
Para informação detalhada sobre os dados recolhidos,
finalidades, prazos de conservação, direitos dos
titulares e medidas de segurança, o utilizador
deverá consultar a{' '}
<Link
href="/politica-de-privacidade"
className="font-medium text-[#ED1C24] underline transition-colors hover:text-[#ED1C24]/80"
>
Política de Privacidade do Portal Tempo
</Link>
, quando disponível.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
8. Cookies e Tecnologias Semelhantes
</h2>
<p className="text-muted-foreground leading-relaxed">
O Portal Tempo poderá utilizar cookies ou
tecnologias semelhantes para melhorar a navegação,
analisar o desempenho do portal, personalizar
conteúdos e garantir funcionalidades essenciais. O
utilizador poderá gerir as suas preferências de
cookies através das definições do navegador ou dos
mecanismos disponibilizados no portal.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
9. Propriedade Intelectual
</h2>
<p className="text-muted-foreground leading-relaxed">
Todos os conteúdos, marcas, logótipos, textos,
imagens, gráficos, bases de dados, layouts, códigos,
documentos, publicações e demais materiais
disponíveis no Portal Tempo são protegidos por
direitos de propriedade intelectual pertencentes à
Tempo, Lda. ou a terceiros devidamente autorizados.
</p>
<p className="text-muted-foreground leading-relaxed">
São permitidas a consulta, a citação parcial e a
partilha de links para fins informativos, educativos
ou institucionais, desde que seja indicada a fonte.
É proibida a reprodução integral, exploração
comercial, alteração, distribuição, republicação ou
utilização não autorizada dos conteúdos sem
consentimento prévio e por escrito da Tempo, Lda.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
10. Links para Terceiros
</h2>
<p className="text-muted-foreground leading-relaxed">
O Portal Tempo poderá conter referências ou ligações
a websites, plataformas, documentos ou serviços de
terceiros. A Tempo, Lda. não controla nem se
responsabiliza pelo conteúdo, pela disponibilidade,
pela exactidão, pela segurança, pelas políticas de
privacidade ou pelas práticas de entidades externas.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
11. Condutas Proibidas
</h2>
<ul className="text-muted-foreground list-disc space-y-2 pl-6 leading-relaxed">
<li>
Utilizar o portal para fins ilícitos,
fraudulentos, ofensivos, difamatórios ou
contrários à ordem pública;
</li>
<li>
Interferir no funcionamento do portal, testar
vulnerabilidades, introduzir vírus, códigos
maliciosos ou mecanismos automatizados não
autorizados;
</li>
<li>
Recolher, copiar ou extrair dados do portal por
meios automatizados sem autorização;
</li>
<li>
Violar direitos de propriedade intelectual,
privacidade, imagem, honra ou outros direitos de
terceiros;
</li>
<li>
Submeter informação falsa, enganosa, abusiva,
ofensiva ou inadequada.
</li>
</ul>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
12. Limitação de Responsabilidade
</h2>
<p className="text-muted-foreground leading-relaxed">
Na medida permitida pela lei aplicável, a Tempo,
Lda. não será responsável por danos directos ou
indirectos resultantes do acesso, da utilização, da
impossibilidade de utilização, de interrupções, de
falhas técnicas, de indisponibilidade temporária, de
perda de dados, de decisões tomadas com base em
informação publicada ou da utilização de conteúdos
de terceiros referenciados no portal.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
13. Suspensão, Restrição ou Remoção de Acesso
</h2>
<p className="text-muted-foreground leading-relaxed">
A Tempo, Lda. poderá suspender, restringir ou
remover o acesso de utilizadores que violem estes
Termos de Uso, a legislação aplicável, os direitos
de terceiros ou as normas de segurança e de boa
utilização do portal, sem prejuízo de outras medidas
legais cabíveis.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
14. Alterações aos Termos
</h2>
<p className="text-muted-foreground leading-relaxed">
A Tempo, Lda. reserva-se o direito de alterar,
actualizar ou substituir estes Termos de Uso a
qualquer momento, mediante publicação da versão
revista no Portal Tempo. A utilização contínua do
portal após a publicação de alterações constitui
aceitação dos novos termos.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
15. Lei Aplicável e Resolução de Conflitos
</h2>
<p className="text-muted-foreground leading-relaxed">
Estes Termos de Uso são regidos pela legislação da
República de Moçambique. Quaisquer dúvidas,
reclamações ou conflitos decorrentes da
interpretação ou aplicação destes termos deverão,
sempre que possível, ser resolvidos de forma
amigável. Na falta de acordo, serão competentes as
instâncias legalmente aplicáveis em Moçambique.
</p>
</section>
<section className="space-y-3 pt-2">
<h2 className="border-border text-foreground border-b pb-2 font-serif text-xl font-semibold tracking-tight sm:text-2xl">
16. Contactos
</h2>
<p className="text-muted-foreground leading-relaxed">
Para esclarecimentos, pedidos, reclamações ou
comunicações relacionadas com estes Termos de Uso, o
utilizador poderá contactar a Tempo, Lda. por meio
dos canais oficiais indicados no Portal Tempo
(www.tempo.co.mz).
</p>
</section>
</div>
</article>
</main>
);
};
export default TermsOfUse;
+3 -1
View File
@@ -47,6 +47,8 @@ use Laravel\Fortify\Features;
// Route::inertia('/', 'welcome')->name('home');
Route::get('/', [PortalServicesController::class, 'home'])->name('home');
Route::inertia('/politica-de-privacidade', 'portal/privacy-policy')->name('legal.privacy');
Route::inertia('/termos-de-uso', 'portal/terms-of-use')->name('legal.terms');
foreach (['sectores' => 'sectors', 'como-investir' => 'procedures', 'legislacao' => 'legislation', 'documentos' => 'documents',
'apoio' => 'support', 'parcerias' => 'partnerships', 'simulador-fiscal' => 'simulator',
'formularios/investidor' => 'investor', 'formularios/empresa' => 'company'] as $path => $page) {
@@ -67,7 +69,7 @@ Route::middleware(['auth', 'verified', 'can:manage-portal'])->group(function ()
Route::get('/management/portal-services/resources/{id}/article', [\App\Http\Controllers\EditorialArticleController::class, 'edit'])->whereNumber('id')->name('editorial.edit');
Route::put('/management/portal-services/resources/{id}/article', [\App\Http\Controllers\EditorialArticleController::class, 'save'])->whereNumber('id')->name('editorial.save');
Route::get('/management/editorial-installation', [\App\Http\Controllers\EditorialArticleController::class, 'installation'])->name('editorial.installation');
Route::get('/management/editorial-installation/mfa', [\App\Http\Controllers\EditorialArticleController::class, 'confirmMfa'])->name('editorial.mfa');
Route::get('/management/editorial-installation/mfa', [\App\Http\Controllers\EditorialArticleController::class, 'showMfa'])->name('editorial.mfa');
Route::post('/management/editorial-installation/mfa', [\App\Http\Controllers\EditorialArticleController::class, 'confirmMfa'])->middleware('throttle:5,1');
Route::post('/management/editorial-installation', [\App\Http\Controllers\EditorialArticleController::class, 'install'])->name('editorial.install');
Route::get('/management/portal-services', [PortalServicesController::class, 'manage'])->name('services.manage');
@@ -0,0 +1,35 @@
<?php
namespace Tests\Feature;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DevelopmentEnvironmentTest extends TestCase
{
use RefreshDatabase;
public function test_the_verified_qa_connection_can_prepare_its_schema(): void
{
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->artisan('portal:dev-check')->assertSuccessful();
}
public function test_an_active_environment_is_never_treated_as_qa(): void
{
$this->app->instance('env', 'production');
$this->assertFalse(IsolatedQaDatabase::allowsSchemaChanges());
}
public function test_invalid_targets_and_a_mismatched_real_database_are_refused(): void
{
$original = config('database.connections.pgsql');
foreach (['host' => 'remote.example', 'port' => '5432', 'database' => 'investemoz', 'url' => 'pgsql://remote.example/qa_test'] as $key => $value) {
config(['database.connections.pgsql' => [...$original, $key => $value]]);
$this->assertFalse(IsolatedQaDatabase::allowsSchemaChanges(), $key);
}
config(['database.connections.pgsql' => [...$original, 'database' => 'different_qa_test']]);
$this->assertFalse(IsolatedQaDatabase::allowsSchemaChanges());
}
}
@@ -0,0 +1,20 @@
<?php
namespace Tests\Unit;
use App\Services\EconomicReferenceCatalog;
use PHPUnit\Framework\TestCase;
class EconomicReferenceManifestTest extends TestCase
{
public function test_the_distributed_manifest_matches_the_existing_approved_byte_hash(): void
{
$path = dirname(__DIR__, 2).'/database/data/economic-references-lot-01.json';
$this->assertSame(EconomicReferenceCatalog::MANIFEST_SHA256, hash_file('sha256', $path));
$manifest = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
$this->assertSame(EconomicReferenceCatalog::LOT, $manifest['lot']);
$this->assertCount(5, $manifest['activities']);
$this->assertCount(5, $manifest['chains']);
$this->assertCount(4, $manifest['links']);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Tests\Unit;
use Illuminate\Filesystem\Filesystem;
use PHPUnit\Framework\TestCase;
class RuntimeStorageTest extends TestCase
{
public function test_missing_runtime_directories_are_created_without_changing_existing_files(): void
{
$root = sys_get_temp_dir().'/investmoz-runtime-'.bin2hex(random_bytes(8));
$prepare = require dirname(__DIR__, 2).'/bootstrap/storage.php';
try {
$prepare($root, $root.'/custom-storage');
foreach (['app/private', 'app/public', 'framework/cache/data', 'framework/sessions', 'framework/views', 'logs'] as $path) {
$this->assertDirectoryExists($root.'/custom-storage/'.$path);
}
$this->assertDirectoryExists($root.'/bootstrap/cache');
$file = $root.'/custom-storage/framework/views/existing.php';
file_put_contents($file, 'preserved');
$prepare($root, $root.'/custom-storage');
$this->assertSame('preserved', file_get_contents($file));
$this->assertNotFalse(realpath($root.'/custom-storage/framework/views'));
} finally {
(new Filesystem)->deleteDirectory($root);
}
}
public function test_a_file_blocking_a_runtime_directory_produces_an_actionable_error(): void
{
$root = sys_get_temp_dir().'/investmoz-runtime-'.bin2hex(random_bytes(8));
mkdir($root, 0775, true);
file_put_contents($root.'/storage', 'preserved');
$prepare = require dirname(__DIR__, 2).'/bootstrap/storage.php';
try {
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Cannot create Laravel runtime directory:');
$prepare($root, $root.'/storage');
} finally {
$this->assertSame('preserved', file_get_contents($root.'/storage'));
(new Filesystem)->deleteDirectory($root);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
// Read-only public navigation and login of the documented local demo account.
// Receipts never include cookies, request bodies or credentials.
(async () => {
const base = 'http://127.0.0.1:8000';
const directory = path.resolve(__dirname, '../outputs/local-startup-20261019');
fs.mkdirSync(directory, { recursive: true });
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const errors = [];
const failures = [];
const checks = [];
try {
const page = await browser.newPage({ viewport: { width: 1366, height: 900 } });
page.on('pageerror', error => errors.push(error.message));
page.on('response', response => {
if (response.url().startsWith(base) && response.status() >= 500) {
failures.push({ path: new URL(response.url()).pathname, status: response.status() });
}
});
for (const route of ['/', '/oportunidades', '/sectores/agronegocio', '/login', '/register']) {
const response = await page.goto(base + route, { waitUntil: 'networkidle', timeout: 90000 });
assert.equal(response.status(), 200, route);
await page.locator('#app').waitFor();
assert.ok((await page.locator('body').innerText()).trim().length > 100, route);
checks.push({ path: route, status: response.status(), rendered: true });
if (route === '/') await page.screenshot({ path: path.join(directory, 'home.png'), fullPage: true });
}
await page.goto(base + '/login', { waitUntil: 'networkidle' });
await page.locator('input[name=email]').fill('test@example.com');
await page.locator('input[name=password]').fill('password');
await Promise.all([
page.waitForURL('**/dashboard', { timeout: 60000 }),
page.locator('button[type=submit]').click(),
]);
await page.waitForLoadState('networkidle');
assert.ok((await page.locator('body').innerText()).trim().length > 100);
checks.push({ path: '/dashboard', authenticated: true, rendered: true });
await page.screenshot({ path: path.join(directory, 'dashboard.png'), fullPage: true });
assert.deepEqual(errors, []);
assert.deepEqual(failures, []);
const receipt = { at: new Date().toISOString(), checks, javascriptErrors: errors, serverErrors: failures };
fs.writeFileSync(path.join(directory, 'browser.json'), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+48
View File
@@ -0,0 +1,48 @@
<?php
// Technical setup for the explicitly authorised, disposable local demo only.
// The production database and authenticated installation workflow remain separate.
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
require dirname(__DIR__).'/vendor/autoload.php';
foreach (['APP_ENV' => 'testing', 'APP_CONFIG_CACHE' => dirname(__DIR__).'/storage/framework/cache/local-qa-config.php'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
$app = require dirname(__DIR__).'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$connection = config('database.connections.pgsql');
if (app()->configurationIsCached() || config('database.default') !== 'pgsql'
|| $connection['host'] !== '127.0.0.1' || (string) $connection['port'] !== '55441'
|| $connection['database'] !== 'investmoz_local_test' || ! empty($connection['url'])) {
fwrite(STDERR, 'Refused: explicit isolated local QA database required.'.PHP_EOL);
exit(1);
}
$identity = DB::selectOne('SELECT current_database() AS database, inet_server_port() AS port');
if ($identity->database !== 'investmoz_local_test' || (int) $identity->port !== 55441) {
fwrite(STDERR, 'Refused: actual QA server identity differs.'.PHP_EOL);
exit(1);
}
try {
$status = Artisan::call('migrate', ['--force' => true]);
} catch (Throwable $error) {
fwrite(STDERR, 'Local QA migration failed: '.$error->getMessage().PHP_EOL);
exit(1);
}
echo Artisan::output();
if ($status !== 0) {
exit($status);
}
// Never reset an account or overwrite an existing demo on a repeated setup.
if (DB::table('sectors')->count() === 0 && DB::table('users')->count() === 0) {
$status = Artisan::call('db:seed', ['--force' => true]);
echo Artisan::output();
exit($status);
}
echo 'Existing local demo preserved; seed skipped.'.PHP_EOL;