ajuste no header e organizaco de elementos, e ajuste no footer
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-20 11:39:25 +02:00
parent a128c5a1b8
commit 97a08df2a0
33 changed files with 2272 additions and 166 deletions
@@ -5,15 +5,24 @@ namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Sleep;
use Throwable;
class CheckDevelopmentEnvironment extends Command class CheckDevelopmentEnvironment extends Command
{ {
protected $signature = 'portal:dev-check'; protected $signature = 'portal:dev-check {--wait=0 : Segundos para aguardar uma falha transitória do PostgreSQL}';
protected $description = 'Verifica o arranque local sem alterar dados ou mostrar credenciais'; protected $description = 'Verifica o arranque local sem alterar dados ou mostrar credenciais';
public function handle(): int public function handle(): int
{ {
$wait = filter_var($this->option('wait'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 3600]]);
if ($wait === false) {
$this->error('--wait deve ser um inteiro entre 0 e 3600 segundos.');
return self::INVALID;
}
$checks = [ $checks = [
'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='), 'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='),
'Driver PostgreSQL activo' => extension_loaded('pdo_pgsql'), 'Driver PostgreSQL activo' => extension_loaded('pdo_pgsql'),
@@ -25,14 +34,17 @@ class CheckDevelopmentEnvironment extends Command
]; ];
if ($checks['Driver PostgreSQL activo'] && $checks['Base PostgreSQL configurada']) { if ($checks['Driver PostgreSQL activo'] && $checks['Base PostgreSQL configurada']) {
$checks['Ligação à base de dados'] = $this->checkConnection($wait);
}
if ($checks['Ligação à base de dados'] ?? false) {
try { 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['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']) $checks['Tabelas principais instaladas'] = collect(['migrations', 'users', 'sectors', 'opportunities', 'sessions', 'cache', 'jobs'])
->every(fn (string $table): bool => Schema::hasTable($table)); ->every(fn (string $table): bool => Schema::hasTable($table));
} catch (\Throwable) { } catch (Throwable) {
$checks['Ligação à base de dados'] = false; $checks['Esquema da base de dados verificável'] = false;
$this->warn('A ligação foi estabelecida, mas a verificação do esquema falhou. Verifique as permissões e a preparação QA.');
} }
} }
@@ -48,4 +60,61 @@ class CheckDevelopmentEnvironment extends Command
return self::SUCCESS; return self::SUCCESS;
} }
private function checkConnection(int $wait): bool
{
$deadline = microtime(true) + $wait;
while (true) {
try {
DB::selectOne('SELECT 1');
return true;
} catch (Throwable $error) {
[$reason, $retryable] = $this->connectionFailure($error);
$remaining = $deadline - microtime(true);
if (! $retryable || $remaining <= 0) {
$this->warn($reason);
if ($retryable) {
$this->warn('Inicie Docker Desktop e execute ./deploy/local/start.ps1. Consulte os registos com docker compose --env-file .env -f deploy/local/compose.yaml logs --tail 40 postgres.');
}
return false;
}
$this->line('[AGUARDA] '.$reason.' Nova tentativa; prazo restante: '.(int) ceil($remaining).' s.');
DB::purge();
Sleep::for(min(5, $remaining))->seconds();
}
}
}
/** @return array{string, bool} */
private function connectionFailure(Throwable $error): array
{
// Inspect the chain internally; never print exception messages, DSNs or credentials.
$messages = '';
do {
$messages .= ' '.strtolower($error->getMessage()).' '.strtolower((string) $error->getCode());
$error = $error->getPrevious();
} while ($error !== null);
if (str_contains($messages, '28p01') || str_contains($messages, '28000')
|| str_contains($messages, 'password authentication failed') || str_contains($messages, 'no pg_hba.conf entry')) {
return ['Autenticação PostgreSQL recusada. Verifique as credenciais e as regras de acesso; aguardar não corrige este erro.', false];
}
if (str_contains($messages, '3d000') || preg_match('/database .* does not exist/', $messages)) {
return ['A base PostgreSQL configurada não existe. Verifique a configuração e a preparação QA.', false];
}
if (str_contains($messages, '57p03') || str_contains($messages, 'recovery mode')
|| str_contains($messages, 'not yet accepting connections') || str_contains($messages, 'database system is starting up')) {
return ['PostgreSQL em arranque ou recuperação; aguarde a conclusão sem remover o volume.', true];
}
if (preg_match('/\b08[0-9a-z]{3}\b/', $messages) || str_contains($messages, 'connection refused')
|| str_contains($messages, '57p01') || str_contains($messages, '57p02')) {
return ['PostgreSQL indisponível. Verifique se Docker Desktop e o contentor local estão iniciados.', true];
}
return ['Falha PostgreSQL não transitória. Verifique a configuração e os registos do servidor.', false];
}
} }
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Mail\ContactMessageReceivedMail;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
class PortalContactController extends Controller
{
// Adapter for investidor's contact form: preserve its fields and validation,
// using the configured mail transport without adding a database dependency.
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'min:2', 'max:150'],
'email' => ['required', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:30'],
'subject' => ['required', 'string', 'min:3', 'max:100'],
'message' => ['required', 'string', 'min:10', 'max:2000'],
]);
try {
Mail::to(config('contact_messages.recipient'))
->send(new ContactMessageReceivedMail([
'name' => $data['name'], 'email' => $data['email'], 'phone' => $data['phone'] ?? null,
'subject' => $data['subject'], 'message' => $data['message'], 'platform' => 'InvesteMoz',
]));
} catch (Throwable $exception) {
Log::warning('Falha no envio de contacto do portal.', ['exception' => $exception::class]);
return response()->json(['success' => false, 'message' => 'Ocorreu um erro ao enviar a mensagem.'], 500);
}
return response()->json(['success' => true, 'message' => 'Mensagem enviada com sucesso.'], 201);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
// Adapted from investidor/app/Mail/ContactMessageReceivedMail.php.
class ContactMessageReceivedMail extends Mailable
{
use Queueable, SerializesModels;
/** @param array{name: string, email: string, phone?: string|null, subject: string, message: string, platform: string} $contactMessage */
public function __construct(public readonly array $contactMessage) {}
public function envelope(): Envelope
{
return new Envelope(
replyTo: [new Address($this->contactMessage['email'], $this->contactMessage['name'])],
subject: $this->contactMessage['subject'],
);
}
public function content(): Content
{
return new Content(
view: 'emails.contact-message-received',
text: 'emails.contact-message-received-text',
);
}
}
+1 -1
View File
@@ -52,7 +52,7 @@
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"@php artisan portal:dev-check", "@php artisan portal:dev-check --wait=900",
"@php artisan dev" "@php artisan dev"
], ],
"lint": [ "lint": [
+6
View File
@@ -0,0 +1,6 @@
<?php
// Same recipient configuration as investidor/config/contact_messages.php.
return [
'recipient' => env('CONTACT_MESSAGES_TO', env('MAIL_FROM_ADDRESS')),
];
+36 -1
View File
@@ -22,14 +22,49 @@ composer run dev
Abra http://127.0.0.1:8000. O comando inicia Laravel, o worker da fila e Vite. 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. Use Ctrl+C para terminar. A base conserva os dados entre arranques.
`composer run dev` aguarda até 900 segundos se o PostgreSQL estiver indisponível
ou em recuperação. Mostra o motivo e o progresso, sem revelar credenciais.
Erros de autenticação ou de base inexistente interrompem o arranque imediatamente.
O diagnóstico directo `php artisan portal:dev-check` continua sem espera;
use `--wait=900` para aguardar. O comando não inicia Docker nem executa migrations.
O script de arranque executa estes passos, parando no primeiro erro: O script de arranque executa estes passos, parando no primeiro erro:
```powershell ```powershell
docker compose --env-file .env -f deploy/local/compose.yaml up -d --wait docker compose --env-file .env -f deploy/local/compose.yaml up -d --wait --wait-timeout 900
php tools/local-qa-bootstrap.php php tools/local-qa-bootstrap.php
composer run dev composer run dev
``` ```
O `start.ps1` usa `--wait-timeout 900`. O healthcheck admite 600 segundos de
arranque antes de contar falhas e reconhece imediatamente um servidor pronto.
O Compose concede 60 segundos para um encerramento normal do PostgreSQL.
### PostgreSQL em recuperação
Em 20/10/2026, os registos mostraram um encerramento incorrecto seguido de
recuperação entre 09:59 e 10:07 (Africa/Johannesburg). Durante esse período,
o servidor recusava ligações com `the database system is in recovery mode`.
O teste anterior ocultava a causa e terminava na primeira falha.
Se voltar a acontecer, deixe a recuperação concluir e consulte:
```powershell
docker compose --env-file .env -f deploy/local/compose.yaml ps -a
docker compose --env-file .env -f deploy/local/compose.yaml logs --tail 40 postgres
php artisan portal:dev-check --wait=900
```
Se o contentor estiver parado, execute `./deploy/local/start.ps1` com Docker
Desktop iniciado. Não remova o volume nem reinicie repetidamente durante a
recuperação. Para parar a base normalmente, use
`docker compose --env-file .env -f deploy/local/compose.yaml stop postgres`
antes de encerrar Docker Desktop ou o computador.
A espera, os diagnósticos e estes prazos são escolhas técnicas para a correcção
solicitada em 20/10/2026, sob as decisões Tempo de AGENTS.md; não criam regras
de negócio nem alteram dados activos.
O preparador verifica configuração e identidade real da base antes de migrar. 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 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 configuração em cache. Executa migrations em `testing`, como exigem as guardas
+2
View File
@@ -4,6 +4,7 @@ services:
image: postgis/postgis:17-3.5 image: postgis/postgis:17-3.5
command: ['postgres', '-p', '55441'] command: ['postgres', '-p', '55441']
restart: unless-stopped restart: unless-stopped
stop_grace_period: 60s
environment: environment:
POSTGRES_DB: investmoz_local_test POSTGRES_DB: investmoz_local_test
POSTGRES_USER: investmoz_test POSTGRES_USER: investmoz_test
@@ -16,6 +17,7 @@ services:
test: ['CMD-SHELL', 'pg_isready -p 55441 -U investmoz_test -d investmoz_local_test'] test: ['CMD-SHELL', 'pg_isready -p 55441 -U investmoz_test -d investmoz_local_test']
interval: 5s interval: 5s
timeout: 5s timeout: 5s
start_period: 600s
retries: 20 retries: 20
volumes: volumes:
postgres-qa: postgres-qa:
+1 -1
View File
@@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
Push-Location $projectRoot Push-Location $projectRoot
try { try {
docker compose --progress plain --env-file .env -f deploy/local/compose.yaml up -d --wait --wait-timeout 120 docker compose --progress plain --env-file .env -f deploy/local/compose.yaml up -d --wait --wait-timeout 900
if ($LASTEXITCODE -ne 0) { throw 'Inicie Docker Desktop e verifique o serviço PostgreSQL QA.' } if ($LASTEXITCODE -ne 0) { throw 'Inicie Docker Desktop e verifique o serviço PostgreSQL QA.' }
php tools/local-qa-bootstrap.php php tools/local-qa-bootstrap.php
if ($LASTEXITCODE -ne 0) { throw 'Preparação QA interrompida; veja a mensagem acima.' } if ($LASTEXITCODE -ne 0) { throw 'Preparação QA interrompida; veja a mensagem acima.' }
Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+254 -68
View File
@@ -14,20 +14,59 @@
background: #090909; background: #090909;
color: #fff; color: #fff;
} }
.tempo-logo-link { .tempo-header-inner {
display: inline-flex; position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-rows: 72px;
column-gap: 0.5rem;
align-items: center; align-items: center;
max-width: 1440px;
margin-inline: auto;
padding-inline: 0.75rem;
}
.tempo-logo-link {
grid-area: 1 / 1;
display: inline-flex;
width: fit-content;
align-items: center;
flex-shrink: 0;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.tempo-header-logo {
width: auto;
height: 36px;
object-fit: contain;
}
.tempo-primary-navigation {
display: none;
grid-area: 2 / 1 / auto / -1;
min-width: 0;
border-top: 1px solid #ffffff1a;
}
.tempo-primary-navigation > ul {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.25rem 0.5rem;
padding-block: 0.75rem;
}
.tempo-primary-navigation .tempo-nav-trigger {
font-size: 0.9375rem;
padding-inline: 0.5rem;
}
.tempo-nav-trigger { .tempo-nav-trigger {
display: flex; display: flex;
width: 100%;
min-height: 44px; min-height: 44px;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: space-between;
padding: 0.6rem 0.75rem; gap: 0.4rem;
border-radius: 0.5rem; padding: 0.5rem 0.75rem;
font-size: 0.875rem; border-radius: 0.4rem;
font-size: 1rem;
line-height: 1.5;
font-weight: 600; font-weight: 600;
white-space: nowrap;
color: #eee; color: #eee;
transition: transition:
color 0.2s, color 0.2s,
@@ -45,16 +84,85 @@
.tempo-nav-trigger[data-state='open'] > svg { .tempo-nav-trigger[data-state='open'] > svg {
transform: rotate(180deg); transform: rotate(180deg);
} }
@media (max-width: 640px) { .tempo-primary-navigation .tempo-nav-trigger > svg {
.tempo-nav-trigger { transform: none;
gap: 0.25rem; flex-shrink: 0;
padding-inline: 0.25rem; }
font-size: 0.75rem; .tempo-section-bar {
border-top: 1px solid #ffffff14;
background: inherit;
}
.tempo-section-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.25rem 0.75rem;
max-width: 1440px;
margin-inline: auto;
padding: 0 0.75rem 0.5rem;
}
.tempo-section-navigation {
grid-area: 1 / 1 / auto / -1;
min-width: 0;
}
.tempo-section-navigation > ul {
display: flex;
justify-content: center;
gap: 0.25rem;
}
.tempo-section-navigation .tempo-nav-trigger {
justify-content: center;
min-height: 44px;
gap: 0.25rem;
padding: 0.5rem 0.375rem;
border-radius: 0;
font-size: 0.8125rem;
letter-spacing: normal;
}
@media (min-width: 640px) {
.tempo-section-navigation .tempo-nav-trigger {
min-height: 48px;
gap: 0.5rem;
padding-inline: 0.75rem;
font-size: 1rem;
} }
.tempo-nav-trigger > svg { }
width: 12px; @media (min-width: 768px) {
height: 12px; .tempo-section-inner {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
padding: 0.25rem 1.25rem;
} }
.tempo-section-navigation {
grid-area: 1 / 2;
}
.tempo-section-inner .tempo-panel-summary {
grid-area: 1 / 1;
}
.tempo-section-inner .tempo-interest-button {
grid-area: 1 / 3;
}
}
.tempo-header-extras {
display: none;
grid-area: 3 / 1 / auto / -1;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
padding-block: 0.75rem;
border-top: 1px solid #ffffff1a;
}
.tempo-header[data-expanded] .tempo-primary-navigation {
display: block;
}
.tempo-header[data-expanded] .tempo-header-extras {
display: flex;
}
.tempo-header-utilities {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
} }
.tempo-utility-button { .tempo-utility-button {
display: flex; display: flex;
@@ -63,7 +171,7 @@
gap: 0.4rem; gap: 0.4rem;
padding: 0.4rem; padding: 0.4rem;
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.75rem; font-size: 0.8125rem;
color: #e2e8f0; color: #e2e8f0;
} }
.tempo-utility-button:hover { .tempo-utility-button:hover {
@@ -85,11 +193,6 @@
transform: translateY(-2px); transform: translateY(-2px);
opacity: 0.8; opacity: 0.8;
} }
@media (max-width: 360px) {
.tempo-header-social {
width: 26px;
}
}
.tempo-login-button { .tempo-login-button {
display: inline-flex; display: inline-flex;
min-height: 40px; min-height: 40px;
@@ -97,9 +200,10 @@
gap: 0.5rem; gap: 0.5rem;
border: 1px solid #ffffff50; border: 1px solid #ffffff50;
border-radius: 0.4rem; border-radius: 0.4rem;
padding: 0.5rem 0.85rem; padding: 0.5rem 0.75rem;
font-size: 0.8rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
white-space: nowrap;
color: #fff; color: #fff;
transition: transition:
background 0.2s, background 0.2s,
@@ -109,19 +213,23 @@
background: #ed1c24; background: #ed1c24;
border-color: #ed1c24; border-color: #ed1c24;
} }
.tempo-login-button > span {
max-width: clamp(2rem, calc(100vw - 18rem), 6rem);
}
.tempo-header-actions { .tempo-header-actions {
order: 2; grid-area: 1 / 2;
display: flex; display: flex;
min-width: 0;
align-items: center; align-items: center;
gap: 0.5rem;
margin-left: auto;
} }
.tempo-interest-button { .tempo-interest-button {
display: inline-flex; display: inline-flex;
grid-area: 2 / 2;
justify-self: end;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 40px; min-height: 40px;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.65rem;
border: 1px solid #ed1c24; border: 1px solid #ed1c24;
border-radius: 0.4rem; border-radius: 0.4rem;
background: #ed1c24; background: #ed1c24;
@@ -135,56 +243,119 @@
background: #c9151c; background: #c9151c;
} }
.tempo-panel-summary { .tempo-panel-summary {
order: 4; grid-area: 2 / 1;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; align-items: flex-start;
gap: 0.5rem; justify-content: center;
width: 100%; gap: 0.1rem;
min-height: 44px; min-height: 44px;
padding-block: 0.5rem; font-size: 0.8125rem;
border-top: 1px solid #ffffff1a; line-height: 1.4;
font-size: 0.75rem;
}
.tempo-panel-summary a {
color: #fff; color: #fff;
text-underline-offset: 3px; text-underline-offset: 3px;
} }
.tempo-panel-summary a:hover { .tempo-panel-summary:hover {
text-decoration: underline; text-decoration: underline;
} }
.tempo-panel-summary span { .tempo-panel-count {
color: #cbd5e1; color: #cbd5e1;
font-size: 0.6875rem; font-size: 0.6875rem;
white-space: nowrap;
} }
@media (max-width: 480px) { .tempo-header-toggle {
.tempo-header-actions { grid-area: 1 / 3;
flex-direction: column; display: inline-flex;
align-items: flex-end; width: 44px;
padding-block: 0.65rem; height: 44px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid #ffffff33;
border-radius: 0.4rem;
color: #fff;
cursor: pointer;
}
.tempo-header-toggle:hover,
.tempo-header-toggle[aria-expanded='true'] {
background: #ffffff10;
}
@media (min-width: 640px) {
.tempo-header-inner {
padding-inline: 1.25rem;
}
.tempo-header-logo {
height: 44px;
}
.tempo-primary-navigation > ul {
display: flex;
justify-content: space-between;
}
}
@media (min-width: 1024px) {
.tempo-header-inner {
grid-template-rows: 88px;
padding-inline: 1.5rem;
}
.tempo-header-logo {
height: 56px;
} }
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
.tempo-header-actions { .tempo-header-inner {
order: 6; display: flex;
margin-left: 0; min-height: 88px;
gap: 0.75rem;
padding-inline: 2rem;
} }
.tempo-header-inner .tempo-nav-trigger { .tempo-header-logo {
padding-inline: 0.5rem; height: 64px;
font-size: 0.8125rem;
} }
.tempo-panel-summary { .tempo-primary-navigation {
order: 0; display: block;
flex-direction: column; flex: 1;
align-items: flex-start; border: 0;
}
.tempo-primary-navigation > ul {
justify-content: center; justify-content: center;
gap: 0.1rem; gap: 0.25rem;
width: auto; padding: 0;
min-width: 102px; }
.tempo-nav-trigger {
justify-content: center;
padding-inline: 0.875rem;
font-size: 1.125rem;
letter-spacing: 0.025em;
}
.tempo-primary-navigation .tempo-nav-trigger {
padding-inline: 0.625rem;
font-size: 1.125rem;
}
.tempo-header-extras {
display: flex;
flex-shrink: 0;
padding: 0 0.75rem;
border-top: 0; border-top: 0;
border-left: 1px solid #ffffff1a; border-inline: 1px solid #ffffff1a;
padding-left: 0.75rem; }
.tempo-header-utilities {
flex-wrap: nowrap;
gap: 0.5rem;
}
.tempo-header-social {
width: 26px;
}
.tempo-header-toggle {
display: none;
}
.tempo-section-inner {
padding-inline: 2rem;
}
.tempo-section-navigation .tempo-nav-trigger {
padding-inline: 1.25rem;
}
.tempo-interest-button {
padding-inline: 0.85rem;
font-size: 0.8125rem;
} }
} }
.tempo-header :is(a, button):focus-visible { .tempo-header :is(a, button):focus-visible {
@@ -223,18 +394,18 @@
color: #fff; color: #fff;
} }
.tempo-footer-heading { .tempo-footer-heading {
margin-bottom: 1.4rem; font-size: 0.875rem;
font-size: 0.75rem; line-height: 1.25rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.15em; letter-spacing: 0.2em;
text-transform: uppercase; text-transform: uppercase;
} }
.tempo-footer-link { .tempo-footer-link {
display: inline-flex; display: inline-flex;
align-items: baseline; align-items: baseline;
gap: 0.35rem; gap: 0.35rem;
font-size: 0.8125rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.25rem;
color: #e2e8f0; color: #e2e8f0;
transition: transition:
color 0.2s, color 0.2s,
@@ -252,7 +423,7 @@
.tempo-contact-link { .tempo-contact-link {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.7rem; gap: 1rem;
transition: color 0.2s; transition: color 0.2s;
} }
.tempo-contact-link:hover { .tempo-contact-link:hover {
@@ -260,8 +431,8 @@
} }
.tempo-contact-icon { .tempo-contact-icon {
display: flex; display: flex;
width: 36px; width: 40px;
height: 36px; height: 40px;
flex-shrink: 0; flex-shrink: 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -271,7 +442,7 @@
} }
.tempo-footer-social { .tempo-footer-social {
display: flex; display: flex;
width: 40px; width: 44px;
height: 44px; height: 44px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -283,6 +454,14 @@
border-color 0.2s, border-color 0.2s,
transform 0.2s; transform 0.2s;
} }
.tempo-footer [aria-label='Redes sociais da Revista Tempo'] {
gap: 0.75rem;
}
.tempo-footer-social > svg {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.tempo-footer-social:hover { .tempo-footer-social:hover {
color: var(--social-color); color: var(--social-color);
border-color: var(--social-color); border-color: var(--social-color);
@@ -292,6 +471,13 @@
outline: 2px solid #fb7185; outline: 2px solid #fb7185;
outline-offset: 4px; outline-offset: 4px;
} }
.tempo-source-page {
overflow-x: hidden;
}
.tempo-about-page {
--primary: #1f1f1f;
--color-primary: #1f1f1f;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.tempo-header *, .tempo-header *,
.tempo-footer *, .tempo-footer *,
@@ -0,0 +1,197 @@
// Imported from investidor/resources/js/modules/news/sections/about/AboutStory.tsx; original content preserved.
import { useState, useEffect, useRef } from 'react';
import { Trans } from './source-text';
const aboutimgstory = '/assets/imgs/aboutimgstory.png';
interface CounterProps {
end: number;
suffix?: string;
}
const Counter = ({ end, suffix = '' }: CounterProps) => {
const [count, setCount] = useState<number>(0);
const countRef = useRef<number>(0);
useEffect(() => {
const duration = 2000;
const increment = end / (duration / 16);
const timer = setInterval(() => {
countRef.current += increment;
if (countRef.current >= end) {
clearInterval(timer);
countRef.current = end;
}
setCount(Math.floor(countRef.current));
}, 16);
return () => clearInterval(timer);
}, [end]);
return (
<span>
{count}
{suffix}
</span>
);
};
export const AboutStory = () => {
const [isVisible, setIsVisible] = useState<boolean>(false);
const [counted, setCounted] = useState<boolean>(false);
const sectionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
setCounted(true);
}
},
{ threshold: 0.2 },
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => {
if (sectionRef.current) {
observer.unobserve(sectionRef.current);
}
};
}, []);
return (
<section
ref={sectionRef}
className={`bg-primary px-6 transition-all duration-1000 md:px-12 lg:px-20 ${
isVisible
? 'translate-y-0 opacity-100'
: 'translate-y-10 opacity-0'
}`}
>
<div className="mx-auto max-w-6xl">
<div className="grid grid-cols-1 items-stretch gap-x-12 gap-y-2 lg:grid-cols-2">
{/* Imagem */}
<div
className={`relative transition-all delay-200 duration-1000 ${
isVisible
? 'translate-x-0 opacity-100'
: '-translate-x-12 opacity-0'
}`}
>
<div className="group relative h-64 overflow-hidden rounded-2xl md:h-72">
<img
src={aboutimgstory}
alt="Equipe Tempo"
className="h-full w-full object-cover transition-transform duration-1000 group-hover:scale-110"
onError={(e) => {
e.currentTarget.src =
'/assets/images/info.png';
}}
/>
<div className="pointer-events-none absolute inset-0 bg-black/12" />
<div className="absolute right-4 bottom-4 left-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:gap-8">
<div className="rounded-xl bg-black/20 p-3 backdrop-blur-sm transition-all sm:bg-transparent sm:p-0 sm:backdrop-blur-none">
<div
className="text-4xl font-black tracking-tighter text-white md:text-6xl"
style={{
textShadow:
'0 4px 12px rgba(0,0,0,0.95), 0 2px 6px rgba(0,0,0,0.9)',
}}
>
{counted ? (
<Counter end={50} suffix="+" />
) : (
'50+'
)}
</div>
<div
className="mt-1 text-xs font-bold tracking-widest text-white uppercase opacity-90 md:text-lg"
style={{
textShadow:
'0 3px 10px rgba(0,0,0,0.9), 0 1px 4px black',
}}
>
<Trans>Anos de Experiência</Trans>
</div>
</div>
<div className="rounded-xl bg-black/20 p-3 backdrop-blur-sm transition-all sm:bg-transparent sm:p-0 sm:backdrop-blur-none">
<div
className="text-4xl font-black tracking-tighter text-[#ED1C24] md:text-6xl"
style={{
textShadow:
'0 4px 12px rgba(0,0,0,0.95), 0 2px 6px rgba(0,0,0,0.9)',
}}
>
{counted ? (
<Counter end={200} suffix="+" />
) : (
'200+'
)}
</div>
<div
className="mt-1 text-xs font-bold tracking-widest text-white uppercase opacity-90 md:text-lg"
style={{
textShadow:
'0 3px 10px rgba(0,0,0,0.9), 0 1px 4px black',
}}
>
<Trans>Projetos Realizados</Trans>
</div>
</div>
</div>
</div>
</div>
<div
className={`absolute -right-6 -bottom-6 -z-10 h-32 w-32 rounded-2xl border-2 border-red-600/30 transition-all delay-500 duration-1000 ${
isVisible
? 'scale-100 opacity-100'
: 'scale-0 opacity-0'
}`}
/>
</div>
{/* Texto */}
<div
className={`flex flex-col justify-center space-y-4 transition-all delay-500 duration-1000 ${
isVisible
? 'translate-x-0 opacity-100'
: '-translate-x-12 opacity-0'
}`}
>
<p className="text-base leading-relaxed font-light text-white md:text-lg">
<Trans>
Somos detentores de um vasto património cultural
e tecnológico, resultado da integração e da
cooperação entre diversas empresas com ampla
experiência nas áreas de Tecnologias de
Informação e Comunicação, produção e
disseminação de conteúdos informativos, educação
vocacional e gestão empresarial.
</Trans>
</p>
<p className="text-base leading-relaxed font-light text-white md:text-lg">
<Trans>
Dispomos de uma equipa qualificada, com sólida
experiência na concepção e implementação de
soluções de TI, de cibersegurança, de
desenvolvimento institucional, de reengenharia
de processos organizacionais e de formação
profissional.
</Trans>
</p>
</div>
</div>
</div>
</section>
);
};
export default AboutStory;
@@ -0,0 +1,156 @@
// Imported from investidor; return destinations are restricted to this platform.
import React, { useEffect, useState } from 'react';
import { router } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import { Trans } from './source-text'; // Import Trans component
interface BackButtonProps {
to?: string;
label?: React.ReactNode; // Change type to React.ReactNode to accept <Trans> component
className?: string;
theme?: 'light' | 'dark';
onClick?: () => void;
}
const RETURN_URL_KEY = 'news:return-url';
function normalizeInternalUrl(value: string | null): string | null {
if (!value || typeof window === 'undefined') {
return null;
}
try {
const url = value.startsWith('http')
? new URL(value)
: new URL(value, window.location.origin);
if (url.origin !== window.location.origin) {
return null;
}
const normalized = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
return normalized !== current ? normalized : null;
} catch {
return null;
}
}
function getStoredReturnUrl(): string | null {
try {
return normalizeInternalUrl(sessionStorage.getItem(RETURN_URL_KEY));
} catch {
return null;
}
}
function getReturnUrlFromPage(): string | null {
if (typeof window === 'undefined') {
return null;
}
const params = new URLSearchParams(window.location.search);
const fromParam = normalizeInternalUrl(params.get('from'));
if (fromParam) {
return fromParam;
}
return null;
}
function getInternalReferrerUrl(): string | null {
if (typeof document === 'undefined') {
return null;
}
return normalizeInternalUrl(document.referrer);
}
export const BackButton: React.FC<BackButtonProps> = ({
to,
label = <Trans>Voltar</Trans>, // Use Trans component for default label
className = '',
theme = 'light',
onClick,
}) => {
const [showFloatingButton, setShowFloatingButton] = useState(false);
useEffect(() => {
const handleScroll = () => {
setShowFloatingButton(window.scrollY > 220);
};
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const themeClasses =
theme === 'dark'
? 'border-white/20 bg-white/10 text-white shadow-lg backdrop-blur-md hover:bg-white/20'
: 'border-red-100 bg-white text-[#ED1C24] shadow-sm hover:border-red-200 hover:bg-red-50';
const baseClasses =
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#ED1C24]/25 active:scale-[0.98]';
const floatingClasses = [
'fixed bottom-5 left-4 z-50 max-w-[calc(100vw-2rem)] sm:bottom-6 sm:left-6',
'border-red-100 bg-white text-[#ED1C24] shadow-xl ring-1 ring-black/5 hover:border-red-200 hover:bg-red-50',
showFloatingButton
? 'pointer-events-auto translate-y-0 opacity-100'
: 'pointer-events-none translate-y-3 opacity-0',
].join(' ');
const renderContent = () => (
<>
<ArrowLeft size={16} aria-hidden="true" />
<span className="truncate">{label}</span>
</>
);
const handleClick = () => {
if (onClick) {
onClick();
return;
}
const explicitReturnUrl = getReturnUrlFromPage();
const storedReturnUrl = getStoredReturnUrl();
if (explicitReturnUrl) {
router.visit(explicitReturnUrl);
} else if (storedReturnUrl) {
router.visit(storedReturnUrl);
} else {
router.visit(getInternalReferrerUrl() || to || '/');
}
};
return (
<>
<button
type="button"
onClick={handleClick}
className={`${baseClasses} ${themeClasses} ${className}`}
aria-label={typeof label === 'string' ? label : undefined} // aria-label should be a string
>
{renderContent()}
</button>
<button
type="button"
onClick={handleClick}
className={`${baseClasses} ${floatingClasses}`}
aria-label={typeof label === 'string' ? label : undefined} // aria-label should be a string
aria-hidden={!showFloatingButton}
tabIndex={showFloatingButton ? undefined : -1}
>
{renderContent()}
</button>
</>
);
};
export default BackButton;
@@ -0,0 +1,218 @@
// Imported from investidor/resources/js/modules/news/sections/about/CompanyValues.tsx; original content preserved.
import { useState, useEffect, useRef } from 'react';
import { Users, Lightbulb, Globe, ChevronDown, ChevronUp } from 'lucide-react';
import { Trans } from './source-text';
const img01 = '/assets/imgs/img01.jpg';
const img02 = '/assets/imgs/img02.jpg';
const img03 = '/assets/imgs/img03.jpg';
const img04 = '/assets/imgs/img04.jpg';
interface CompanyValue {
id: number;
title: string;
description: string;
image?: string;
icon?: React.ReactNode;
color?: string;
label?: string;
}
export const CompanyValues = () => {
const [hoveredId, setHoveredId] = useState<number | null>(null);
const [isInView, setIsInView] = useState<boolean>(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
const handleScroll = () => {
if (sectionRef.current) {
const rect = sectionRef.current.getBoundingClientRect();
setIsInView(
rect.top < window.innerHeight * 0.8 && rect.bottom > 100,
);
}
};
window.addEventListener('scroll', handleScroll);
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const values: CompanyValue[] = [
{
id: 1,
title: 'Diversidade & Inclusão',
description:
'Representando todas as vozes e realidades de Moçambique',
image: img01,
icon: <Users className="h-6 w-6" />,
color: 'from-red-500 to-orange-500',
label: 'Valor Central',
},
{
id: 2,
title: 'Inovação',
description:
'Soluções tecnológicas e sustentáveis que impulsionam o desenvolvimento',
image: img02,
icon: <Lightbulb className="h-6 w-6" />,
color: 'from-red-600 to-red-700',
label: 'Essencial',
},
{
id: 3,
title: 'Excelência',
description:
'Acreditamos que cada história merece ser contada com propósito. Combinamos visão criativa e conhecimento estratégico para dar vida às ideias.',
image: img03,
label: 'Somos mais que comunicação, somos transformação',
},
{
id: 4,
title: 'Impacto Positivo',
description:
'Criamos marcas que inspiram, conteúdo que conecta e experiências memoráveis. Transformamos visão em realidade com estratégia, criatividade e tecnologia.',
image: img04,
icon: <Globe className="h-6 w-6" />,
color: 'from-red-600 to-red-700',
label: 'Propósito',
},
];
const toggleExpand = (id: number) => {
setExpandedId(expandedId === id ? null : id);
};
return (
<section
ref={sectionRef}
className="bg-primary relative overflow-hidden py-4"
>
<div className="mx-auto mb-4 max-w-7xl space-y-2 px-6 text-center">
<div
className={`transition-all duration-1000 ${isInView ? 'translate-y-0 opacity-100' : 'translate-y-8 opacity-0'}`}
>
<h2 className="text-3xl font-semibold tracking-wide text-white md:text-3xl">
<span
className="font-bold italic not-italic"
style={{ color: '#FFFFFF' }}
>
<Trans>Valores</Trans>
</span>
</h2>
</div>
</div>
<div className="mx-auto max-w-7xl px-6">
<div className="grid grid-cols-1 items-stretch gap-4 md:grid-cols-2 lg:grid-cols-4">
{values.map((value, i) => {
const isExpanded = expandedId === value.id;
const hasLongText = value.id === 3 || value.id === 4;
return (
<div
key={value.id}
onMouseEnter={() => setHoveredId(value.id)}
onMouseLeave={() => setHoveredId(null)}
className={`group relative transition-all duration-500 ${isInView ? 'translate-y-0 opacity-100' : 'translate-y-10 opacity-0'}`}
style={{ transitionDelay: `${i * 100}ms` }}
>
<div
className={`relative flex h-full flex-col overflow-hidden rounded-2xl border bg-white/5 backdrop-blur-xl transition-all duration-500 ${
hoveredId === value.id
? 'translate-y-[-6px] border-red-500/40 shadow-2xl shadow-red-600/20'
: 'border-white/10'
}`}
>
<div className="relative flex h-44 flex-1 flex-col overflow-hidden">
<img
src={value.image}
alt={value.title}
className={`h-full w-full object-cover transition-transform duration-700 ${
hoveredId === value.id
? 'scale-110'
: 'scale-100'
}`}
/>
<div className="absolute inset-0 bg-black/40" />
{value.icon && value.color && (
<div
className={`absolute top-4 left-4 h-11 w-11 rounded-xl bg-gradient-to-br ${value.color} flex items-center justify-center text-white shadow-lg transition-transform duration-500 ${
hoveredId === value.id
? 'scale-110 rotate-12'
: ''
}`}
>
{value.icon}
</div>
)}
{value.label && (
<div className="absolute top-4 right-5 px-3 py-1 text-lg font-bold text-white drop-shadow-md md:text-xl">
<Trans>{value.label}</Trans>
</div>
)}
</div>
<div className="flex flex-col bg-gradient-to-t from-black/60 via-black/30 to-transparent p-5">
<h3 className="mb-2 text-2xl font-bold text-white drop-shadow-md md:text-3xl">
<Trans>{value.title}</Trans>
</h3>
<p
className={`text-sm leading-relaxed text-gray-100 drop-shadow-md md:text-base ${
!isExpanded && hasLongText
? 'line-clamp-2'
: ''
}`}
>
<Trans>{value.description}</Trans>
</p>
{hasLongText && (
<button
onClick={() =>
toggleExpand(value.id)
}
className="mt-1 flex items-center gap-1 text-sm font-medium text-red-400 transition-colors hover:text-red-300"
>
{isExpanded ? (
<>
<Trans>Ver menos</Trans>{' '}
<ChevronUp className="h-4 w-4" />
</>
) : (
<>
<Trans>Ver mais</Trans>{' '}
<ChevronDown className="h-4 w-4" />
</>
)}
</button>
)}
</div>
{hoveredId === value.id && (
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl">
<div className="animate-shimmer absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/10 to-transparent" />
</div>
)}
</div>
</div>
);
})}
</div>
</div>
<style>{`
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.animate-shimmer { animation: shimmer 1.5s ease-in-out infinite; }
`}</style>
</section>
);
};
export default CompanyValues;
@@ -0,0 +1,124 @@
// Imported from investidor/resources/js/modules/news/sections/about/MissionVision.tsx; original content preserved.
import { useState, useEffect, useRef } from 'react';
import { Target, Eye } from 'lucide-react';
import { Trans } from './source-text';
interface MissionVisionItem {
id: number;
title: string;
description: string;
icon: React.ReactNode;
gradient: string;
}
export const MissionVision = () => {
const [hoveredId, setHoveredId] = useState<number | null>(null);
const sectionRef = useRef<HTMLElement>(null);
const [isInView, setIsInView] = useState<boolean>(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setIsInView(entry.isIntersecting),
{ threshold: 0.1, rootMargin: '0px 0px -80px 0px' },
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
}, []);
const items: MissionVisionItem[] = [
{
id: 1,
title: 'Missão',
description:
'Fornecer soluções integradas e inovadoras em comércio, comunicação, tecnologia e informação, com excelência na intermediação comercial, na informação pública e em plataformas digitais promovendo crescimento sustentável e impacto positivo no mercado moçambicano.',
icon: <Target className="h-7 w-7 sm:h-8 sm:w-8" />,
gradient: 'from-red-500 to-red-600',
},
{
id: 2,
title: 'Visão',
description:
'Ser a referência líder em Moçambique, impulsionando o progresso do país através de soluções inovadoras e sustentáveis, para um futuro mais conectado, próspero e dinâmico.',
icon: <Eye className="h-7 w-7 sm:h-8 sm:w-8" />,
gradient: 'from-red-600 to-red-800',
},
];
return (
<section ref={sectionRef} className="relative overflow-hidden py-6">
<div className="relative mx-auto max-w-7xl px-5 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-4 sm:gap-5 lg:grid-cols-2 lg:gap-6">
{items.map((item, index) => (
<div
key={item.id}
onMouseEnter={() => setHoveredId(item.id)}
onMouseLeave={() => setHoveredId(null)}
className={`transition-all duration-1200 ease-in-out ${isInView ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-6 scale-95 opacity-0'} `}
style={{ transitionDelay: `${index * 200}ms` }}
>
<div
className={`relative h-full rounded-2xl border bg-white/5 backdrop-blur-lg transition-all duration-700 ease-in-out sm:rounded-3xl ${
hoveredId === item.id
? '-translate-y-2 border-red-500/50 shadow-xl shadow-red-600/20 sm:-translate-y-3'
: 'border-white/10 shadow-sm'
} `}
>
<div className="p-5 sm:p-6 lg:p-8">
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start sm:gap-6">
<div
className={`h-14 w-14 flex-shrink-0 rounded-xl bg-gradient-to-br sm:h-16 sm:w-16 sm:rounded-2xl ${item.gradient} flex items-center justify-center p-3 text-white shadow-lg transition-all duration-700 ease-in-out sm:p-3.5 ${
hoveredId === item.id
? 'scale-125 rotate-12 animate-pulse shadow-2xl'
: 'animate-float scale-100 rotate-0'
} `}
>
{item.icon}
</div>
<div className="mt-3 min-w-0 flex-1 text-center sm:mt-0 sm:text-left">
<h3
className={`mb-2 text-2xl font-light tracking-tight transition-colors duration-500 sm:mb-3 sm:text-3xl lg:text-4xl ${hoveredId === item.id ? 'text-red-400' : 'text-red-500'} `}
>
<Trans>{item.title}</Trans>
</h3>
<p className="text-sm leading-relaxed font-light text-[#ffffff] sm:text-base lg:text-lg">
<Trans>
{item.description}
</Trans>
</p>
</div>
</div>
</div>
{hoveredId === item.id && (
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl sm:rounded-3xl">
<div className="animate-shimmer absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/10 to-transparent" />
</div>
)}
</div>
</div>
))}
</div>
</div>
<style>{`
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
.animate-shimmer { animation: shimmer 2.5s ease-in-out infinite; }
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.animate-float { animation: float 3s ease-in-out infinite; }
`}</style>
</section>
);
};
export default MissionVision;
@@ -0,0 +1,24 @@
import React from 'react';
const InstituitionalMaps = () => {
// Google Maps EMBED – Prédio Revista Tempo (Maputo)
const mapUrl =
'https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3586.601889886872!2d32.5840525!3d-25.9697003!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x1ee69b0710fc41f7%3A0x4fa9cf4e964b7e50!2sPr%C3%A9dio%20Revista%20Tempo!5e0!3m2!1spt-PT!2smz!4v1733750000000';
return (
<div className="relative h-full w-full">
{/* MAPA */}
<iframe
src={mapUrl}
className="absolute inset-0 h-full w-full rounded-xl"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="Localização - Prédio Revista Tempo, Maputo"
/>
</div>
);
};
export default InstituitionalMaps;
@@ -0,0 +1,36 @@
// Empty-slot presentation copied from investidor's AdvertisementSlot.
// Preserve the original reserved spaces without importing its advertising backend or tracking.
export function AdvertisementSlot({
position,
className = '',
}: {
page: string;
position: string;
slot?: string;
limit?: number;
fallbackToUnslotted?: boolean;
className?: string;
}) {
const placement = position === 'top' ? 'article.top' : 'article.bottom';
return (
<aside
aria-label={`Espaço de publicidade ${placement}`}
className={`mx-auto flex w-full items-center justify-center overflow-hidden border border-dashed border-slate-300 bg-slate-50/80 text-center ${className}`}
style={{ aspectRatio: '970 / 250', maxWidth: '970px' }}
data-ad-placement={placement}
data-ad-state="empty"
>
<div className="px-4 py-6">
<p className="text-[10px] font-semibold tracking-[0.2em] text-slate-400 uppercase">
Publicidade
</p>
<p className="mt-1 text-xs text-slate-500">
Espaço disponível para publicidade
</p>
<p className="mt-1 text-[10px] text-slate-400">
970 × 250px · {placement}
</p>
</div>
</aside>
);
}
@@ -0,0 +1,7 @@
import { useTempoUi } from '@/components/tempo-brand';
// Bridge the original Trans calls to this platform's existing language context.
export function Trans({ children }: { children: string }) {
const { t } = useTempoUi();
return <span>{t(children)}</span>;
}
+168 -69
View File
@@ -1,6 +1,13 @@
import { Link, router, usePage } from '@inertiajs/react'; import { Link, router, usePage } from '@inertiajs/react';
import { ChevronDown, LogOut, UserRound } from 'lucide-react'; import {
import { useState } from 'react'; ChevronDown,
ArrowUpRight,
LogOut,
Menu,
UserRound,
X,
} from 'lucide-react';
import { useEffect, useId, useRef, useState } from 'react';
import { useNavigationContext } from '@/components/navigation-context'; import { useNavigationContext } from '@/components/navigation-context';
import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand'; import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
import { import {
@@ -61,6 +68,11 @@ export function PortalGroupedMenu() {
const current = currentNavigationGroup(groups, url); const current = currentNavigationGroup(groups, url);
const currentItem = currentNavigationItem(groups, url); const currentItem = currentNavigationItem(groups, url);
const [loggingOut, setLoggingOut] = useState(false); const [loggingOut, setLoggingOut] = useState(false);
const [expanded, setExpanded] = useState(false);
const institutionalId = useId();
const toolsId = useId();
const toggle = useRef<HTMLButtonElement>(null);
useEffect(() => router.on('navigate', () => setExpanded(false)), []);
const items = (children: PortalNavItem[]) => const items = (children: PortalNavItem[]) =>
children.map((item) => children.map((item) =>
item.children ? ( item.children ? (
@@ -97,101 +109,100 @@ export function PortalGroupedMenu() {
!['home', 'discovery', 'realization', 'support'].includes(group.id), !['home', 'discovery', 'realization', 'support'].includes(group.id),
); );
return ( return (
<header className="tempo-header" lang={language}> <header
<div className="tempo-header-inner mx-auto flex max-w-[1440px] flex-wrap items-center justify-between gap-x-3 gap-y-0 px-4 sm:px-6 xl:min-h-22"> className="tempo-header"
lang={language}
data-expanded={expanded || undefined}
onKeyDown={(event) => {
if (
event.key === 'Escape' &&
expanded &&
!event.defaultPrevented
) {
setExpanded(false);
toggle.current?.focus();
}
}}
>
<div className="tempo-header-inner">
<Link <Link
href="/" href="/"
aria-label="Tempo — início" aria-label="Tempo — início"
className="tempo-logo-link my-4 shrink-0" className="tempo-logo-link"
> >
<img <img
src="/images/logo/logo-vermelho.png" src="/images/logo/logo-vermelho.png"
alt="Tempo" alt="Tempo"
width="1787" width="1787"
height="564" height="564"
className="h-9 w-auto object-contain sm:h-10" className="tempo-header-logo"
/> />
</Link> </Link>
<nav <nav
aria-label="Áreas do portal" id={institutionalId}
className="order-3 w-full border-t border-white/10 xl:order-none xl:w-auto xl:flex-1 xl:border-0" aria-label="Navegação institucional"
className="tempo-primary-navigation"
> >
<ul className="flex items-center justify-between gap-0 py-2 xl:justify-center xl:py-0"> <ul>
<li> <li>
<Link <Link
href="/" href="/"
className="tempo-nav-trigger" className="tempo-nav-trigger"
aria-current={ aria-current={
current === 'home' ? 'page' : undefined url.split('?')[0] === '/'
? 'page'
: undefined
} }
data-current-group={ data-current-group={
current === 'home' || undefined url.split('?')[0] === '/' || undefined
} }
> >
{t('Início')} {t('Início')}
</Link> </Link>
</li> </li>
{groups <li>
.filter((group) => <a
[ href="https://revista.tempo.co.mz/"
'discovery', target="_blank"
'realization', rel="noopener noreferrer"
'support', className="tempo-nav-trigger"
].includes(group.id), aria-label={`${t('Revista Tempo')} — ${t('Abre num novo separador')}`}
) >
.map((group) => ( {t('Revista Tempo')}
<li key={group.id}> <ArrowUpRight size={14} aria-hidden="true" />
<DropdownMenu modal={false}> </a>
<DropdownMenuTrigger </li>
className="tempo-nav-trigger" {[
data-current-group={ ['/sobre', 'Sobre nós'],
current === group.id || ['/contacto', 'Contacto'],
undefined ].map(([href, label]) => (
} <li key={href}>
> <Link
{t(group.label)} href={href}
<ChevronDown className="tempo-nav-trigger"
size={14} aria-current={
aria-hidden="true" url.split('?')[0] === href
/> ? 'page'
{current === group.id && ( : undefined
<span className="sr-only"> }
Grupo actual data-current-group={
</span> url.split('?')[0] === href || undefined
)} }
</DropdownMenuTrigger> >
<DropdownMenuContent {t(label)}
align="start" </Link>
sideOffset={12} </li>
collisionPadding={12} ))}
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
{items(group.children ?? [])}
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul> </ul>
</nav> </nav>
<div className="tempo-panel-summary"> <div id={toolsId} className="tempo-header-extras">
<Link href="/investidor/manifestacoes"> <div className="tempo-header-utilities">
{t('O meu painel')} <div className="flex items-center gap-1">
</Link> <TempoWeather />
<span aria-live="polite" aria-atomic="true"> <TempoLanguageSelector />
{requestCount}{' '} </div>
{t( <TempoSocialLinks />
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> </div>
<TempoSocialLinks />
</div> </div>
<div className="tempo-header-actions"> <div className="tempo-header-actions">
{props.auth?.user ? ( {props.auth?.user ? (
@@ -242,6 +253,94 @@ export function PortalGroupedMenu() {
{t('Entrar')} {t('Entrar')}
</Link> </Link>
)} )}
</div>
<button
ref={toggle}
type="button"
className="tempo-header-toggle"
aria-label={
expanded
? 'Fechar menu e ferramentas'
: 'Abrir menu e ferramentas'
}
aria-expanded={expanded}
aria-controls={`${institutionalId} ${toolsId}`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<X size={22} aria-hidden="true" />
) : (
<Menu size={22} aria-hidden="true" />
)}
</button>
</div>
<div className="tempo-section-bar">
<div className="tempo-section-inner">
<Link
href="/investidor/manifestacoes"
className="tempo-panel-summary"
>
<span>{t('O meu painel')}</span>
<span
className="tempo-panel-count"
aria-live="polite"
aria-atomic="true"
>
{requestCount}{' '}
{t(
requestCount === 1
? 'pedido recebido'
: 'pedidos recebidos',
)}
</span>
</Link>
<nav
aria-label="Áreas do portal"
className="tempo-section-navigation"
>
<ul>
{groups
.filter((group) =>
[
'discovery',
'realization',
'support',
].includes(group.id),
)
.map((group) => (
<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={8}
collisionPadding={12}
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
{items(group.children ?? [])}
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
</nav>
<Link <Link
href="/manifestar-interesse" href="/manifestar-interesse"
className="tempo-interest-button" className="tempo-interest-button"
+8
View File
@@ -30,6 +30,14 @@ export const tempoSocialLinks = [
]; ];
const menuTranslations = [ const menuTranslations = [
['Sobre nós', 'About us', 'À propos', 'Sobre nosotros'],
['Revista Tempo', 'Revista Tempo', 'Revista Tempo', 'Revista Tempo'],
[
'Abre num novo separador',
'Opens in a new tab',
'Ouvre un nouvel onglet',
'Abre en una nueva pestaña',
],
['O meu painel', 'My dashboard', 'Mon espace', 'Mi panel'], ['O meu painel', 'My dashboard', 'Mon espace', 'Mi panel'],
[ [
'Manifestar interesse', 'Manifestar interesse',
+29 -20
View File
@@ -10,34 +10,41 @@ const navigation = [
export function TempoFooter() { export function TempoFooter() {
const { t, language } = useTempoUi(); const { t, language } = useTempoUi();
return ( return (
<footer className="tempo-footer" lang={language}> <footer
<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"> className="tempo-footer border-t border-white/5"
<div className="space-y-6"> lang={language}
<Link href="/" aria-label="Tempo — início"> >
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-12 px-6 py-10 md:grid-cols-2 lg:grid-cols-5 lg:gap-16 lg:px-12">
<div className="space-y-8">
<Link
href="/"
aria-label="Tempo — início"
className="block w-fit"
>
<img <img
src="/images/logo/logo-vermelho.png" src="/images/logo/logo-vermelho.png"
alt="Tempo" alt="Tempo"
width="1787" width="1787"
height="564" height="564"
className="h-11 w-auto object-contain" className="h-10 w-auto object-contain md:h-12"
loading="lazy" loading="lazy"
/> />
</Link> </Link>
<div className="flex items-start gap-3"> <div className="flex items-start gap-4">
<MapPin <MapPin
className="mt-1 size-5 shrink-0 text-[#ed1c24]" className="mt-1 size-[22px] shrink-0 text-[#ed1c24]"
aria-hidden="true" aria-hidden="true"
/> />
<address className="border-l border-white/15 pl-3 text-sm leading-7 text-slate-300 not-italic"> <address className="border-l border-white/15 pl-5 text-sm leading-relaxed text-slate-300 not-italic">
Av. Ahmed Sekou Touré, 1126 RC Av. Ahmed Sekou Touré, 1126 RC
<br /> <br />
Maputo Cidade, Moçambique Maputo Cidade, Moçambique
</address> </address>
</div> </div>
</div> </div>
<div> <div className="space-y-8">
<h2 className="tempo-footer-heading">{t('Contactos')}</h2> <h2 className="tempo-footer-heading">{t('Contactos')}</h2>
<div className="space-y-3"> <div className="space-y-5">
{[ {[
['+258868010880', '+258 86 801 0880'], ['+258868010880', '+258 86 801 0880'],
['+258833219644', '+258 83 321 9644'], ['+258833219644', '+258 83 321 9644'],
@@ -62,15 +69,17 @@ export function TempoFooter() {
<span className="tempo-contact-icon"> <span className="tempo-contact-icon">
<Mail size={16} aria-hidden="true" /> <Mail size={16} aria-hidden="true" />
</span> </span>
<strong className="text-sm font-medium [overflow-wrap:anywhere]"> <strong className="min-w-0 text-sm font-medium [overflow-wrap:anywhere]">
comercial@tempo.co.mz comercial@
<wbr />
tempo.co.mz
</strong> </strong>
</a> </a>
</div> </div>
</div> </div>
<nav aria-label="Navegação do rodapé"> <nav aria-label="Navegação do rodapé" className="space-y-8">
<h2 className="tempo-footer-heading">{t('Navegação')}</h2> <h2 className="tempo-footer-heading">{t('Navegação')}</h2>
<ul className="space-y-3"> <ul className="space-y-4">
{navigation.map(([href, label]) => ( {navigation.map(([href, label]) => (
<li key={href}> <li key={href}>
<Link href={href} className="tempo-footer-link"> <Link href={href} className="tempo-footer-link">
@@ -84,9 +93,9 @@ export function TempoFooter() {
))} ))}
</ul> </ul>
</nav> </nav>
<nav aria-label="Informação legal"> <nav aria-label="Informação legal" className="space-y-8">
<h2 className="tempo-footer-heading">Legal</h2> <h2 className="tempo-footer-heading">Legal</h2>
<ul className="space-y-3"> <ul className="space-y-4">
{[ {[
[ [
'politica-de-privacidade', 'politica-de-privacidade',
@@ -109,9 +118,9 @@ export function TempoFooter() {
))} ))}
</ul> </ul>
</nav> </nav>
<div> <div className="space-y-8">
<h2 className="tempo-footer-heading">Social</h2> <h2 className="tempo-footer-heading">Social</h2>
<p className="mb-5 text-sm leading-6 text-slate-300"> <p className="text-xs leading-relaxed font-medium text-slate-300">
{t( {t(
'Siga a Revista Tempo para actualizações exclusivas.', 'Siga a Revista Tempo para actualizações exclusivas.',
)} )}
@@ -119,8 +128,8 @@ export function TempoFooter() {
<TempoSocialLinks footer /> <TempoSocialLinks footer />
</div> </div>
</div> </div>
<div className="border-t border-white/10"> <div className="border-t border-white/10 py-2">
<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"> <div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-6 px-6 text-center text-[10px] tracking-[0.2em] text-slate-400 md:flex-row md:text-left md:text-xs">
<p> <p>
© {new Date().getFullYear()}{' '} © {new Date().getFullYear()}{' '}
<span className="text-[#ed1c24]">Tempo</span>.{' '} <span className="text-[#ed1c24]">Tempo</span>.{' '}
@@ -23,6 +23,8 @@ export const portalLinks = [
['/apoio', 'Apoio ao Investidor'], ['/apoio', 'Apoio ao Investidor'],
] as const; ] as const;
const names: Record<string, string> = { const names: Record<string, string> = {
'/sobre': 'Sobre nós',
'/contacto': 'Contacto',
'/politica-de-privacidade': 'Política de Privacidade', '/politica-de-privacidade': 'Política de Privacidade',
'/termos-de-uso': 'Termos de Uso', '/termos-de-uso': 'Termos de Uso',
'/porque-investir': 'Porquê investir em Moçambique', '/porque-investir': 'Porquê investir em Moçambique',
+75
View File
@@ -0,0 +1,75 @@
// Imported from investidor/resources/js/modules/news/pages/AboutPage.tsx.
import React, { useState, useEffect } from 'react';
import { Head } from '@inertiajs/react';
import { AboutStory } from '@/components/institutional-source/AboutStory';
import { MissionVision } from '@/components/institutional-source/MissionVision';
import { CompanyValues } from '@/components/institutional-source/CompanyValues';
import BackButton from '@/components/institutional-source/BackButton';
import { AdvertisementSlot } from '@/components/institutional-source/source-advertisement-slot';
const AboutPage: React.FC = () => {
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setLoading(false);
}, 300);
return () => clearTimeout(timer);
}, []);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center pt-20">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-red-600 border-t-transparent"></div>
</div>
);
}
return (
<div className="tempo-source-page tempo-about-page min-h-screen bg-[#1f1f1f] pt-[3px]">
<Head title="Sobre nós" />
<BackButton label="Voltar" className="mb-1" />
<header className="relative overflow-hidden px-6 pb-2 md:px-12 lg:px-20">
<div className="relative z-10 mx-auto w-full max-w-7xl px-6 md:px-12 lg:px-20">
<div className="space-y-2">
<h1 className="text-4xl leading-tight font-light text-white sm:text-6xl lg:text-5xl">
Ajudamos a construir o{' '}
<span className="bg-gradient-to-r from-red-500 to-red-700 bg-clip-text text-transparent italic">
futuro
</span>{' '}
de Moçambique
</h1>
<div className="h-[3px] w-16 rounded-full bg-[#ED1C24]" />
</div>
</div>
</header>
<AdvertisementSlot
page="about"
position="top"
slot="about_top"
limit={1}
fallbackToUnslotted
className=""
/>
<div className="text-white">
<AboutStory />
<MissionVision />
<CompanyValues />
</div>
<AdvertisementSlot
page="about"
position="bottom"
slot="about_bottom"
limit={2}
fallbackToUnslotted
className="py-4"
/>
</div>
);
};
export default AboutPage;
+552
View File
@@ -0,0 +1,552 @@
// Imported from investidor/resources/js/domains/investor-portal/components/contactpage/institutional-contact.tsx.
// Shell and submission endpoint adapted to InvesteMoz; page content and form retained.
import { useMemo, useState } from 'react';
import { Head } from '@inertiajs/react';
import {
AlertCircle,
CheckCircle2,
FileText,
Loader2,
Mail,
MessageCircle,
MessageSquare,
Phone,
Send,
User,
X,
} from 'lucide-react';
import InstituitionalMaps from '@/components/institutional-source/institutional-map';
type FormData = {
name: string;
email: string;
phone: string;
subject: string;
message: string;
};
type FieldName = keyof FormData;
type FormErrors = Partial<Record<FieldName, string>>;
const contactMessagesEndpoint = '/contacto/mensagem';
const emptyForm: FormData = {
name: '',
email: '',
phone: '',
subject: '',
message: '',
};
function extractPhoneMZ(value: string) {
const digits = value.replace(/\D/g, '');
if (digits.length > 9 && digits.startsWith('258'))
return digits.slice(3, 12);
return digits.slice(0, 9);
}
function maskPhoneMZ(value: string) {
const digits = extractPhoneMZ(value);
if (digits.length <= 2) return digits;
if (digits.length <= 5) return `${digits.slice(0, 2)} ${digits.slice(2)}`;
return `${digits.slice(0, 2)} ${digits.slice(2, 5)} ${digits.slice(5)}`;
}
function validateField(field: FieldName, value: string) {
const trimmed = value.trim();
if (field === 'phone') {
const digits = extractPhoneMZ(value);
return digits && digits.length !== 9
? 'Introduza exactamente 9 dígitos depois de +258.'
: '';
}
if (!trimmed) return 'Este campo é obrigatório.';
if (field === 'name' && trimmed.length < 2) return 'Nome muito curto.';
if (field === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
return 'Endereço de email inválido.';
}
if (field === 'subject' && trimmed.length < 3)
return 'Assunto muito curto.';
if (field === 'subject' && trimmed.length > 100)
return 'Assunto muito longo (máximo 100 caracteres).';
if (field === 'message' && trimmed.length < 10)
return 'Mensagem muito curta (mínimo 10 caracteres).';
if (field === 'message' && trimmed.length > 2000)
return 'Mensagem muito longa (máximo 2000 caracteres).';
return '';
}
const Contact = () => {
const [showForm, setShowForm] = useState(false);
const [data, setData] = useState<FormData>(emptyForm);
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Partial<Record<FieldName, boolean>>>(
{},
);
const [processing, setProcessing] = useState(false);
const [sent, setSent] = useState(false);
const [submitError, setSubmitError] = useState('');
const isValid = useMemo(
() =>
(Object.keys(data) as FieldName[]).every(
(field) => !validateField(field, data[field]),
),
[data],
);
const setField = (field: FieldName, value: string) => {
const nextValue = field === 'phone' ? maskPhoneMZ(value) : value;
setData((current) => ({ ...current, [field]: nextValue }));
setSent(false);
setSubmitError('');
if (touched[field]) {
setErrors((current) => ({
...current,
[field]: validateField(field, nextValue),
}));
}
};
const blurField = (field: FieldName) => {
setTouched((current) => ({ ...current, [field]: true }));
setErrors((current) => ({
...current,
[field]: validateField(field, data[field]),
}));
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const nextErrors: FormErrors = {};
(Object.keys(data) as FieldName[]).forEach((field) => {
const error = validateField(field, data[field]);
if (error) nextErrors[field] = error;
});
setTouched({
name: true,
email: true,
phone: true,
subject: true,
message: true,
});
setErrors(nextErrors);
if (Object.keys(nextErrors).length) return;
setProcessing(true);
setSent(false);
setSubmitError('');
try {
const response = await fetch(contactMessagesEndpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((cookie) => cookie.startsWith('XSRF-TOKEN='))
?.slice(11) ?? '',
),
},
body: JSON.stringify({
platform: 'InvesteMoz',
name: data.name.trim(),
email: data.email.trim(),
phone: extractPhoneMZ(data.phone) || null,
subject: data.subject.trim(),
message: data.message.trim(),
}),
});
const result = await response.json().catch(() => null);
if (!response.ok) {
const validationMessage = result?.errors
? Object.values(result.errors as Record<string, string[]>)
.flat()
.join(' ')
: '';
throw new Error(
validationMessage ||
result?.message ||
'Não foi possível enviar a mensagem. Tente novamente.',
);
}
setData(emptyForm);
setTouched({});
setErrors({});
setSent(true);
} catch (error) {
setSubmitError(
error instanceof Error
? error.message
: 'Não foi possível enviar a mensagem. Tente novamente.',
);
} finally {
setProcessing(false);
}
};
const fieldClass = (field: FieldName) =>
`w-full rounded-lg border bg-white/5 px-4 py-3.5 text-white outline-none transition placeholder:text-white/40 ${
errors[field] && touched[field]
? 'border-red-500 ring-2 ring-red-500/20'
: 'border-white/20 focus:border-red-500 focus:bg-white/10 focus:ring-2 focus:ring-red-500/20'
}`;
const ErrorMessage = ({ field }: { field: FieldName }) =>
errors[field] && touched[field] ? (
<p className="mt-1 flex items-center gap-1 text-xs text-red-400">
<AlertCircle className="h-3 w-3" />
{errors[field]}
</p>
) : null;
return (
<>
<Head title="Contacto" />
<main className="tempo-source-page">
<div className="min-h-screen bg-[#1f1f1f] pt-8 text-white">
<header className="relative overflow-hidden px-6 pb-2 md:px-12 lg:px-20">
<div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute top-0 left-1/4 h-96 w-96 rounded-full bg-red-600/10 blur-3xl" />
<div className="absolute right-1/4 bottom-0 h-96 w-96 rounded-full bg-purple-600/10 blur-3xl" />
</div>
<div className="relative z-10 mx-auto w-full max-w-7xl px-6 md:px-12 lg:px-20">
<div className="space-y-2">
<h1 className="text-5xl leading-tight font-light text-white md:text-6xl">
Vamos{' '}
<span className="bg-gradient-to-r from-red-500 to-red-700 bg-clip-text text-transparent italic">
conversar
</span>
</h1>
<div className="h-[3px] w-16 rounded-full bg-[#ED1C24]" />
</div>
</div>
</header>
<section className="bg-[#1f1f1f] px-4 py-4 sm:px-4 md:px-8 lg:px-20">
<div className="mx-auto grid max-w-6xl grid-cols-1 items-start gap-6 lg:grid-cols-2 lg:gap-8">
<div className="order-2 space-y-5 rounded-3xl border border-white/10 bg-[#2a2a2a] p-5 shadow-2xl sm:p-6 lg:order-1 lg:p-8">
<h3 className="mb-5 text-center font-serif text-xl font-bold tracking-tight text-white">
Informações de Contacto
</h3>
<div className="flex flex-col gap-3">
<a
href="https://wa.me/258868010880"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 rounded-xl px-4 py-3 font-medium text-white transition hover:opacity-80 active:scale-95"
>
<div className="flex h-10 w-10 items-center justify-center">
<MessageCircle className="text-xl text-[#25D366]" />
</div>
<span>WhatsApp</span>
</a>
<a
href="tel:+258868010880"
className="flex items-center gap-4 rounded-xl px-4 py-3 font-medium text-white transition hover:opacity-80 active:scale-95"
>
<div className="flex h-10 w-10 items-center justify-center">
<Phone
size={20}
className="text-[#2563EB]"
/>
</div>
<span>Ligar Agora</span>
</a>
<button
onClick={() => setShowForm(!showForm)}
className="flex w-full items-center gap-4 rounded-xl px-4 py-3 font-medium text-white transition hover:opacity-80 active:scale-95"
>
<div className="flex h-10 w-10 items-center justify-center">
{showForm ? (
<X
size={20}
className="text-red-500"
/>
) : (
<Mail size={20} />
)}
</div>
<span>
{showForm
? 'Fechar Formulário'
: 'Solicitar Serviços'}
</span>
</button>
</div>
</div>
<div className="order-1 lg:order-2">
{showForm && (
<div className="animate-in fade-in slide-in-from-bottom-4 rounded-3xl border border-white/10 bg-[#2a2a2a] p-5 shadow-2xl duration-500 sm:p-6 lg:p-8">
<div className="mb-10 text-center">
<h2 className="mb-3 text-4xl font-bold text-white md:text-5xl">
Fale Connosco.
</h2>
<p className="text-lg text-white/70">
Envie uma mensagem,
responderemos com atenção.
</p>
</div>
<form
onSubmit={handleSubmit}
className="space-y-6"
noValidate
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<label
htmlFor="name"
className="flex items-center gap-2 text-sm font-medium text-white/90"
>
<User className="h-4 w-4 text-white/60" />
Nome Completo
<span className="text-red-400">
*
</span>
</label>
<input
id="name"
value={data.name}
onChange={(e) =>
setField(
'name',
e.target.value,
)
}
onBlur={() =>
blurField('name')
}
placeholder="Seu nome..."
className={fieldClass(
'name',
)}
/>
<ErrorMessage field="name" />
</div>
<div className="space-y-2">
<label
htmlFor="email"
className="flex items-center gap-2 text-sm font-medium text-white/90"
>
<Mail className="h-4 w-4 text-white/60" />
Email
<span className="text-red-400">
*
</span>
</label>
<input
id="email"
type="email"
value={data.email}
onChange={(e) =>
setField(
'email',
e.target.value,
)
}
onBlur={() =>
blurField('email')
}
placeholder="Seuemail@exemplo.com"
autoComplete="email"
className={fieldClass(
'email',
)}
/>
<ErrorMessage field="email" />
</div>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<label
htmlFor="phone"
className="flex items-center gap-2 text-sm font-medium text-white/90"
>
<Phone className="h-4 w-4 text-white/60" />
Telefone
</label>
<div className="flex overflow-hidden rounded-lg">
<span className="flex items-center border border-r-0 border-white/20 bg-white/10 px-4 text-sm font-semibold">
+258
</span>
<input
id="phone"
type="tel"
inputMode="numeric"
maxLength={11}
value={data.phone}
onChange={(e) =>
setField(
'phone',
e.target
.value,
)
}
onBlur={() =>
blurField(
'phone',
)
}
placeholder="85 884 7646"
autoComplete="tel-national"
className={`${fieldClass('phone')} rounded-l-none`}
/>
</div>
<ErrorMessage field="phone" />
{!errors.phone && (
<p className="flex justify-between text-xs text-white/45">
<span>
Apenas os 9
dígitos
nacionais
</span>
<span>
{
extractPhoneMZ(
data.phone,
).length
}
/9
</span>
</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="subject"
className="flex items-center gap-2 text-sm font-medium text-white/90"
>
<FileText className="h-4 w-4 text-white/60" />
Assunto
<span className="text-red-400">
*
</span>
</label>
<input
id="subject"
value={data.subject}
onChange={(e) =>
setField(
'subject',
e.target.value,
)
}
onBlur={() =>
blurField('subject')
}
placeholder="Sobre o que deseja falar?"
className={fieldClass(
'subject',
)}
/>
<ErrorMessage field="subject" />
</div>
</div>
<div className="space-y-2">
<label
htmlFor="message"
className="flex items-center gap-2 text-sm font-medium text-white/90"
>
<MessageSquare className="h-4 w-4 text-white/60" />
Mensagem
<span className="text-red-400">
*
</span>
</label>
<textarea
id="message"
rows={6}
value={data.message}
onChange={(e) =>
setField(
'message',
e.target.value,
)
}
onBlur={() =>
blurField('message')
}
placeholder="Escreva sua mensagem aqui..."
className={`${fieldClass('message')} resize-none`}
/>
<ErrorMessage field="message" />
{data.message.length > 0 && (
<p className="text-right text-xs text-white/40">
{data.message.length}
/2000
</p>
)}
</div>
<div className="border-t border-white/10 pt-6">
<div className="flex flex-col items-center gap-4">
<button
type="submit"
disabled={
processing ||
!isValid
}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-600 px-8 py-4 font-semibold text-white transition hover:-translate-y-0.5 hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50 md:w-auto"
>
{processing ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Enviando...
</>
) : (
<>
<Send className="h-5 w-5" />
Enviar Mensagem
</>
)}
</button>
{sent && (
<div
role="status"
className="flex items-center gap-2 rounded-lg border border-green-500/50 bg-green-500/20 px-4 py-2.5 text-green-300"
>
<CheckCircle2 className="h-5 w-5" />
Mensagem enviada com
sucesso!
</div>
)}
{submitError && (
<div
role="alert"
className="flex items-center gap-2 rounded-lg border border-red-500/50 bg-red-500/20 px-4 py-2.5 text-red-200"
>
<AlertCircle className="h-5 w-5 shrink-0" />
{submitError}
</div>
)}
</div>
</div>
</form>
</div>
)}
</div>
</div>
</section>
<section className="bg-[#1f1f1f] px-4 pb-4 sm:px-6 md:px-12">
<div className="mx-auto max-w-6xl">
<div className="mb-6 flex items-center gap-4">
<div className="h-[1px] w-12 bg-red-600" />
<span className="text-xs font-medium tracking-[0.25em] text-white uppercase">
Localização Geográfica
</span>
</div>
<div className="h-[350px] overflow-hidden rounded-2xl border border-white/10 contrast-125 grayscale transition-all duration-1000 hover:grayscale-0 sm:h-[450px]">
<InstituitionalMaps />
</div>
</div>
</section>
</div>
</main>
</>
);
};
export default Contact;
@@ -0,0 +1,8 @@
Plataforma: {{ $contactMessage['platform'] }}
Nome: {{ $contactMessage['name'] }}
E-mail: {{ $contactMessage['email'] }}
Telefone: {{ $contactMessage['phone'] ?: 'Não informado' }}
Assunto: {{ $contactMessage['subject'] }}
Mensagem:
{{ $contactMessage['message'] }}
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $contactMessage['subject'] }}</title>
</head>
<body style="margin: 0; padding: 24px; background: #f3f4f6; color: #111827; font-family: Arial, sans-serif;">
<div style="max-width: 640px; margin: 0 auto; padding: 32px; background: #ffffff; border-radius: 8px;">
<p><strong>Plataforma:</strong> {{ $contactMessage['platform'] }}</p>
<p><strong>Nome:</strong> {{ $contactMessage['name'] }}</p>
<p><strong>E-mail:</strong> <a href="mailto:{{ $contactMessage['email'] }}">{{ $contactMessage['email'] }}</a></p>
<p><strong>Telefone:</strong> {{ $contactMessage['phone'] ?: 'Não informado' }}</p>
<p><strong>Assunto:</strong> {{ $contactMessage['subject'] }}</p>
<div style="margin-top: 24px; padding: 16px; background: #f9fafb; border-left: 4px solid #2563eb; white-space: pre-wrap;">{{ $contactMessage['message'] }}</div>
<p style="margin-bottom: 0; margin-top: 24px; color: #6b7280; font-size: 13px;">
Para responder, use o botão "Responder" do seu cliente de e-mail.
</p>
</div>
</body>
</html>
+4 -1
View File
@@ -4,6 +4,7 @@ use App\Http\Controllers\InvestmentContextController;
use App\Http\Controllers\InvestmentDiscoveryController; use App\Http\Controllers\InvestmentDiscoveryController;
use App\Http\Controllers\InvestmentSubmissionController; use App\Http\Controllers\InvestmentSubmissionController;
use App\Http\Controllers\InvestmentWorkspaceController; use App\Http\Controllers\InvestmentWorkspaceController;
use App\Http\Controllers\PortalContactController;
use App\Http\Controllers\PortalManagementController; use App\Http\Controllers\PortalManagementController;
use App\Http\Middleware\PrivateInvestmentResponse; use App\Http\Middleware\PrivateInvestmentResponse;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -15,7 +16,9 @@ Route::redirect('/projectos-ancora', '/carteira?kind=project');
Route::redirect('/portal-do-investidor', '/'); Route::redirect('/portal-do-investidor', '/');
Route::redirect('/portal-do-investidor/oportunidades', '/oportunidades'); Route::redirect('/portal-do-investidor/oportunidades', '/oportunidades');
Route::get('/portal-do-investidor/oportunidades/{slug}', fn (string $slug) => redirect('/oportunidades/'.rawurlencode($slug), 301)); Route::get('/portal-do-investidor/oportunidades/{slug}', fn (string $slug) => redirect('/oportunidades/'.rawurlencode($slug), 301));
Route::redirect('/contacto', '/apoio'); Route::inertia('/sobre', 'portal/about')->name('institutional.about');
Route::inertia('/contacto', 'portal/contact')->name('institutional.contact');
Route::post('/contacto/mensagem', [PortalContactController::class, 'store'])->middleware('throttle:10,1')->name('institutional.contact.store');
Route::prefix('api/v1/investment')->middleware('throttle:api')->group(function () { Route::prefix('api/v1/investment')->middleware('throttle:api')->group(function () {
foreach (['search', 'references', 'suggest', 'map', 'compare', 'export'] as $method) { foreach (['search', 'references', 'suggest', 'map', 'compare', 'export'] as $method) {
Route::get($method, [InvestmentDiscoveryController::class, $method]); Route::get($method, [InvestmentDiscoveryController::class, $method]);
@@ -0,0 +1,128 @@
<?php
namespace Tests\Unit;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Builder;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Sleep;
use Mockery;
use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
// All database calls are mocked. No migrations, cleanup or real database access.
class CheckDevelopmentEnvironmentTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'pgsql', 'app.key' => 'test-key']);
Schema::swap(Mockery::mock(Builder::class));
}
protected function tearDown(): void
{
Sleep::fake(false);
parent::tearDown();
}
private function expectSchemaChecks(): void
{
DB::shouldReceive('selectOne')->with("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS installed")
->once()->andReturn((object) ['installed' => true]);
Schema::shouldReceive('hasTable')->times(7)->andReturn(true);
}
public function test_recovery_is_retried_and_startup_succeeds_without_exposing_the_exception(): void
{
Sleep::fake();
DB::shouldReceive('selectOne')->with('SELECT 1')->once()
->andThrow(new RuntimeException('SQLSTATE[08006] FATAL: the database system is in recovery mode; secret-password'));
DB::shouldReceive('purge')->once();
DB::shouldReceive('selectOne')->with('SELECT 1')->once()->andReturn((object) ['value' => 1]);
$this->expectSchemaChecks();
$this->artisan('portal:dev-check', ['--wait' => 10])
->expectsOutputToContain('[AGUARDA] PostgreSQL em arranque ou recuperação')
->doesntExpectOutputToContain('secret-password')
->expectsOutput('[OK] Ligação à base de dados')
->assertSuccessful();
Sleep::assertSleptTimes(1);
}
public function test_recovery_without_wait_fails_with_an_actionable_diagnosis(): void
{
DB::shouldReceive('selectOne')->with('SELECT 1')->once()
->andThrow(new RuntimeException('SQLSTATE[57P03] server starting'));
DB::shouldReceive('purge')->never();
$this->artisan('portal:dev-check')
->expectsOutputToContain('PostgreSQL em arranque ou recuperação')
->expectsOutputToContain('./deploy/local/start.ps1')
->expectsOutput('[FALHA] Ligação à base de dados')
->assertFailed();
}
public function test_persistent_unavailability_stops_at_the_deadline(): void
{
DB::shouldReceive('selectOne')->with('SELECT 1')->twice()
->andThrow(new RuntimeException('SQLSTATE[08006] connection refused'));
DB::shouldReceive('purge')->once();
$this->artisan('portal:dev-check', ['--wait' => 1])
->expectsOutputToContain('[AGUARDA] PostgreSQL indisponível')
->expectsOutput('[FALHA] Ligação à base de dados')
->assertFailed();
}
/** @return array<string, array{string, string}> */
public static function permanentFailures(): array
{
return [
'authentication behind generic PDO state' => ['SQLSTATE[08006] password authentication failed secret-password', 'Autenticação PostgreSQL recusada'],
'authentication SQLSTATE' => ['SQLSTATE[28P01] secret-password', 'Autenticação PostgreSQL recusada'],
'missing database behind generic PDO state' => ['SQLSTATE[08006] database "missing" does not exist secret-password', 'A base PostgreSQL configurada não existe'],
'missing database SQLSTATE' => ['SQLSTATE[3D000] secret-password', 'A base PostgreSQL configurada não existe'],
'unknown failure' => ['unexpected secret-password', 'Falha PostgreSQL não transitória'],
];
}
#[DataProvider('permanentFailures')]
public function test_configuration_failures_do_not_wait_or_expose_credentials(string $message, string $diagnosis): void
{
Sleep::fake();
$error = new QueryException('pgsql', 'SELECT 1', [], new RuntimeException($message));
DB::shouldReceive('selectOne')->with('SELECT 1')->once()->andThrow($error);
DB::shouldReceive('purge')->never();
$this->artisan('portal:dev-check', ['--wait' => 900])
->expectsOutputToContain($diagnosis)
->doesntExpectOutputToContain('secret-password')
->assertFailed();
Sleep::assertNeverSlept();
}
public function test_schema_errors_are_not_reported_as_connection_failures(): void
{
DB::shouldReceive('selectOne')->with('SELECT 1')->once()->andReturn((object) ['value' => 1]);
DB::shouldReceive('selectOne')->with("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS installed")
->once()->andThrow(new RuntimeException('permission denied secret-password'));
$this->artisan('portal:dev-check')
->expectsOutput('[OK] Ligação à base de dados')
->expectsOutput('[FALHA] Esquema da base de dados verificável')
->doesntExpectOutputToContain('secret-password')
->assertFailed();
}
public function test_an_invalid_wait_is_rejected_before_database_access(): void
{
DB::shouldReceive('selectOne')->never();
$this->artisan('portal:dev-check', ['--wait' => -1])
->expectsOutput('--wait deve ser um inteiro entre 0 e 3600 segundos.')
->assertExitCode(2);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace Tests\Unit;
use App\Mail\ContactMessageReceivedMail;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use RuntimeException;
// No database or external email: all transport calls are intercepted.
class PortalContactTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['contact_messages.recipient' => 'tempo@example.invalid']);
DB::shouldReceive('connection')->never();
}
/** @return array<string, string> */
private function message(): array
{
return ['name' => 'Teste QA', 'email' => 'sender@example.invalid', 'subject' => 'Pedido de contacto',
'message' => 'Mensagem de teste do adaptador local.'];
}
public function test_the_original_fields_are_delivered_through_the_local_mail_transport(): void
{
Mail::fake();
$this->postJson('/contacto/mensagem', [...$this->message(), 'platform' => 'Other'])
->assertCreated()->assertJson(['success' => true]);
Mail::assertSent(ContactMessageReceivedMail::class, fn (ContactMessageReceivedMail $mail) => $mail->hasTo('tempo@example.invalid') && $mail->contactMessage['platform'] === 'InvesteMoz'
&& $mail->contactMessage['phone'] === null
&& $mail->envelope()->replyTo[0]->address === 'sender@example.invalid'
&& $mail->envelope()->subject === 'Pedido de contacto');
Mail::assertSentCount(1);
}
public function test_invalid_fields_are_rejected_before_sending(): void
{
Mail::fake();
$this->postJson('/contacto/mensagem', ['name' => 'A', 'email' => 'invalid', 'subject' => 'AB', 'message' => 'short'])
->assertUnprocessable()->assertJsonValidationErrors(['name', 'email', 'subject', 'message']);
Mail::assertNothingSent();
}
public function test_transport_failure_is_not_reported_as_success(): void
{
Mail::shouldReceive('to')->once()->with('tempo@example.invalid')->andReturnSelf();
Mail::shouldReceive('send')->once()->andThrow(new RuntimeException('transport unavailable'));
$this->postJson('/contacto/mensagem', $this->message())
->assertStatus(500)->assertJson(['success' => false]);
}
public function test_the_copied_email_template_escapes_user_content(): void
{
$mail = new ContactMessageReceivedMail([...$this->message(), 'phone' => null, 'platform' => 'InvesteMoz',
'message' => '<script>alert(1)</script>']);
$html = $mail->render();
$this->assertStringContainsString('&lt;script&gt;', $html);
$this->assertStringNotContainsString('<script>', $html);
}
}