header:navbar e footer
tests / ci (push) Canceled after 0s

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

After

Width:  |  Height:  |  Size: 25 KiB

+289
View File
@@ -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,
+7 -16
View File
@@ -1,20 +1,11 @@
import { usePage } from '@inertiajs/react';
import AppLogoIcon from '@/components/app-logo-icon';
export default function AppLogo() {
const { name } = usePage().props;
return (
<>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md">
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">
{name}
</span>
</div>
</>
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo"
width="1787"
height="564"
className="h-8 w-auto max-w-full object-contain group-data-[collapsible=icon]:h-auto group-data-[collapsible=icon]:w-8"
/>
);
}
+187 -247
View File
@@ -1,16 +1,32 @@
import { useNavigationContext } from '@/components/navigation-context';
import type { ProfileAccess } from '@/types/profile-access';
import { Link, router, usePage } from '@inertiajs/react';
import { LogOut } from 'lucide-react';
import { logout } from '@/routes';
import { useEffect, useId, useRef, useState } from 'react';
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
import { ChevronDown, LogOut, UserRound } from 'lucide-react';
import { useState } from 'react';
import { useNavigationContext } from '@/components/navigation-context';
import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
import {
TempoLanguageSelector,
TempoWeather,
} from '@/components/tempo-header-tools';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
portalNavigation,
currentNavigationGroup,
currentNavigationItem,
type PortalNavItem,
} from '@/lib/portal-navigation';
import type { PortalNavItem } from '@/lib/portal-navigation';
import type { ProfileAccess } from '@/types/profile-access';
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
import { logout } from '@/routes';
export function PortalGroupedMenu() {
const { props, url } = usePage<{
@@ -23,10 +39,10 @@ export function PortalGroupedMenu() {
educationEnabled?: boolean;
aiEnabled?: boolean;
legacyContentEnabled?: boolean;
portalOperator?: string | null;
profileAccess?: ProfileAccess | null;
opportunityManagement?: OpportunityManagementAccess | null;
}>();
const { t, language, requestCount } = useTempoUi();
const navigation = useNavigationContext();
const groups = portalNavigation({
context: navigation.view
@@ -44,159 +60,77 @@ export function PortalGroupedMenu() {
});
const current = currentNavigationGroup(groups, url);
const currentItem = currentNavigationItem(groups, url);
const [open, setOpen] = useState<string | null>(null);
const [branch, setBranch] = useState<string | null>(null);
const [offset, setOffset] = useState(0);
const [loggingOut, setLoggingOut] = useState(false);
const root = useRef<HTMLElement>(null);
const id = useId();
useEffect(() => {
const position = () => {
const button = open
? document.getElementById(`${id}-${open}-button`)
: null;
if (!button || window.innerWidth < 640) {
setOffset(0);
return;
}
const rect = button.getBoundingClientRect();
const width = Math.min(288, window.innerWidth - 32);
setOffset(
Math.max(
16,
Math.min(rect.left, window.innerWidth - width - 16),
) - rect.left,
);
};
position();
window.addEventListener('resize', position);
return () => window.removeEventListener('resize', position);
}, [open, id]);
useEffect(() => {
const outside = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!root.current?.contains(event.target)
) {
setOpen(null);
setBranch(null);
}
};
document.addEventListener('pointerdown', outside);
return () => document.removeEventListener('pointerdown', outside);
}, []);
const close = () => {
setOpen(null);
setBranch(null);
};
const items = (children: PortalNavItem[]) =>
children.map((item) => (
<li key={item.id}>
{item.children ? (
<>
<button
type="button"
id={`${id}-${item.id}-button`}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left font-semibold hover:bg-emerald-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700"
aria-expanded={branch === item.id}
aria-controls={`${id}-${item.id}`}
onClick={() =>
setBranch(branch === item.id ? null : item.id)
}
>
{item.label}
<span aria-hidden="true">
{branch === item.id ? '−' : '+'}
</span>
</button>
{branch === item.id && (
<ul
id={`${id}-${item.id}`}
className="ml-3 space-y-1 border-l-2 border-emerald-200 pl-2"
>
{items(item.children)}
</ul>
)}
</>
) : (
children.map((item) =>
item.children ? (
<DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
{t(item.label)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={12}
className="tempo-dropdown max-h-[min(70vh,34rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
{items(item.children)}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem
key={item.id}
asChild
className="tempo-menu-item"
>
<Link
href={item.href!}
onClick={close}
aria-current={
currentItem === item.id ? 'page' : undefined
}
className="block rounded-lg px-3 py-2 hover:bg-emerald-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700"
>
{item.label}
{t(item.label)}
</Link>
)}
</li>
));
</DropdownMenuItem>
),
);
const accountGroups = groups.filter(
(group) =>
!['home', 'discovery', 'realization', 'support'].includes(group.id),
);
return (
<header
ref={root}
className="relative z-40 border-b border-slate-200 bg-white text-slate-900"
onKeyDown={(event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
if (branch) {
document.getElementById(`${id}-${branch}-button`)?.focus();
setBranch(null);
} else if (open) {
document.getElementById(`${id}-${open}-button`)?.focus();
close();
}
}}
>
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<Link
href="/"
className="text-xl font-extrabold tracking-tight text-emerald-900"
>
InvesteMoz
</Link>
<p className="max-w-2xl text-xs leading-relaxed text-slate-600">
Iniciativa privada e independente de apoio ao
investimento. Orientação e acompanhamento internos;
decisões oficiais cabem às instituições competentes.
{props.portalOperator && (
<> Operador: {props.portalOperator}.</>
)}
</p>
</div>
{props.auth?.user && (
<nav
aria-label="Sessão da conta"
className="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-lg bg-slate-50 px-3 py-2"
>
<p className="min-w-0 text-sm [overflow-wrap:anywhere] break-words text-slate-700">
Sessão:{' '}
{props.auth.user.name || props.auth.user.email}
</p>
<Link
href={logout()}
as="button"
type="button"
disabled={loggingOut}
onClick={() => {
close();
router.flushAll();
}}
onStart={() => setLoggingOut(true)}
onFinish={() => setLoggingOut(false)}
data-test="portal-logout-button"
className="inline-flex min-h-11 shrink-0 items-center gap-2 rounded-lg border border-slate-400 bg-white px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 disabled:cursor-wait disabled:opacity-60"
>
<LogOut aria-hidden="true" className="size-4" />
{loggingOut
? 'A terminar sessão…'
: 'Terminar sessão'}
</Link>
</nav>
)}
<nav aria-label="Áreas do portal">
<ul className="flex flex-wrap gap-2">
<header className="tempo-header" lang={language}>
<div className="tempo-header-inner mx-auto flex max-w-[1440px] flex-wrap items-center justify-between gap-x-3 gap-y-0 px-4 sm:px-6 xl:min-h-22">
<Link
href="/"
aria-label="Tempo — início"
className="tempo-logo-link my-4 shrink-0"
>
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo"
width="1787"
height="564"
className="h-9 w-auto object-contain sm:h-10"
/>
</Link>
<nav
aria-label="Áreas do portal"
className="order-3 w-full border-t border-white/10 xl:order-none xl:w-auto xl:flex-1 xl:border-0"
>
<ul className="flex items-center justify-between gap-0 py-2 xl:justify-center xl:py-0">
<li>
<Link
href="/"
className="tempo-nav-trigger"
aria-current={
current === 'home' ? 'page' : undefined
}
data-current-group={
current === 'home' || undefined
}
>
{t('Início')}
</Link>
</li>
{groups
.filter((group) =>
[
@@ -206,109 +140,115 @@ export function PortalGroupedMenu() {
].includes(group.id),
)
.map((group) => (
<li
key={group.id}
className="relative w-full sm:w-auto"
>
<button
id={`${id}-${group.id}-button`}
type="button"
className={`flex min-h-11 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-left text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 ${current === group.id ? 'border-emerald-700 bg-emerald-50 text-emerald-950' : 'border-slate-200 hover:bg-slate-50'}`}
aria-expanded={open === group.id}
aria-controls={`${id}-${group.id}`}
data-current-group={
current === group.id || undefined
}
onClick={() => {
setOpen(
open === group.id
? null
: group.id,
);
setBranch(null);
}}
>
{group.label}
<span aria-hidden="true">
{open === group.id ? '▴' : '▾'}
</span>
{current === group.id && (
<span className="sr-only">
Grupo actual
</span>
)}
</button>
{open === group.id && (
<ul
id={`${id}-${group.id}`}
style={{
transform: `translateX(${offset}px)`,
}}
className="mt-2 max-h-[min(70vh,32rem)] w-full space-y-1 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2 text-sm shadow-xl sm:absolute sm:top-full sm:left-0 sm:z-40 sm:w-72 sm:max-w-[calc(100vw-2rem)]"
<li key={group.id}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-nav-trigger"
data-current-group={
current === group.id ||
undefined
}
>
{t(group.label)}
<ChevronDown
size={14}
aria-hidden="true"
/>
{current === group.id && (
<span className="sr-only">
Grupo actual
</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
<li className="border-b border-slate-100 px-3 pb-2 text-xs leading-relaxed text-slate-600">
{group.id === 'discovery'
? 'Conhecer possibilidades, sectores e localizações.'
: group.id === 'realization'
? 'Preparar o investimento e os seus requisitos.'
: 'Encontrar serviços, acompanhar e pedir apoio.'}
</li>
{items(group.children ?? [])}
</ul>
)}
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
</nav>
<nav
aria-label="Conta e trabalho por papel"
className="mt-3 grid gap-3 sm:grid-cols-2"
>
<details className="rounded border p-3">
<summary className="cursor-pointer font-semibold">
A minha conta e atividade
</summary>
<ul className="mt-2 space-y-1">
{items(
groups.filter((g) =>
['account', 'personal-activity'].includes(
g.id,
),
),
)}
</ul>
</details>
{groups.some((g) =>
[
'management',
'profile-opportunities',
'business-work',
].includes(g.id),
) && (
<details className="rounded border p-3">
<summary className="cursor-pointer font-semibold">
Área de trabalho — criação, revisão, publicação
e administração
</summary>
<p className="my-2 text-xs">
Destinos disponíveis segundo o papel e âmbito
existentes; não exige percorrer a jornada do
investidor.
</p>
<ul className="space-y-1">
{items(
groups.filter((g) =>
[
'management',
'profile-opportunities',
'business-work',
].includes(g.id),
),
)}
</ul>
</details>
<div className="tempo-panel-summary">
<Link href="/investidor/manifestacoes">
{t('O meu painel')}
</Link>
<span aria-live="polite" aria-atomic="true">
{requestCount}{' '}
{t(
requestCount === 1
? 'pedido recebido'
: 'pedidos recebidos',
)}
</span>
</div>
<div className="order-5 flex w-full items-center justify-between gap-2 border-t border-white/10 py-2 xl:order-none xl:w-auto xl:justify-end xl:border-0 xl:py-0">
<div className="flex items-center gap-1 sm:gap-2">
<TempoWeather />
<TempoLanguageSelector />
</div>
<TempoSocialLinks />
</div>
<div className="tempo-header-actions">
{props.auth?.user ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="tempo-login-button">
<UserRound size={16} aria-hidden="true" />
<span className="max-w-24 truncate">
{props.auth.user.name || t('A minha área')}
</span>
<ChevronDown size={12} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[75vh] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
<DropdownMenuLabel className="truncate text-xs text-slate-500">
{props.auth.user.email}
</DropdownMenuLabel>
{items(accountGroups)}
<DropdownMenuSeparator />
<DropdownMenuItem
asChild
className="tempo-menu-item text-red-700"
>
<Link
href={logout()}
as="button"
type="button"
disabled={loggingOut}
data-test="portal-logout-button"
onClick={() => router.flushAll()}
onStart={() => setLoggingOut(true)}
onFinish={() => setLoggingOut(false)}
>
<LogOut size={16} aria-hidden="true" />
{loggingOut
? 'A terminar sessão…'
: t('Terminar sessão')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/login" className="tempo-login-button">
<UserRound size={15} aria-hidden="true" />
{t('Entrar')}
</Link>
)}
</nav>
<Link
href="/manifestar-interesse"
className="tempo-interest-button"
>
{t('Manifestar interesse')}
</Link>
</div>
</div>
</header>
);
+288
View File
@@ -0,0 +1,288 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { Facebook, Instagram, Linkedin, Youtube } from 'lucide-react';
import type { CSSProperties, ReactNode } from 'react';
export const tempoSocialLinks = [
{
name: 'Facebook',
href: 'https://www.facebook.com/revistatempo.mz/',
icon: Facebook,
color: '#1877f2',
},
{
name: 'Instagram',
href: 'https://www.instagram.com/revistatempo.mz/',
icon: Instagram,
color: '#e1306c',
},
{
name: 'LinkedIn',
href: 'https://www.linkedin.com/company/revistatempo/',
icon: Linkedin,
color: '#38a5df',
},
{
name: 'YouTube',
href: 'https://www.youtube.com/@RevistaTempoMz/',
icon: Youtube,
color: '#ff3333',
},
];
const menuTranslations = [
['O meu painel', 'My dashboard', 'Mon espace', 'Mi panel'],
[
'Manifestar interesse',
'Express interest',
'Manifester un intérêt',
'Manifestar interés',
],
[
'pedido recebido',
'request received',
'demande reçue',
'solicitud recibida',
],
[
'pedidos recebidos',
'requests received',
'demandes reçues',
'solicitudes recibidas',
],
[
'Porquê investir em Moçambique',
'Why invest in Mozambique',
'Pourquoi investir au Mozambique',
'Por qué invertir en Mozambique',
],
[
'Oportunidades de investimento',
'Investment opportunities',
'Opportunités d’investissement',
'Oportunidades de inversión',
],
[
'Sectores de investimento',
'Investment sectors',
'Secteurs d’investissement',
'Sectores de inversión',
],
['Localizações', 'Locations', 'Localisations', 'Ubicaciones'],
[
'Iniciar investimento',
'Start an investment',
'Démarrer un investissement',
'Iniciar una inversión',
],
[
'Incentivos e simulador fiscal',
'Incentives and tax simulator',
'Incitations et simulateur fiscal',
'Incentivos y simulador fiscal',
],
['Licenciamento', 'Licensing', 'Autorisations', 'Licencias'],
['Acesso à terra', 'Land access', 'Accès à la terre', 'Acceso a la tierra'],
[
'Serviços ao investidor',
'Investor services',
'Services aux investisseurs',
'Servicios al inversor',
],
[
'Acompanhamento do projecto',
'Project follow-up',
'Suivi du projet',
'Seguimiento del proyecto',
],
[
'Apoio ao investidor',
'Investor support',
'Assistance aux investisseurs',
'Apoyo al inversor',
],
[
'Dossiers e percursos',
'Investment files and journeys',
'Dossiers et parcours',
'Expedientes y recorridos',
],
[
'Perfil e empresas',
'Profile and companies',
'Profil et entreprises',
'Perfil y empresas',
],
[
'Pedidos e tarefas de acompanhamento',
'Requests and follow-up tasks',
'Demandes et tâches de suivi',
'Solicitudes y tareas de seguimiento',
],
[
'Manifestações e favoritos',
'Expressions of interest and favourites',
'Manifestations d’intérêt et favoris',
'Manifestaciones de interés y favoritos',
],
['Notificações', 'Notifications', 'Notifications', 'Notificaciones'],
['A minha atividade', 'My activity', 'Mes activités', 'Mi actividad'],
[
'As minhas parcerias',
'My partnerships',
'Mes partenariats',
'Mis alianzas',
],
['Os meus negócios', 'My businesses', 'Mes entreprises', 'Mis negocios'],
];
const translatedMenu = (column: number) =>
Object.fromEntries(menuTranslations.map((row) => [row[0], row[column]]));
const translations: Record<string, Record<string, string>> = {
en: {
...translatedMenu(1),
Descoberta: 'Discover',
Realização: 'Invest',
Suporte: 'Support',
Entrar: 'Sign in',
'A minha área': 'My account',
'Terminar sessão': 'Sign out',
Contactos: 'Contacts',
Navegação: 'Navigation',
Início: 'Home',
Oportunidades: 'Opportunities',
Sectores: 'Sectors',
Legislações: 'Legislation',
'Guia do investidor': 'Investor guide',
Contacto: 'Contact',
'Política de Privacidade': 'Privacy Policy',
'Termos de Uso': 'Terms of Use',
'Todos os direitos reservados.': 'All rights reserved.',
'Siga a Revista Tempo para actualizações exclusivas.':
'Follow Revista Tempo for exclusive updates.',
'Número de registo': 'Registration number',
'A minha conta e atividade': 'My account and activity',
'Área de trabalho': 'Workspace',
},
fr: {
...translatedMenu(2),
Descoberta: 'Découvrir',
Realização: 'Investir',
Suporte: 'Assistance',
Entrar: 'Connexion',
'A minha área': 'Mon compte',
'Terminar sessão': 'Déconnexion',
Contactos: 'Contacts',
Navegação: 'Navigation',
Início: 'Accueil',
Oportunidades: 'Opportunités',
Sectores: 'Secteurs',
Legislações: 'Législation',
'Guia do investidor': 'Guide de l’investisseur',
Contacto: 'Contact',
'Política de Privacidade': 'Politique de confidentialité',
'Termos de Uso': 'Conditions d’utilisation',
'Todos os direitos reservados.': 'Tous droits réservés.',
'Siga a Revista Tempo para actualizações exclusivas.':
'Suivez Revista Tempo pour des actualités exclusives.',
'Número de registo': 'Numéro d’enregistrement',
'A minha conta e atividade': 'Mon compte et mes activités',
'Área de trabalho': 'Espace de travail',
},
es: {
...translatedMenu(3),
Descoberta: 'Descubrir',
Realização: 'Invertir',
Suporte: 'Soporte',
Entrar: 'Entrar',
'A minha área': 'Mi cuenta',
'Terminar sessão': 'Cerrar sesión',
Contactos: 'Contactos',
Navegação: 'Navegación',
Início: 'Inicio',
Oportunidades: 'Oportunidades',
Sectores: 'Sectores',
Legislações: 'Legislación',
'Guia do investidor': 'Guía del inversor',
Contacto: 'Contacto',
'Política de Privacidade': 'Política de privacidad',
'Termos de Uso': 'Términos de uso',
'Todos os direitos reservados.': 'Todos los derechos reservados.',
'Siga a Revista Tempo para actualizações exclusivas.':
'Siga Revista Tempo para recibir novedades exclusivas.',
'Número de registo': 'Número de registro',
'A minha conta e atividade': 'Mi cuenta y actividad',
'Área de trabalho': 'Área de trabajo',
},
};
const TempoUi = createContext({
language: 'pt',
setLanguage: (_language: string) => {},
t: (text: string) => text,
requestCount: 0,
registerSubmittedInterest: () => {},
});
export function TempoUiProvider({ children }: { children: ReactNode }) {
const [language, setCurrentLanguage] = useState('pt');
const [requestCount, setRequestCount] = useState(0);
useEffect(() => {
try {
const saved = localStorage.getItem('tempo-navigation-language');
if (saved && ['pt', 'en', 'fr', 'es'].includes(saved))
setCurrentLanguage(saved);
} catch {
/* Navigation remains usable without browser storage. */
}
}, []);
const setLanguage = (next: string) => {
setCurrentLanguage(next);
try {
localStorage.setItem('tempo-navigation-language', next);
} catch {
/* Optional persistence. */
}
};
return (
<TempoUi.Provider
value={{
language,
setLanguage,
t: (text) => translations[language]?.[text] ?? text,
requestCount,
registerSubmittedInterest: () =>
setRequestCount((count) => count + 1),
}}
>
{children}
</TempoUi.Provider>
);
}
export const useTempoUi = () => useContext(TempoUi);
export function TempoSocialLinks({ footer = false }: { footer?: boolean }) {
return (
<div
className="flex items-center gap-1"
aria-label="Redes sociais da Revista Tempo"
>
{tempoSocialLinks.map(({ name, href, icon: Icon, color }) => (
<a
key={name}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={`Revista Tempo — ${name}`}
title={name}
className={
footer ? 'tempo-footer-social' : 'tempo-header-social'
}
style={{ '--social-color': color } as CSSProperties}
>
<Icon size={footer ? 19 : 16} aria-hidden="true" />
</a>
))}
</div>
);
}
+136
View File
@@ -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>
);
}
+71 -64
View File
@@ -1,6 +1,8 @@
import { Link, usePage } from '@inertiajs/react';
import { createContext, useContext } from 'react';
import { PortalGroupedMenu } from '@/components/portal-grouped-menu';
import { TempoUiProvider } from '@/components/tempo-brand';
import { TempoFooter } from '@/components/tempo-footer';
import type { ReactNode } from 'react';
import {
NavigationContextProvider,
@@ -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>
);
}
+1 -1
View File
@@ -440,11 +440,11 @@ export function currentNavigationItem(
export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
const previous = legacyPortalNavigation(access);
return uniqueNavigationDestinations([
link('home', 'Início', '/'),
{
id: 'discovery',
label: 'Descoberta',
children: [
link('home', 'Início', '/'),
link(
'why',
'Porquê investir em Moçambique',
+3 -63
View File
@@ -1,6 +1,7 @@
import { createContext, useContext, useRef, useState } from 'react';
import { router } from '@inertiajs/react';
import { PortalMenu } from '@/layouts/portal-navigation';
import { useTempoUi } from '@/components/tempo-brand';
import { ProvinceSelect } from '@/components/investment-territory';
import { MATURITY_LEVELS } from '@/lib/investment-maturity';
import CountrySelect, { type Country } from '@/components/country-select';
@@ -94,48 +95,6 @@ function QueryStatus({
/* Componentes */
/* ------------------------------------------------------------------ */
function TopBar({
onNavigate,
requestCount,
}: {
onNavigate: (view: View) => void;
requestCount: number;
}) {
return (
<nav
aria-label="Navegação das manifestações"
className="sticky top-0 z-30 border-b border-slate-200 bg-white/95 backdrop-blur"
>
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-5 py-4">
<button
onClick={() => onNavigate('home')}
className="flex items-baseline gap-1 font-black tracking-tight text-slate-900"
>
<span className="text-lg">INVESTE</span>
<span className="text-lg text-emerald-700">Moçambique</span>
</button>
<div className="flex items-center gap-3 text-xs text-slate-500">
<a href="/investidor/manifestacoes" className="underline">
O meu painel
</a>
<span className="hidden sm:inline">
{requestCount} pedido{requestCount === 1 ? '' : 's'}{' '}
recebido
{requestCount === 1 ? '' : 's'}
</span>
<button
onClick={() => onNavigate('interest')}
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-bold text-white hover:bg-slate-700"
>
Manifestar interesse
</button>
</div>
</div>
</nav>
);
}
function Hero({
query,
setQuery,
@@ -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 {
-10
View File
@@ -1307,16 +1307,6 @@ export default function Services(p: Props) {
</>
)}
</main>
<footer className="border-t px-5 py-8 text-center text-sm">
Investe Moçambique ·{' '}
<Link href="/apoio" className="underline">
Apoio ao Investidor
</Link>{' '}
·{' '}
<Link href="/documentos" className="underline">
Documentos e Formulários
</Link>
</footer>
</div>
);
}
+1 -1
View File
@@ -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']);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Tests\Unit;
use Illuminate\Filesystem\Filesystem;
use PHPUnit\Framework\TestCase;
class RuntimeStorageTest extends TestCase
{
public function test_missing_runtime_directories_are_created_without_changing_existing_files(): void
{
$root = sys_get_temp_dir().'/investmoz-runtime-'.bin2hex(random_bytes(8));
$prepare = require dirname(__DIR__, 2).'/bootstrap/storage.php';
try {
$prepare($root, $root.'/custom-storage');
foreach (['app/private', 'app/public', 'framework/cache/data', 'framework/sessions', 'framework/views', 'logs'] as $path) {
$this->assertDirectoryExists($root.'/custom-storage/'.$path);
}
$this->assertDirectoryExists($root.'/bootstrap/cache');
$file = $root.'/custom-storage/framework/views/existing.php';
file_put_contents($file, 'preserved');
$prepare($root, $root.'/custom-storage');
$this->assertSame('preserved', file_get_contents($file));
$this->assertNotFalse(realpath($root.'/custom-storage/framework/views'));
} finally {
(new Filesystem)->deleteDirectory($root);
}
}
public function test_a_file_blocking_a_runtime_directory_produces_an_actionable_error(): void
{
$root = sys_get_temp_dir().'/investmoz-runtime-'.bin2hex(random_bytes(8));
mkdir($root, 0775, true);
file_put_contents($root.'/storage', 'preserved');
$prepare = require dirname(__DIR__, 2).'/bootstrap/storage.php';
try {
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Cannot create Laravel runtime directory:');
$prepare($root, $root.'/storage');
} finally {
$this->assertSame('preserved', file_get_contents($root.'/storage'));
(new Filesystem)->deleteDirectory($root);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
// Read-only public navigation and login of the documented local demo account.
// Receipts never include cookies, request bodies or credentials.
(async () => {
const base = 'http://127.0.0.1:8000';
const directory = path.resolve(__dirname, '../outputs/local-startup-20261019');
fs.mkdirSync(directory, { recursive: true });
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const errors = [];
const failures = [];
const checks = [];
try {
const page = await browser.newPage({ viewport: { width: 1366, height: 900 } });
page.on('pageerror', error => errors.push(error.message));
page.on('response', response => {
if (response.url().startsWith(base) && response.status() >= 500) {
failures.push({ path: new URL(response.url()).pathname, status: response.status() });
}
});
for (const route of ['/', '/oportunidades', '/sectores/agronegocio', '/login', '/register']) {
const response = await page.goto(base + route, { waitUntil: 'networkidle', timeout: 90000 });
assert.equal(response.status(), 200, route);
await page.locator('#app').waitFor();
assert.ok((await page.locator('body').innerText()).trim().length > 100, route);
checks.push({ path: route, status: response.status(), rendered: true });
if (route === '/') await page.screenshot({ path: path.join(directory, 'home.png'), fullPage: true });
}
await page.goto(base + '/login', { waitUntil: 'networkidle' });
await page.locator('input[name=email]').fill('test@example.com');
await page.locator('input[name=password]').fill('password');
await Promise.all([
page.waitForURL('**/dashboard', { timeout: 60000 }),
page.locator('button[type=submit]').click(),
]);
await page.waitForLoadState('networkidle');
assert.ok((await page.locator('body').innerText()).trim().length > 100);
checks.push({ path: '/dashboard', authenticated: true, rendered: true });
await page.screenshot({ path: path.join(directory, 'dashboard.png'), fullPage: true });
assert.deepEqual(errors, []);
assert.deepEqual(failures, []);
const receipt = { at: new Date().toISOString(), checks, javascriptErrors: errors, serverErrors: failures };
fs.writeFileSync(path.join(directory, 'browser.json'), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+48
View File
@@ -0,0 +1,48 @@
<?php
// Technical setup for the explicitly authorised, disposable local demo only.
// The production database and authenticated installation workflow remain separate.
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
require dirname(__DIR__).'/vendor/autoload.php';
foreach (['APP_ENV' => 'testing', 'APP_CONFIG_CACHE' => dirname(__DIR__).'/storage/framework/cache/local-qa-config.php'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
$app = require dirname(__DIR__).'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$connection = config('database.connections.pgsql');
if (app()->configurationIsCached() || config('database.default') !== 'pgsql'
|| $connection['host'] !== '127.0.0.1' || (string) $connection['port'] !== '55441'
|| $connection['database'] !== 'investmoz_local_test' || ! empty($connection['url'])) {
fwrite(STDERR, 'Refused: explicit isolated local QA database required.'.PHP_EOL);
exit(1);
}
$identity = DB::selectOne('SELECT current_database() AS database, inet_server_port() AS port');
if ($identity->database !== 'investmoz_local_test' || (int) $identity->port !== 55441) {
fwrite(STDERR, 'Refused: actual QA server identity differs.'.PHP_EOL);
exit(1);
}
try {
$status = Artisan::call('migrate', ['--force' => true]);
} catch (Throwable $error) {
fwrite(STDERR, 'Local QA migration failed: '.$error->getMessage().PHP_EOL);
exit(1);
}
echo Artisan::output();
if ($status !== 0) {
exit($status);
}
// Never reset an account or overwrite an existing demo on a repeated setup.
if (DB::table('sectors')->count() === 0 && DB::table('users')->count() === 0) {
$status = Artisan::call('db:seed', ['--force' => true]);
echo Artisan::output();
exit($status);
}
echo 'Existing local demo preserved; seed skipped.'.PHP_EOL;