@@ -1,5 +1,8 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# Preserve the exact bytes pinned by EconomicReferenceCatalog::MANIFEST_SHA256.
|
||||
database/data/economic-references-lot-01.json text eol=crlf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
# Portal do Investidor — esqueleto Laravel
|
||||
# Portal do Investidor — InvesteMoz
|
||||
|
||||
## Executar esta plataforma no Windows
|
||||
|
||||
Consulte [Execução local](deploy/local/README.md). Com Docker Desktop iniciado,
|
||||
execute `./deploy/local/start.ps1`. Depois da preparação, `composer run dev`
|
||||
inicia a plataforma em http://127.0.0.1:8000.
|
||||
|
||||
O ambiente local usa uma base PostgreSQL/PostGIS de demonstração isolada.
|
||||
Não execute os exemplos históricos de criação de um projecto novo sobre esta
|
||||
instalação ou sobre dados existentes.
|
||||
|
||||
## Referência histórica do pacote inicial
|
||||
|
||||
Estes ficheiros correspondem ao domínio de **sectores**, **oportunidades** e
|
||||
**manifestações de interesse** do Portal do Investidor. Foram desenhados
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CheckDevelopmentEnvironment extends Command
|
||||
{
|
||||
protected $signature = 'portal:dev-check';
|
||||
|
||||
protected $description = 'Verifica o arranque local sem alterar dados ou mostrar credenciais';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$checks = [
|
||||
'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='),
|
||||
'Driver PostgreSQL activo' => extension_loaded('pdo_pgsql'),
|
||||
'APP_KEY definida' => filled(config('app.key')),
|
||||
'Base PostgreSQL configurada' => config('database.default') === 'pgsql',
|
||||
'Cache de vistas gravável' => is_dir(config('view.compiled') ?: '') && is_writable(config('view.compiled') ?: ''),
|
||||
'Sessões e logs graváveis' => is_writable(storage_path('framework/sessions')) && is_writable(storage_path('logs')),
|
||||
'Dependências frontend instaladas' => is_file(base_path('node_modules/.bin/vp')) || is_file(base_path('node_modules/.bin/vp.cmd')),
|
||||
];
|
||||
|
||||
if ($checks['Driver PostgreSQL activo'] && $checks['Base PostgreSQL configurada']) {
|
||||
try {
|
||||
DB::selectOne('SELECT 1');
|
||||
$checks['Ligação à base de dados'] = true;
|
||||
$checks['Extensão PostGIS instalada'] = (bool) DB::selectOne("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS installed")->installed;
|
||||
$checks['Tabelas principais instaladas'] = collect(['migrations', 'users', 'sectors', 'opportunities', 'sessions', 'cache', 'jobs'])
|
||||
->every(fn (string $table): bool => Schema::hasTable($table));
|
||||
} catch (\Throwable) {
|
||||
$checks['Ligação à base de dados'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($checks as $label => $passed) {
|
||||
$this->line(($passed ? '[OK] ' : '[FALHA] ').$label);
|
||||
}
|
||||
|
||||
if (in_array(false, $checks, true)) {
|
||||
$this->error('Arranque interrompido. Consulte deploy/local/README.md e corrija os itens acima.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ class EditorialArticleController extends Controller
|
||||
: response()->view('management.editorial-installation', ['plan' => $installation->preview($request->user()), 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function showMfa(Request $request)
|
||||
{
|
||||
return $this->confirmMfa($request);
|
||||
}
|
||||
|
||||
public function confirmMfa(Request $request)
|
||||
{
|
||||
$actor = $request->user();
|
||||
|
||||
@@ -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 Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
$app = Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
@@ -106,3 +106,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
return $response;
|
||||
});
|
||||
})->create();
|
||||
|
||||
(require __DIR__.'/storage.php')($app->basePath(), $app->storagePath());
|
||||
|
||||
return $app;
|
||||
|
||||
@@ -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": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php artisan portal:dev-check",
|
||||
"@php artisan dev"
|
||||
],
|
||||
"lint": [
|
||||
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
if (!config('investment-zone-profile.authenticated_installation')) { throw new RuntimeException('Use a ação autenticada de instalação nominal da ficha.'); }
|
||||
if (!config('investment-zone-profile.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()) { throw new RuntimeException('Use a ação autenticada de instalação nominal da ficha.'); }
|
||||
Schema::table('investment_zones',fn(Blueprint $table)=>$table->text('editorial_draft')->nullable());
|
||||
}
|
||||
public function down(): void
|
||||
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
if(!config('investment-zone-details.authenticated_installation')){throw new RuntimeException('Use a instalação autenticada nominal L1.');}
|
||||
if(!config('investment-zone-details.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()){throw new RuntimeException('Use a instalação autenticada nominal L1.');}
|
||||
if(!Schema::hasColumn('investment_zones','editorial_draft') || !DB::table('migrations')->where('migration','2026_10_16_130000_add_investment_zone_editorial_draft')->exists()){throw new RuntimeException('Instale/verifique primeiro a base 130000.');}
|
||||
Schema::table('investment_zones',fn(Blueprint $table)=>$table->jsonb('operational_details')->nullable());
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! config('investor-context.authenticated_installation')) {
|
||||
if (! config('investor-context.authenticated_installation') && ! \App\Support\IsolatedQaDatabase::allowsSchemaChanges()) {
|
||||
throw new RuntimeException('Use a ação autenticada nominal de instalação do contexto.');
|
||||
}
|
||||
Schema::create('investor_navigation_preferences', function (Blueprint $t) {
|
||||
|
||||
@@ -9,7 +9,7 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!config('editorial.authenticated_installation')) { throw new RuntimeException('Use a instalação editorial autenticada.'); }
|
||||
if (!config('editorial.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()) { throw new RuntimeException('Use a instalação editorial autenticada.'); }
|
||||
Schema::table('portal_resources', fn (Blueprint $t) => $t->jsonb('editorial_content')->nullable());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,295 @@
|
||||
|
||||
@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;
|
||||
}
|
||||
.tempo-login-button {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #ffffff50;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
.tempo-login-button:hover {
|
||||
background: #ed1c24;
|
||||
border-color: #ed1c24;
|
||||
}
|
||||
.tempo-header-actions {
|
||||
order: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.tempo-interest-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ed1c24;
|
||||
border-radius: 0.4rem;
|
||||
background: #ed1c24;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.tempo-interest-button:hover {
|
||||
background: #c9151c;
|
||||
}
|
||||
.tempo-panel-summary {
|
||||
order: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding-block: 0.5rem;
|
||||
border-top: 1px solid #ffffff1a;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.tempo-panel-summary a {
|
||||
color: #fff;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.tempo-panel-summary a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tempo-panel-summary span {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.6875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.tempo-header-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
padding-block: 0.65rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.tempo-header-actions {
|
||||
order: 6;
|
||||
margin-left: 0;
|
||||
}
|
||||
.tempo-header-inner .tempo-nav-trigger {
|
||||
padding-inline: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.tempo-panel-summary {
|
||||
order: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
width: auto;
|
||||
min-width: 102px;
|
||||
border-top: 0;
|
||||
border-left: 1px solid #ffffff1a;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
}
|
||||
.tempo-header :is(a, button):focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.tempo-dropdown {
|
||||
z-index: 60;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.8rem;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
box-shadow: 0 18px 50px #00000026;
|
||||
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
|
||||
animation-duration: 160ms;
|
||||
}
|
||||
.tempo-menu-item {
|
||||
min-height: 42px;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
font-size: 0.8125rem;
|
||||
white-space: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tempo-menu-item[data-highlighted],
|
||||
.tempo-menu-item:focus,
|
||||
.tempo-menu-item[aria-current='page'] {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
outline: none;
|
||||
}
|
||||
.tempo-footer {
|
||||
margin-top: 2rem;
|
||||
background: #050505;
|
||||
color: #fff;
|
||||
}
|
||||
.tempo-footer-heading {
|
||||
margin-bottom: 1.4rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tempo-footer-link {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: #e2e8f0;
|
||||
transition:
|
||||
color 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
.tempo-footer-link > svg {
|
||||
align-self: center;
|
||||
color: #ed1c24;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tempo-footer-link:hover {
|
||||
color: #fb7185;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
.tempo-contact-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.tempo-contact-link:hover {
|
||||
color: #fb7185;
|
||||
}
|
||||
.tempo-contact-icon {
|
||||
display: flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #ffffff12;
|
||||
border-radius: 0.65rem;
|
||||
background: #ffffff08;
|
||||
}
|
||||
.tempo-footer-social {
|
||||
display: flex;
|
||||
width: 40px;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #ffffff15;
|
||||
border-radius: 0.65rem;
|
||||
color: #fff;
|
||||
transition:
|
||||
color 0.2s,
|
||||
border-color 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
.tempo-footer-social:hover {
|
||||
color: var(--social-color);
|
||||
border-color: var(--social-color);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.tempo-footer :is(a, button):focus-visible {
|
||||
outline: 2px solid #fb7185;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tempo-header *,
|
||||
.tempo-footer *,
|
||||
.tempo-dropdown {
|
||||
animation-duration: 0ms !important;
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { usePage } from '@inertiajs/react';
|
||||
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
|
||||
export default function AppLogo() {
|
||||
const { name } = usePage().props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md">
|
||||
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
|
||||
</div>
|
||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
<img
|
||||
src="/images/logo/logo-vermelho.png"
|
||||
alt="Tempo"
|
||||
width="1787"
|
||||
height="564"
|
||||
className="h-8 w-auto max-w-full object-contain group-data-[collapsible=icon]:h-auto group-data-[collapsible=icon]:w-8"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { useNavigationContext } from '@/components/navigation-context';
|
||||
import type { ProfileAccess } from '@/types/profile-access';
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { logout } from '@/routes';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
|
||||
import { ChevronDown, LogOut, UserRound } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigationContext } from '@/components/navigation-context';
|
||||
import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
|
||||
import {
|
||||
TempoLanguageSelector,
|
||||
TempoWeather,
|
||||
} from '@/components/tempo-header-tools';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
portalNavigation,
|
||||
currentNavigationGroup,
|
||||
currentNavigationItem,
|
||||
type PortalNavItem,
|
||||
} from '@/lib/portal-navigation';
|
||||
import type { PortalNavItem } from '@/lib/portal-navigation';
|
||||
import type { ProfileAccess } from '@/types/profile-access';
|
||||
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
|
||||
import { logout } from '@/routes';
|
||||
|
||||
export function PortalGroupedMenu() {
|
||||
const { props, url } = usePage<{
|
||||
@@ -23,10 +39,10 @@ export function PortalGroupedMenu() {
|
||||
educationEnabled?: boolean;
|
||||
aiEnabled?: boolean;
|
||||
legacyContentEnabled?: boolean;
|
||||
portalOperator?: string | null;
|
||||
profileAccess?: ProfileAccess | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
}>();
|
||||
const { t, language, requestCount } = useTempoUi();
|
||||
const navigation = useNavigationContext();
|
||||
const groups = portalNavigation({
|
||||
context: navigation.view
|
||||
@@ -44,159 +60,77 @@ export function PortalGroupedMenu() {
|
||||
});
|
||||
const current = currentNavigationGroup(groups, url);
|
||||
const currentItem = currentNavigationItem(groups, url);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [branch, setBranch] = useState<string | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const root = useRef<HTMLElement>(null);
|
||||
const id = useId();
|
||||
useEffect(() => {
|
||||
const position = () => {
|
||||
const button = open
|
||||
? document.getElementById(`${id}-${open}-button`)
|
||||
: null;
|
||||
if (!button || window.innerWidth < 640) {
|
||||
setOffset(0);
|
||||
return;
|
||||
}
|
||||
const rect = button.getBoundingClientRect();
|
||||
const width = Math.min(288, window.innerWidth - 32);
|
||||
setOffset(
|
||||
Math.max(
|
||||
16,
|
||||
Math.min(rect.left, window.innerWidth - width - 16),
|
||||
) - rect.left,
|
||||
);
|
||||
};
|
||||
position();
|
||||
window.addEventListener('resize', position);
|
||||
return () => window.removeEventListener('resize', position);
|
||||
}, [open, id]);
|
||||
useEffect(() => {
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!root.current?.contains(event.target)
|
||||
) {
|
||||
setOpen(null);
|
||||
setBranch(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', outside);
|
||||
return () => document.removeEventListener('pointerdown', outside);
|
||||
}, []);
|
||||
const close = () => {
|
||||
setOpen(null);
|
||||
setBranch(null);
|
||||
};
|
||||
const items = (children: PortalNavItem[]) =>
|
||||
children.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.children ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
id={`${id}-${item.id}-button`}
|
||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left font-semibold hover:bg-emerald-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700"
|
||||
aria-expanded={branch === item.id}
|
||||
aria-controls={`${id}-${item.id}`}
|
||||
onClick={() =>
|
||||
setBranch(branch === item.id ? null : item.id)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
<span aria-hidden="true">
|
||||
{branch === item.id ? '−' : '+'}
|
||||
</span>
|
||||
</button>
|
||||
{branch === item.id && (
|
||||
<ul
|
||||
id={`${id}-${item.id}`}
|
||||
className="ml-3 space-y-1 border-l-2 border-emerald-200 pl-2"
|
||||
>
|
||||
{items(item.children)}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
children.map((item) =>
|
||||
item.children ? (
|
||||
<DropdownMenuSub key={item.id}>
|
||||
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
|
||||
{t(item.label)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
collisionPadding={12}
|
||||
className="tempo-dropdown max-h-[min(70vh,34rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||
>
|
||||
{items(item.children)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
asChild
|
||||
className="tempo-menu-item"
|
||||
>
|
||||
<Link
|
||||
href={item.href!}
|
||||
onClick={close}
|
||||
aria-current={
|
||||
currentItem === item.id ? 'page' : undefined
|
||||
}
|
||||
className="block rounded-lg px-3 py-2 hover:bg-emerald-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700"
|
||||
>
|
||||
{item.label}
|
||||
{t(item.label)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
));
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
);
|
||||
const accountGroups = groups.filter(
|
||||
(group) =>
|
||||
!['home', 'discovery', 'realization', 'support'].includes(group.id),
|
||||
);
|
||||
return (
|
||||
<header
|
||||
ref={root}
|
||||
className="relative z-40 border-b border-slate-200 bg-white text-slate-900"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
if (branch) {
|
||||
document.getElementById(`${id}-${branch}-button`)?.focus();
|
||||
setBranch(null);
|
||||
} else if (open) {
|
||||
document.getElementById(`${id}-${open}-button`)?.focus();
|
||||
close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-extrabold tracking-tight text-emerald-900"
|
||||
>
|
||||
InvesteMoz
|
||||
</Link>
|
||||
<p className="max-w-2xl text-xs leading-relaxed text-slate-600">
|
||||
Iniciativa privada e independente de apoio ao
|
||||
investimento. Orientação e acompanhamento internos;
|
||||
decisões oficiais cabem às instituições competentes.
|
||||
{props.portalOperator && (
|
||||
<> Operador: {props.portalOperator}.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{props.auth?.user && (
|
||||
<nav
|
||||
aria-label="Sessão da conta"
|
||||
className="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-lg bg-slate-50 px-3 py-2"
|
||||
>
|
||||
<p className="min-w-0 text-sm [overflow-wrap:anywhere] break-words text-slate-700">
|
||||
Sessão:{' '}
|
||||
{props.auth.user.name || props.auth.user.email}
|
||||
</p>
|
||||
<Link
|
||||
href={logout()}
|
||||
as="button"
|
||||
type="button"
|
||||
disabled={loggingOut}
|
||||
onClick={() => {
|
||||
close();
|
||||
router.flushAll();
|
||||
}}
|
||||
onStart={() => setLoggingOut(true)}
|
||||
onFinish={() => setLoggingOut(false)}
|
||||
data-test="portal-logout-button"
|
||||
className="inline-flex min-h-11 shrink-0 items-center gap-2 rounded-lg border border-slate-400 bg-white px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<LogOut aria-hidden="true" className="size-4" />
|
||||
{loggingOut
|
||||
? 'A terminar sessão…'
|
||||
: 'Terminar sessão'}
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
<nav aria-label="Áreas do portal">
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
<header className="tempo-header" lang={language}>
|
||||
<div className="tempo-header-inner mx-auto flex max-w-[1440px] flex-wrap items-center justify-between gap-x-3 gap-y-0 px-4 sm:px-6 xl:min-h-22">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Tempo — início"
|
||||
className="tempo-logo-link my-4 shrink-0"
|
||||
>
|
||||
<img
|
||||
src="/images/logo/logo-vermelho.png"
|
||||
alt="Tempo"
|
||||
width="1787"
|
||||
height="564"
|
||||
className="h-9 w-auto object-contain sm:h-10"
|
||||
/>
|
||||
</Link>
|
||||
<nav
|
||||
aria-label="Áreas do portal"
|
||||
className="order-3 w-full border-t border-white/10 xl:order-none xl:w-auto xl:flex-1 xl:border-0"
|
||||
>
|
||||
<ul className="flex items-center justify-between gap-0 py-2 xl:justify-center xl:py-0">
|
||||
<li>
|
||||
<Link
|
||||
href="/"
|
||||
className="tempo-nav-trigger"
|
||||
aria-current={
|
||||
current === 'home' ? 'page' : undefined
|
||||
}
|
||||
data-current-group={
|
||||
current === 'home' || undefined
|
||||
}
|
||||
>
|
||||
{t('Início')}
|
||||
</Link>
|
||||
</li>
|
||||
{groups
|
||||
.filter((group) =>
|
||||
[
|
||||
@@ -206,109 +140,115 @@ export function PortalGroupedMenu() {
|
||||
].includes(group.id),
|
||||
)
|
||||
.map((group) => (
|
||||
<li
|
||||
key={group.id}
|
||||
className="relative w-full sm:w-auto"
|
||||
>
|
||||
<button
|
||||
id={`${id}-${group.id}-button`}
|
||||
type="button"
|
||||
className={`flex min-h-11 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-left text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 ${current === group.id ? 'border-emerald-700 bg-emerald-50 text-emerald-950' : 'border-slate-200 hover:bg-slate-50'}`}
|
||||
aria-expanded={open === group.id}
|
||||
aria-controls={`${id}-${group.id}`}
|
||||
data-current-group={
|
||||
current === group.id || undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setOpen(
|
||||
open === group.id
|
||||
? null
|
||||
: group.id,
|
||||
);
|
||||
setBranch(null);
|
||||
}}
|
||||
>
|
||||
{group.label}
|
||||
<span aria-hidden="true">
|
||||
{open === group.id ? '▴' : '▾'}
|
||||
</span>
|
||||
{current === group.id && (
|
||||
<span className="sr-only">
|
||||
Grupo actual
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open === group.id && (
|
||||
<ul
|
||||
id={`${id}-${group.id}`}
|
||||
style={{
|
||||
transform: `translateX(${offset}px)`,
|
||||
}}
|
||||
className="mt-2 max-h-[min(70vh,32rem)] w-full space-y-1 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2 text-sm shadow-xl sm:absolute sm:top-full sm:left-0 sm:z-40 sm:w-72 sm:max-w-[calc(100vw-2rem)]"
|
||||
<li key={group.id}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
className="tempo-nav-trigger"
|
||||
data-current-group={
|
||||
current === group.id ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
{t(group.label)}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{current === group.id && (
|
||||
<span className="sr-only">
|
||||
Grupo actual
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
collisionPadding={12}
|
||||
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||
>
|
||||
<li className="border-b border-slate-100 px-3 pb-2 text-xs leading-relaxed text-slate-600">
|
||||
{group.id === 'discovery'
|
||||
? 'Conhecer possibilidades, sectores e localizações.'
|
||||
: group.id === 'realization'
|
||||
? 'Preparar o investimento e os seus requisitos.'
|
||||
: 'Encontrar serviços, acompanhar e pedir apoio.'}
|
||||
</li>
|
||||
{items(group.children ?? [])}
|
||||
</ul>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<nav
|
||||
aria-label="Conta e trabalho por papel"
|
||||
className="mt-3 grid gap-3 sm:grid-cols-2"
|
||||
>
|
||||
<details className="rounded border p-3">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
A minha conta e atividade
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{items(
|
||||
groups.filter((g) =>
|
||||
['account', 'personal-activity'].includes(
|
||||
g.id,
|
||||
),
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
{groups.some((g) =>
|
||||
[
|
||||
'management',
|
||||
'profile-opportunities',
|
||||
'business-work',
|
||||
].includes(g.id),
|
||||
) && (
|
||||
<details className="rounded border p-3">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
Área de trabalho — criação, revisão, publicação
|
||||
e administração
|
||||
</summary>
|
||||
<p className="my-2 text-xs">
|
||||
Destinos disponíveis segundo o papel e âmbito
|
||||
existentes; não exige percorrer a jornada do
|
||||
investidor.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{items(
|
||||
groups.filter((g) =>
|
||||
[
|
||||
'management',
|
||||
'profile-opportunities',
|
||||
'business-work',
|
||||
].includes(g.id),
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
<div className="tempo-panel-summary">
|
||||
<Link href="/investidor/manifestacoes">
|
||||
{t('O meu painel')}
|
||||
</Link>
|
||||
<span aria-live="polite" aria-atomic="true">
|
||||
{requestCount}{' '}
|
||||
{t(
|
||||
requestCount === 1
|
||||
? 'pedido recebido'
|
||||
: 'pedidos recebidos',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="order-5 flex w-full items-center justify-between gap-2 border-t border-white/10 py-2 xl:order-none xl:w-auto xl:justify-end xl:border-0 xl:py-0">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<TempoWeather />
|
||||
<TempoLanguageSelector />
|
||||
</div>
|
||||
<TempoSocialLinks />
|
||||
</div>
|
||||
<div className="tempo-header-actions">
|
||||
{props.auth?.user ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger className="tempo-login-button">
|
||||
<UserRound size={16} aria-hidden="true" />
|
||||
<span className="max-w-24 truncate">
|
||||
{props.auth.user.name || t('A minha área')}
|
||||
</span>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
collisionPadding={12}
|
||||
className="tempo-dropdown max-h-[75vh] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||
>
|
||||
<DropdownMenuLabel className="truncate text-xs text-slate-500">
|
||||
{props.auth.user.email}
|
||||
</DropdownMenuLabel>
|
||||
{items(accountGroups)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="tempo-menu-item text-red-700"
|
||||
>
|
||||
<Link
|
||||
href={logout()}
|
||||
as="button"
|
||||
type="button"
|
||||
disabled={loggingOut}
|
||||
data-test="portal-logout-button"
|
||||
onClick={() => router.flushAll()}
|
||||
onStart={() => setLoggingOut(true)}
|
||||
onFinish={() => setLoggingOut(false)}
|
||||
>
|
||||
<LogOut size={16} aria-hidden="true" />
|
||||
{loggingOut
|
||||
? 'A terminar sessão…'
|
||||
: t('Terminar sessão')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Link href="/login" className="tempo-login-button">
|
||||
<UserRound size={15} aria-hidden="true" />
|
||||
{t('Entrar')}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
<Link
|
||||
href="/manifestar-interesse"
|
||||
className="tempo-interest-button"
|
||||
>
|
||||
{t('Manifestar interesse')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -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,136 @@
|
||||
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}>
|
||||
<a
|
||||
href={`https://teste-info.tempo.co.mz/${path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="tempo-footer-link"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(label)}
|
||||
</a>
|
||||
</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 { createContext, useContext } from 'react';
|
||||
import { PortalGroupedMenu } from '@/components/portal-grouped-menu';
|
||||
import { TempoUiProvider } from '@/components/tempo-brand';
|
||||
import { TempoFooter } from '@/components/tempo-footer';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
NavigationContextProvider,
|
||||
@@ -99,80 +101,85 @@ export default function PortalNavigation({
|
||||
? 'Definições da conta'
|
||||
: 'Detalhes');
|
||||
return (
|
||||
<NavigationContextProvider>
|
||||
<MenuPlacement.Provider value={true}>
|
||||
<div
|
||||
className={`pb-8 lg:pb-24 [&_button]:scroll-mb-24 [&_input]:scroll-mb-24 [&_select]:scroll-mb-24 [&_textarea]:scroll-mb-24 ${printable ? 'print:pb-0' : ''}`}
|
||||
>
|
||||
<nav
|
||||
aria-label="Atalho para o conteúdo"
|
||||
className={printable ? 'print:hidden' : undefined}
|
||||
>
|
||||
<a
|
||||
href="#portal-content"
|
||||
className="sr-only focus:not-sr-only focus:block focus:bg-white focus:p-4 focus:text-emerald-900"
|
||||
<TempoUiProvider>
|
||||
<NavigationContextProvider>
|
||||
<MenuPlacement.Provider value={true}>
|
||||
<div className="min-h-screen [&_button]:scroll-mb-24 [&_input]:scroll-mb-24 [&_select]:scroll-mb-24 [&_textarea]:scroll-mb-24">
|
||||
<nav
|
||||
aria-label="Atalho para o conteúdo"
|
||||
className={printable ? 'print:hidden' : undefined}
|
||||
>
|
||||
Saltar para o conteúdo
|
||||
</a>
|
||||
</nav>
|
||||
{portalShell && (
|
||||
<div className={printable ? 'print:hidden' : undefined}>
|
||||
<PortalMenu primary />
|
||||
</div>
|
||||
)}
|
||||
<nav
|
||||
aria-label="Caminho de navegação"
|
||||
className={`bg-background text-foreground flex flex-wrap items-center gap-2 border-b px-5 py-3 text-sm ${printable ? 'print:hidden' : ''}`}
|
||||
>
|
||||
{path === '/' ? (
|
||||
<span aria-current="page">Início</span>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/" className="underline">
|
||||
Início
|
||||
</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
{parent && (
|
||||
<>
|
||||
<Link
|
||||
href={parent}
|
||||
className="underline"
|
||||
>
|
||||
{names[parent]}
|
||||
</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
</>
|
||||
)}
|
||||
<span aria-current="page">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div id="portal-content" tabIndex={-1}>
|
||||
<a
|
||||
href="#portal-content"
|
||||
className="sr-only focus:not-sr-only focus:block focus:bg-white focus:p-4 focus:text-emerald-900"
|
||||
>
|
||||
Saltar para o conteúdo
|
||||
</a>
|
||||
</nav>
|
||||
{portalShell && (
|
||||
<div
|
||||
className={
|
||||
printable ? 'print:hidden' : undefined
|
||||
}
|
||||
>
|
||||
<NavigationContextSummary />
|
||||
<PortalMenu primary />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<aside
|
||||
aria-label="Contacto de apoio ao investidor"
|
||||
className={printable ? 'print:hidden' : undefined}
|
||||
>
|
||||
<a
|
||||
href="/apoio"
|
||||
className="mx-3 mt-4 block max-w-[calc(100%-1.5rem)] rounded-xl bg-emerald-800 px-5 py-3 text-center text-sm font-semibold text-white shadow-xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-600"
|
||||
{path !== '/' && (
|
||||
<nav
|
||||
aria-label="Caminho de navegação"
|
||||
className={`bg-background text-foreground flex flex-wrap items-center gap-2 border-b px-5 py-3 text-sm ${printable ? 'print:hidden' : ''}`}
|
||||
>
|
||||
<>
|
||||
<Link href="/" className="underline">
|
||||
Início
|
||||
</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
{parent && (
|
||||
<>
|
||||
<Link
|
||||
href={parent}
|
||||
className="underline"
|
||||
>
|
||||
{names[parent]}
|
||||
</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
</>
|
||||
)}
|
||||
<span aria-current="page">{title}</span>
|
||||
</>
|
||||
</nav>
|
||||
)}
|
||||
<div id="portal-content" tabIndex={-1}>
|
||||
{portalShell && (
|
||||
<div
|
||||
className={
|
||||
printable ? 'print:hidden' : undefined
|
||||
}
|
||||
>
|
||||
<NavigationContextSummary />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<aside
|
||||
aria-label="Contacto de apoio ao investidor"
|
||||
className={printable ? 'print:hidden' : undefined}
|
||||
>
|
||||
Contactar Apoio ao Investidor
|
||||
</a>
|
||||
</aside>
|
||||
</div>
|
||||
</MenuPlacement.Provider>
|
||||
</NavigationContextProvider>
|
||||
<a
|
||||
href="/apoio"
|
||||
className="mx-3 mt-4 block max-w-[calc(100%-1.5rem)] rounded-xl bg-emerald-800 px-5 py-3 text-center text-sm font-semibold text-white shadow-xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-600"
|
||||
>
|
||||
Contactar Apoio ao Investidor
|
||||
</a>
|
||||
</aside>
|
||||
<div className={printable ? 'print:hidden' : undefined}>
|
||||
<TempoFooter />
|
||||
</div>
|
||||
</div>
|
||||
</MenuPlacement.Provider>
|
||||
</NavigationContextProvider>
|
||||
</TempoUiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -440,11 +440,11 @@ export function currentNavigationItem(
|
||||
export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||
const previous = legacyPortalNavigation(access);
|
||||
return uniqueNavigationDestinations([
|
||||
link('home', 'Início', '/'),
|
||||
{
|
||||
id: 'discovery',
|
||||
label: 'Descoberta',
|
||||
children: [
|
||||
link('home', 'Início', '/'),
|
||||
link(
|
||||
'why',
|
||||
'Porquê investir em Moçambique',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useRef, useState } from 'react';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { PortalMenu } from '@/layouts/portal-navigation';
|
||||
import { useTempoUi } from '@/components/tempo-brand';
|
||||
import { ProvinceSelect } from '@/components/investment-territory';
|
||||
import { MATURITY_LEVELS } from '@/lib/investment-maturity';
|
||||
import CountrySelect, { type Country } from '@/components/country-select';
|
||||
@@ -94,48 +95,6 @@ function QueryStatus({
|
||||
/* Componentes */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function TopBar({
|
||||
onNavigate,
|
||||
requestCount,
|
||||
}: {
|
||||
onNavigate: (view: View) => void;
|
||||
requestCount: number;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Navegação das manifestações"
|
||||
className="sticky top-0 z-30 border-b border-slate-200 bg-white/95 backdrop-blur"
|
||||
>
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-5 py-4">
|
||||
<button
|
||||
onClick={() => onNavigate('home')}
|
||||
className="flex items-baseline gap-1 font-black tracking-tight text-slate-900"
|
||||
>
|
||||
<span className="text-lg">INVESTE</span>
|
||||
<span className="text-lg text-emerald-700">Moçambique</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||
<a href="/investidor/manifestacoes" className="underline">
|
||||
O meu painel
|
||||
</a>
|
||||
<span className="hidden sm:inline">
|
||||
{requestCount} pedido{requestCount === 1 ? '' : 's'}{' '}
|
||||
recebido
|
||||
{requestCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onNavigate('interest')}
|
||||
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-bold text-white hover:bg-slate-700"
|
||||
>
|
||||
Manifestar interesse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Hero({
|
||||
query,
|
||||
setQuery,
|
||||
@@ -1048,23 +1007,6 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-slate-200 bg-slate-950 py-10 text-slate-400">
|
||||
<div className="mx-auto max-w-6xl px-5 text-sm">
|
||||
<p className="font-bold text-white">INVESTE Moçambique</p>
|
||||
<p className="mt-2 max-w-md">
|
||||
Portal do Investidor — conheça as oportunidades de
|
||||
investimento em Moçambique e manifeste o seu interesse.{' '}
|
||||
<a href="/acesso-gestao" className="underline">
|
||||
Acesso à gestão
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* App */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -1092,7 +1034,7 @@ export default function PortalInvestidorApp({
|
||||
const selected = initialOpportunity
|
||||
? toOpportunity(initialOpportunity)
|
||||
: null;
|
||||
const [requestCount, setRequestCount] = useState(0);
|
||||
const { registerSubmittedInterest } = useTempoUi();
|
||||
const [heroQuery, setHeroQuery] = useState('');
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
query: initialQuery,
|
||||
@@ -1137,7 +1079,6 @@ export default function PortalInvestidorApp({
|
||||
return (
|
||||
<SectorContext.Provider value={sectors}>
|
||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
||||
<TopBar onNavigate={navigate} requestCount={requestCount} />
|
||||
<PortalMenu />
|
||||
<main>
|
||||
{view === 'home' && (
|
||||
@@ -1376,11 +1317,10 @@ export default function PortalInvestidorApp({
|
||||
key={selected?.reference ?? 'general'}
|
||||
preselected={selected}
|
||||
countries={countries}
|
||||
onSubmitted={() => setRequestCount((c) => c + 1)}
|
||||
onSubmitted={registerSubmittedInterest}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
<style>{`
|
||||
.input {
|
||||
|
||||
@@ -1307,16 +1307,6 @@ export default function Services(p: Props) {
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<footer className="border-t px-5 py-8 text-center text-sm">
|
||||
Investe Moçambique ·{' '}
|
||||
<Link href="/apoio" className="underline">
|
||||
Apoio ao Investidor
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link href="/documentos" className="underline">
|
||||
Documentos e Formulários
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ Route::middleware(['auth', 'verified', 'can:manage-portal'])->group(function ()
|
||||
Route::get('/management/portal-services/resources/{id}/article', [\App\Http\Controllers\EditorialArticleController::class, 'edit'])->whereNumber('id')->name('editorial.edit');
|
||||
Route::put('/management/portal-services/resources/{id}/article', [\App\Http\Controllers\EditorialArticleController::class, 'save'])->whereNumber('id')->name('editorial.save');
|
||||
Route::get('/management/editorial-installation', [\App\Http\Controllers\EditorialArticleController::class, 'installation'])->name('editorial.installation');
|
||||
Route::get('/management/editorial-installation/mfa', [\App\Http\Controllers\EditorialArticleController::class, 'confirmMfa'])->name('editorial.mfa');
|
||||
Route::get('/management/editorial-installation/mfa', [\App\Http\Controllers\EditorialArticleController::class, 'showMfa'])->name('editorial.mfa');
|
||||
Route::post('/management/editorial-installation/mfa', [\App\Http\Controllers\EditorialArticleController::class, 'confirmMfa'])->middleware('throttle:5,1');
|
||||
Route::post('/management/editorial-installation', [\App\Http\Controllers\EditorialArticleController::class, 'install'])->name('editorial.install');
|
||||
Route::get('/management/portal-services', [PortalServicesController::class, 'manage'])->name('services.manage');
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DevelopmentEnvironmentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_the_verified_qa_connection_can_prepare_its_schema(): void
|
||||
{
|
||||
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
|
||||
$this->artisan('portal:dev-check')->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_an_active_environment_is_never_treated_as_qa(): void
|
||||
{
|
||||
$this->app->instance('env', 'production');
|
||||
$this->assertFalse(IsolatedQaDatabase::allowsSchemaChanges());
|
||||
}
|
||||
|
||||
public function test_invalid_targets_and_a_mismatched_real_database_are_refused(): void
|
||||
{
|
||||
$original = config('database.connections.pgsql');
|
||||
foreach (['host' => 'remote.example', 'port' => '5432', 'database' => 'investemoz', 'url' => 'pgsql://remote.example/qa_test'] as $key => $value) {
|
||||
config(['database.connections.pgsql' => [...$original, $key => $value]]);
|
||||
$this->assertFalse(IsolatedQaDatabase::allowsSchemaChanges(), $key);
|
||||
}
|
||||
config(['database.connections.pgsql' => [...$original, 'database' => 'different_qa_test']]);
|
||||
$this->assertFalse(IsolatedQaDatabase::allowsSchemaChanges());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\EconomicReferenceCatalog;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class EconomicReferenceManifestTest extends TestCase
|
||||
{
|
||||
public function test_the_distributed_manifest_matches_the_existing_approved_byte_hash(): void
|
||||
{
|
||||
$path = dirname(__DIR__, 2).'/database/data/economic-references-lot-01.json';
|
||||
$this->assertSame(EconomicReferenceCatalog::MANIFEST_SHA256, hash_file('sha256', $path));
|
||||
$manifest = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertSame(EconomicReferenceCatalog::LOT, $manifest['lot']);
|
||||
$this->assertCount(5, $manifest['activities']);
|
||||
$this->assertCount(5, $manifest['chains']);
|
||||
$this->assertCount(4, $manifest['links']);
|
||||
}
|
||||
}
|
||||
@@ -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