This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
* text=auto eol=lf
|
* 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
|
*.blade.php diff=html
|
||||||
*.css diff=css
|
*.css diff=css
|
||||||
*.html diff=html
|
*.html diff=html
|
||||||
|
|||||||
@@ -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
|
Estes ficheiros correspondem ao domínio de **sectores**, **oportunidades** e
|
||||||
**manifestações de interesse** do Portal do Investidor. Foram desenhados
|
**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');
|
: 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)
|
public function confirmMfa(Request $request)
|
||||||
{
|
{
|
||||||
$actor = $request->user();
|
$actor = $request->user();
|
||||||
|
|||||||
@@ -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
@@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Log;
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
$app = Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
api: __DIR__.'/../routes/api.php',
|
api: __DIR__.'/../routes/api.php',
|
||||||
@@ -106,3 +106,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
return $response;
|
return $response;
|
||||||
});
|
});
|
||||||
})->create();
|
})->create();
|
||||||
|
|
||||||
|
(require __DIR__.'/storage.php')($app->basePath(), $app->storagePath());
|
||||||
|
|
||||||
|
return $app;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"@php artisan portal:dev-check",
|
||||||
"@php artisan dev"
|
"@php artisan dev"
|
||||||
],
|
],
|
||||||
"lint": [
|
"lint": [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
return new class extends Migration {
|
return new class extends Migration {
|
||||||
public function up(): void
|
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());
|
Schema::table('investment_zones',fn(Blueprint $table)=>$table->text('editorial_draft')->nullable());
|
||||||
}
|
}
|
||||||
public function down(): void
|
public function down(): void
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
return new class extends Migration {
|
return new class extends Migration {
|
||||||
public function up(): void
|
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.');}
|
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());
|
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
|
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.');
|
throw new RuntimeException('Use a ação autenticada nominal de instalação do contexto.');
|
||||||
}
|
}
|
||||||
Schema::create('investor_navigation_preferences', function (Blueprint $t) {
|
Schema::create('investor_navigation_preferences', function (Blueprint $t) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
public function up(): void
|
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());
|
Schema::table('portal_resources', fn (Blueprint $t) => $t->jsonb('editorial_content')->nullable());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
@@ -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 |
@@ -7,6 +7,300 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@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 {
|
@theme {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
import { usePage } from '@inertiajs/react';
|
|
||||||
|
|
||||||
import AppLogoIcon from '@/components/app-logo-icon';
|
|
||||||
|
|
||||||
export default function AppLogo() {
|
export default function AppLogo() {
|
||||||
const { name } = usePage().props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<img
|
||||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md">
|
src="/images/logo/logo-vermelho.png"
|
||||||
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
|
alt="Tempo"
|
||||||
</div>
|
width="1787"
|
||||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
height="564"
|
||||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
className="h-8 w-auto max-w-full object-contain group-data-[collapsible=icon]:h-auto group-data-[collapsible=icon]:w-8"
|
||||||
{name}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Link, router, usePage } from '@inertiajs/react';
|
||||||
import { LogOut } from 'lucide-react';
|
import { ChevronDown, LogOut, UserRound } from 'lucide-react';
|
||||||
import { logout } from '@/routes';
|
import { useState } from 'react';
|
||||||
import { useEffect, useId, useRef, useState } from 'react';
|
import { useNavigationContext } from '@/components/navigation-context';
|
||||||
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
|
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 {
|
import {
|
||||||
portalNavigation,
|
portalNavigation,
|
||||||
currentNavigationGroup,
|
currentNavigationGroup,
|
||||||
currentNavigationItem,
|
currentNavigationItem,
|
||||||
type PortalNavItem,
|
|
||||||
} from '@/lib/portal-navigation';
|
} 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() {
|
export function PortalGroupedMenu() {
|
||||||
const { props, url } = usePage<{
|
const { props, url } = usePage<{
|
||||||
@@ -23,10 +39,10 @@ export function PortalGroupedMenu() {
|
|||||||
educationEnabled?: boolean;
|
educationEnabled?: boolean;
|
||||||
aiEnabled?: boolean;
|
aiEnabled?: boolean;
|
||||||
legacyContentEnabled?: boolean;
|
legacyContentEnabled?: boolean;
|
||||||
portalOperator?: string | null;
|
|
||||||
profileAccess?: ProfileAccess | null;
|
profileAccess?: ProfileAccess | null;
|
||||||
opportunityManagement?: OpportunityManagementAccess | null;
|
opportunityManagement?: OpportunityManagementAccess | null;
|
||||||
}>();
|
}>();
|
||||||
|
const { t, language, requestCount } = useTempoUi();
|
||||||
const navigation = useNavigationContext();
|
const navigation = useNavigationContext();
|
||||||
const groups = portalNavigation({
|
const groups = portalNavigation({
|
||||||
context: navigation.view
|
context: navigation.view
|
||||||
@@ -44,159 +60,77 @@ export function PortalGroupedMenu() {
|
|||||||
});
|
});
|
||||||
const current = currentNavigationGroup(groups, url);
|
const current = currentNavigationGroup(groups, url);
|
||||||
const currentItem = currentNavigationItem(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 [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[]) =>
|
const items = (children: PortalNavItem[]) =>
|
||||||
children.map((item) => (
|
children.map((item) =>
|
||||||
<li key={item.id}>
|
item.children ? (
|
||||||
{item.children ? (
|
<DropdownMenuSub key={item.id}>
|
||||||
<>
|
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
|
||||||
<button
|
{t(item.label)}
|
||||||
type="button"
|
</DropdownMenuSubTrigger>
|
||||||
id={`${id}-${item.id}-button`}
|
<DropdownMenuSubContent
|
||||||
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"
|
collisionPadding={12}
|
||||||
aria-expanded={branch === item.id}
|
className="tempo-dropdown max-h-[min(70vh,34rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||||
aria-controls={`${id}-${item.id}`}
|
>
|
||||||
onClick={() =>
|
{items(item.children)}
|
||||||
setBranch(branch === item.id ? null : item.id)
|
</DropdownMenuSubContent>
|
||||||
}
|
</DropdownMenuSub>
|
||||||
>
|
) : (
|
||||||
{item.label}
|
<DropdownMenuItem
|
||||||
<span aria-hidden="true">
|
key={item.id}
|
||||||
{branch === item.id ? '−' : '+'}
|
asChild
|
||||||
</span>
|
className="tempo-menu-item"
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Link
|
<Link
|
||||||
href={item.href!}
|
href={item.href!}
|
||||||
onClick={close}
|
|
||||||
aria-current={
|
aria-current={
|
||||||
currentItem === item.id ? 'page' : undefined
|
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>
|
</Link>
|
||||||
)}
|
</DropdownMenuItem>
|
||||||
</li>
|
),
|
||||||
));
|
);
|
||||||
|
const accountGroups = groups.filter(
|
||||||
|
(group) =>
|
||||||
|
!['home', 'discovery', 'realization', 'support'].includes(group.id),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<header
|
<header className="tempo-header" lang={language}>
|
||||||
ref={root}
|
<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">
|
||||||
className="relative z-40 border-b border-slate-200 bg-white text-slate-900"
|
<Link
|
||||||
onKeyDown={(event) => {
|
href="/"
|
||||||
if (event.key !== 'Escape') return;
|
aria-label="Tempo — início"
|
||||||
event.preventDefault();
|
className="tempo-logo-link my-4 shrink-0"
|
||||||
if (branch) {
|
>
|
||||||
document.getElementById(`${id}-${branch}-button`)?.focus();
|
<img
|
||||||
setBranch(null);
|
src="/images/logo/logo-vermelho.png"
|
||||||
} else if (open) {
|
alt="Tempo"
|
||||||
document.getElementById(`${id}-${open}-button`)?.focus();
|
width="1787"
|
||||||
close();
|
height="564"
|
||||||
}
|
className="h-9 w-auto object-contain sm:h-10"
|
||||||
}}
|
/>
|
||||||
>
|
</Link>
|
||||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6">
|
<nav
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
aria-label="Áreas do portal"
|
||||||
<Link
|
className="order-3 w-full border-t border-white/10 xl:order-none xl:w-auto xl:flex-1 xl:border-0"
|
||||||
href="/"
|
>
|
||||||
className="text-xl font-extrabold tracking-tight text-emerald-900"
|
<ul className="flex items-center justify-between gap-0 py-2 xl:justify-center xl:py-0">
|
||||||
>
|
<li>
|
||||||
InvesteMoz
|
<Link
|
||||||
</Link>
|
href="/"
|
||||||
<p className="max-w-2xl text-xs leading-relaxed text-slate-600">
|
className="tempo-nav-trigger"
|
||||||
Iniciativa privada e independente de apoio ao
|
aria-current={
|
||||||
investimento. Orientação e acompanhamento internos;
|
current === 'home' ? 'page' : undefined
|
||||||
decisões oficiais cabem às instituições competentes.
|
}
|
||||||
{props.portalOperator && (
|
data-current-group={
|
||||||
<> Operador: {props.portalOperator}.</>
|
current === 'home' || undefined
|
||||||
)}
|
}
|
||||||
</p>
|
>
|
||||||
</div>
|
{t('Início')}
|
||||||
{props.auth?.user && (
|
</Link>
|
||||||
<nav
|
</li>
|
||||||
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">
|
|
||||||
{groups
|
{groups
|
||||||
.filter((group) =>
|
.filter((group) =>
|
||||||
[
|
[
|
||||||
@@ -206,109 +140,115 @@ export function PortalGroupedMenu() {
|
|||||||
].includes(group.id),
|
].includes(group.id),
|
||||||
)
|
)
|
||||||
.map((group) => (
|
.map((group) => (
|
||||||
<li
|
<li key={group.id}>
|
||||||
key={group.id}
|
<DropdownMenu modal={false}>
|
||||||
className="relative w-full sm:w-auto"
|
<DropdownMenuTrigger
|
||||||
>
|
className="tempo-nav-trigger"
|
||||||
<button
|
data-current-group={
|
||||||
id={`${id}-${group.id}-button`}
|
current === group.id ||
|
||||||
type="button"
|
undefined
|
||||||
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}`}
|
{t(group.label)}
|
||||||
data-current-group={
|
<ChevronDown
|
||||||
current === group.id || undefined
|
size={14}
|
||||||
}
|
aria-hidden="true"
|
||||||
onClick={() => {
|
/>
|
||||||
setOpen(
|
{current === group.id && (
|
||||||
open === group.id
|
<span className="sr-only">
|
||||||
? null
|
Grupo actual
|
||||||
: group.id,
|
</span>
|
||||||
);
|
)}
|
||||||
setBranch(null);
|
</DropdownMenuTrigger>
|
||||||
}}
|
<DropdownMenuContent
|
||||||
>
|
align="start"
|
||||||
{group.label}
|
sideOffset={12}
|
||||||
<span aria-hidden="true">
|
collisionPadding={12}
|
||||||
{open === group.id ? '▴' : '▾'}
|
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||||
</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 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 ?? [])}
|
{items(group.children ?? [])}
|
||||||
</ul>
|
</DropdownMenuContent>
|
||||||
)}
|
</DropdownMenu>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<nav
|
<div className="tempo-panel-summary">
|
||||||
aria-label="Conta e trabalho por papel"
|
<Link href="/investidor/manifestacoes">
|
||||||
className="mt-3 grid gap-3 sm:grid-cols-2"
|
{t('O meu painel')}
|
||||||
>
|
</Link>
|
||||||
<details className="rounded border p-3">
|
<span aria-live="polite" aria-atomic="true">
|
||||||
<summary className="cursor-pointer font-semibold">
|
{requestCount}{' '}
|
||||||
A minha conta e atividade
|
{t(
|
||||||
</summary>
|
requestCount === 1
|
||||||
<ul className="mt-2 space-y-1">
|
? 'pedido recebido'
|
||||||
{items(
|
: 'pedidos recebidos',
|
||||||
groups.filter((g) =>
|
)}
|
||||||
['account', 'personal-activity'].includes(
|
</span>
|
||||||
g.id,
|
</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 />
|
||||||
</ul>
|
<TempoLanguageSelector />
|
||||||
</details>
|
</div>
|
||||||
{groups.some((g) =>
|
<TempoSocialLinks />
|
||||||
[
|
</div>
|
||||||
'management',
|
<div className="tempo-header-actions">
|
||||||
'profile-opportunities',
|
{props.auth?.user ? (
|
||||||
'business-work',
|
<DropdownMenu modal={false}>
|
||||||
].includes(g.id),
|
<DropdownMenuTrigger className="tempo-login-button">
|
||||||
) && (
|
<UserRound size={16} aria-hidden="true" />
|
||||||
<details className="rounded border p-3">
|
<span className="max-w-24 truncate">
|
||||||
<summary className="cursor-pointer font-semibold">
|
{props.auth.user.name || t('A minha área')}
|
||||||
Área de trabalho — criação, revisão, publicação
|
</span>
|
||||||
e administração
|
<ChevronDown size={12} aria-hidden="true" />
|
||||||
</summary>
|
</DropdownMenuTrigger>
|
||||||
<p className="my-2 text-xs">
|
<DropdownMenuContent
|
||||||
Destinos disponíveis segundo o papel e âmbito
|
align="end"
|
||||||
existentes; não exige percorrer a jornada do
|
sideOffset={12}
|
||||||
investidor.
|
collisionPadding={12}
|
||||||
</p>
|
className="tempo-dropdown max-h-[75vh] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||||
<ul className="space-y-1">
|
>
|
||||||
{items(
|
<DropdownMenuLabel className="truncate text-xs text-slate-500">
|
||||||
groups.filter((g) =>
|
{props.auth.user.email}
|
||||||
[
|
</DropdownMenuLabel>
|
||||||
'management',
|
{items(accountGroups)}
|
||||||
'profile-opportunities',
|
<DropdownMenuSeparator />
|
||||||
'business-work',
|
<DropdownMenuItem
|
||||||
].includes(g.id),
|
asChild
|
||||||
),
|
className="tempo-menu-item text-red-700"
|
||||||
)}
|
>
|
||||||
</ul>
|
<Link
|
||||||
</details>
|
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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
import { PortalGroupedMenu } from '@/components/portal-grouped-menu';
|
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 type { ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
NavigationContextProvider,
|
NavigationContextProvider,
|
||||||
@@ -21,6 +23,8 @@ export const portalLinks = [
|
|||||||
['/apoio', 'Apoio ao Investidor'],
|
['/apoio', 'Apoio ao Investidor'],
|
||||||
] as const;
|
] as const;
|
||||||
const names: Record<string, string> = {
|
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',
|
'/porque-investir': 'Porquê investir em Moçambique',
|
||||||
'/investimentos/iniciar': 'Iniciar ou continuar investimento',
|
'/investimentos/iniciar': 'Iniciar ou continuar investimento',
|
||||||
'/explorar/agronegocio': 'Explorar agronegócio',
|
'/explorar/agronegocio': 'Explorar agronegócio',
|
||||||
@@ -99,80 +103,85 @@ export default function PortalNavigation({
|
|||||||
? 'Definições da conta'
|
? 'Definições da conta'
|
||||||
: 'Detalhes');
|
: 'Detalhes');
|
||||||
return (
|
return (
|
||||||
<NavigationContextProvider>
|
<TempoUiProvider>
|
||||||
<MenuPlacement.Provider value={true}>
|
<NavigationContextProvider>
|
||||||
<div
|
<MenuPlacement.Provider value={true}>
|
||||||
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' : ''}`}
|
<div className="min-h-screen [&_button]:scroll-mb-24 [&_input]:scroll-mb-24 [&_select]:scroll-mb-24 [&_textarea]:scroll-mb-24">
|
||||||
>
|
<nav
|
||||||
<nav
|
aria-label="Atalho para o conteúdo"
|
||||||
aria-label="Atalho para o conteúdo"
|
className={printable ? 'print:hidden' : undefined}
|
||||||
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
|
<a
|
||||||
</a>
|
href="#portal-content"
|
||||||
</nav>
|
className="sr-only focus:not-sr-only focus:block focus:bg-white focus:p-4 focus:text-emerald-900"
|
||||||
{portalShell && (
|
>
|
||||||
<div className={printable ? 'print:hidden' : undefined}>
|
Saltar para o conteúdo
|
||||||
<PortalMenu primary />
|
</a>
|
||||||
</div>
|
</nav>
|
||||||
)}
|
|
||||||
<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}>
|
|
||||||
{portalShell && (
|
{portalShell && (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
printable ? 'print:hidden' : undefined
|
printable ? 'print:hidden' : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<NavigationContextSummary />
|
<PortalMenu primary />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children}
|
{path !== '/' && (
|
||||||
</div>
|
<nav
|
||||||
<aside
|
aria-label="Caminho de navegação"
|
||||||
aria-label="Contacto de apoio ao investidor"
|
className={`bg-background text-foreground flex flex-wrap items-center gap-2 border-b px-5 py-3 text-sm ${printable ? 'print:hidden' : ''}`}
|
||||||
className={printable ? 'print:hidden' : undefined}
|
>
|
||||||
>
|
<>
|
||||||
<a
|
<Link href="/" className="underline">
|
||||||
href="/apoio"
|
Início
|
||||||
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"
|
</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
|
||||||
</a>
|
href="/apoio"
|
||||||
</aside>
|
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"
|
||||||
</div>
|
>
|
||||||
</MenuPlacement.Provider>
|
Contactar Apoio ao Investidor
|
||||||
</NavigationContextProvider>
|
</a>
|
||||||
|
</aside>
|
||||||
|
<div className={printable ? 'print:hidden' : undefined}>
|
||||||
|
<TempoFooter />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MenuPlacement.Provider>
|
||||||
|
</NavigationContextProvider>
|
||||||
|
</TempoUiProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -440,11 +440,11 @@ export function currentNavigationItem(
|
|||||||
export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||||
const previous = legacyPortalNavigation(access);
|
const previous = legacyPortalNavigation(access);
|
||||||
return uniqueNavigationDestinations([
|
return uniqueNavigationDestinations([
|
||||||
|
link('home', 'Início', '/'),
|
||||||
{
|
{
|
||||||
id: 'discovery',
|
id: 'discovery',
|
||||||
label: 'Descoberta',
|
label: 'Descoberta',
|
||||||
children: [
|
children: [
|
||||||
link('home', 'Início', '/'),
|
|
||||||
link(
|
link(
|
||||||
'why',
|
'why',
|
||||||
'Porquê investir em Moçambique',
|
'Porquê investir em Moçambique',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useContext, useRef, useState } from 'react';
|
import { createContext, useContext, useRef, useState } from 'react';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { PortalMenu } from '@/layouts/portal-navigation';
|
import { PortalMenu } from '@/layouts/portal-navigation';
|
||||||
|
import { useTempoUi } from '@/components/tempo-brand';
|
||||||
import { ProvinceSelect } from '@/components/investment-territory';
|
import { ProvinceSelect } from '@/components/investment-territory';
|
||||||
import { MATURITY_LEVELS } from '@/lib/investment-maturity';
|
import { MATURITY_LEVELS } from '@/lib/investment-maturity';
|
||||||
import CountrySelect, { type Country } from '@/components/country-select';
|
import CountrySelect, { type Country } from '@/components/country-select';
|
||||||
@@ -94,48 +95,6 @@ function QueryStatus({
|
|||||||
/* Componentes */
|
/* 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({
|
function Hero({
|
||||||
query,
|
query,
|
||||||
setQuery,
|
setQuery,
|
||||||
@@ -151,26 +110,30 @@ function Hero({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-slate-200 bg-[#0B2038] text-white">
|
<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 className="mx-auto grid max-w-6xl gap-10 px-5 py-16 md:py-24">
|
||||||
<div>
|
<div className="text-center">
|
||||||
<p className="text-xs font-bold tracking-[0.2em] text-emerald-300 uppercase">
|
<p className="text-4xl leading-tight font-black tracking-tight text-emerald-300 uppercase md:text-6xl">
|
||||||
Portal do Investidor
|
INVISTA EM MOÇAMBIQUE
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-4 max-w-xl text-4xl leading-[1.05] font-black tracking-tight md:text-5xl">
|
<h1 className="mx-auto mt-4 max-w-4xl text-4xl leading-[1.05] font-black tracking-tight md:text-5xl">
|
||||||
Oportunidades reais de investimento em Moçambique,
|
Transforme informação em decisões
|
||||||
<span className="text-amber-400">
|
<span className="text-amber-400">
|
||||||
{' '}
|
{' '}
|
||||||
organizadas por sector.
|
e ideias em negócios reais.
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</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
|
Consulte projectos por província e estágio de
|
||||||
maturidade, compreenda os requisitos legais e fiscais, e
|
maturidade, compreenda os requisitos legais e fiscais, e
|
||||||
manifeste interesse directamente junto da entidade
|
manifeste interesse directamente junto da entidade
|
||||||
promotora.
|
promotora.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap gap-3">
|
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate('opportunities')}
|
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"
|
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 */
|
/* App */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -1092,7 +1038,7 @@ export default function PortalInvestidorApp({
|
|||||||
const selected = initialOpportunity
|
const selected = initialOpportunity
|
||||||
? toOpportunity(initialOpportunity)
|
? toOpportunity(initialOpportunity)
|
||||||
: null;
|
: null;
|
||||||
const [requestCount, setRequestCount] = useState(0);
|
const { registerSubmittedInterest } = useTempoUi();
|
||||||
const [heroQuery, setHeroQuery] = useState('');
|
const [heroQuery, setHeroQuery] = useState('');
|
||||||
const [filters, setFilters] = useState<Filters>({
|
const [filters, setFilters] = useState<Filters>({
|
||||||
query: initialQuery,
|
query: initialQuery,
|
||||||
@@ -1137,7 +1083,6 @@ export default function PortalInvestidorApp({
|
|||||||
return (
|
return (
|
||||||
<SectorContext.Provider value={sectors}>
|
<SectorContext.Provider value={sectors}>
|
||||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
||||||
<TopBar onNavigate={navigate} requestCount={requestCount} />
|
|
||||||
<PortalMenu />
|
<PortalMenu />
|
||||||
<main>
|
<main>
|
||||||
{view === 'home' && (
|
{view === 'home' && (
|
||||||
@@ -1376,11 +1321,10 @@ export default function PortalInvestidorApp({
|
|||||||
key={selected?.reference ?? 'general'}
|
key={selected?.reference ?? 'general'}
|
||||||
preselected={selected}
|
preselected={selected}
|
||||||
countries={countries}
|
countries={countries}
|
||||||
onSubmitted={() => setRequestCount((c) => c + 1)}
|
onSubmitted={registerSubmittedInterest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.input {
|
.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;
|
||||||
@@ -1307,16 +1307,6 @@ export default function Services(p: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -47,6 +47,8 @@ use Laravel\Fortify\Features;
|
|||||||
|
|
||||||
// Route::inertia('/', 'welcome')->name('home');
|
// Route::inertia('/', 'welcome')->name('home');
|
||||||
Route::get('/', [PortalServicesController::class, 'home'])->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',
|
foreach (['sectores' => 'sectors', 'como-investir' => 'procedures', 'legislacao' => 'legislation', 'documentos' => 'documents',
|
||||||
'apoio' => 'support', 'parcerias' => 'partnerships', 'simulador-fiscal' => 'simulator',
|
'apoio' => 'support', 'parcerias' => 'partnerships', 'simulador-fiscal' => 'simulator',
|
||||||
'formularios/investidor' => 'investor', 'formularios/empresa' => 'company'] as $path => $page) {
|
'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::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::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', [\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/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::post('/management/editorial-installation', [\App\Http\Controllers\EditorialArticleController::class, 'install'])->name('editorial.install');
|
||||||
Route::get('/management/portal-services', [PortalServicesController::class, 'manage'])->name('services.manage');
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; });
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user