This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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": [
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('<script>', $html);
|
||||||
|
$this->assertStringNotContainsString('<script>', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user