diff --git a/app/Console/Commands/CheckDevelopmentEnvironment.php b/app/Console/Commands/CheckDevelopmentEnvironment.php index 240becbd..1acc5478 100644 --- a/app/Console/Commands/CheckDevelopmentEnvironment.php +++ b/app/Console/Commands/CheckDevelopmentEnvironment.php @@ -5,15 +5,24 @@ namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Sleep; +use Throwable; 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'; 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 = [ 'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='), 'Driver PostgreSQL activo' => extension_loaded('pdo_pgsql'), @@ -25,14 +34,17 @@ class CheckDevelopmentEnvironment extends Command ]; 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 { - DB::selectOne('SELECT 1'); - $checks['Ligação à base de dados'] = true; $checks['Extensão PostGIS instalada'] = (bool) DB::selectOne("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS installed")->installed; $checks['Tabelas principais instaladas'] = collect(['migrations', 'users', 'sectors', 'opportunities', 'sessions', 'cache', 'jobs']) ->every(fn (string $table): bool => Schema::hasTable($table)); - } catch (\Throwable) { - $checks['Ligação à base de dados'] = false; + } catch (Throwable) { + $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; } + + 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]; + } } diff --git a/app/Http/Controllers/PortalContactController.php b/app/Http/Controllers/PortalContactController.php new file mode 100644 index 00000000..34d88da4 --- /dev/null +++ b/app/Http/Controllers/PortalContactController.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/app/Mail/ContactMessageReceivedMail.php b/app/Mail/ContactMessageReceivedMail.php new file mode 100644 index 00000000..939594b9 --- /dev/null +++ b/app/Mail/ContactMessageReceivedMail.php @@ -0,0 +1,35 @@ +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', + ); + } +} diff --git a/composer.json b/composer.json index 64022c5c..e8c8c1c2 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "@php artisan portal:dev-check", + "@php artisan portal:dev-check --wait=900", "@php artisan dev" ], "lint": [ diff --git a/config/contact_messages.php b/config/contact_messages.php new file mode 100644 index 00000000..5049784d --- /dev/null +++ b/config/contact_messages.php @@ -0,0 +1,6 @@ + env('CONTACT_MESSAGES_TO', env('MAIL_FROM_ADDRESS')), +]; diff --git a/deploy/local/README.md b/deploy/local/README.md index 95e23f63..2de3960c 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -22,14 +22,49 @@ composer run dev Abra http://127.0.0.1:8000. O comando inicia Laravel, o worker da fila e Vite. Use Ctrl+C para terminar. A base conserva os dados entre arranques. +`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: ```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 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. 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 diff --git a/deploy/local/compose.yaml b/deploy/local/compose.yaml index ca62aa2d..88709cce 100644 --- a/deploy/local/compose.yaml +++ b/deploy/local/compose.yaml @@ -4,6 +4,7 @@ services: image: postgis/postgis:17-3.5 command: ['postgres', '-p', '55441'] restart: unless-stopped + stop_grace_period: 60s environment: POSTGRES_DB: investmoz_local_test POSTGRES_USER: investmoz_test @@ -16,6 +17,7 @@ services: test: ['CMD-SHELL', 'pg_isready -p 55441 -U investmoz_test -d investmoz_local_test'] interval: 5s timeout: 5s + start_period: 600s retries: 20 volumes: postgres-qa: diff --git a/deploy/local/start.ps1 b/deploy/local/start.ps1 index 525b631a..d5f2aba6 100644 --- a/deploy/local/start.ps1 +++ b/deploy/local/start.ps1 @@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop' $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path Push-Location $projectRoot try { - docker compose --progress plain --env-file .env -f deploy/local/compose.yaml up -d --wait --wait-timeout 120 + 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.' } php tools/local-qa-bootstrap.php if ($LASTEXITCODE -ne 0) { throw 'Preparação QA interrompida; veja a mensagem acima.' } diff --git a/public/assets/images/info.png b/public/assets/images/info.png new file mode 100644 index 00000000..69d412f9 Binary files /dev/null and b/public/assets/images/info.png differ diff --git a/public/assets/imgs/aboutimgstory.png b/public/assets/imgs/aboutimgstory.png new file mode 100644 index 00000000..7c5c7be7 Binary files /dev/null and b/public/assets/imgs/aboutimgstory.png differ diff --git a/public/assets/imgs/img01.jpg b/public/assets/imgs/img01.jpg new file mode 100644 index 00000000..f027f645 Binary files /dev/null and b/public/assets/imgs/img01.jpg differ diff --git a/public/assets/imgs/img02.jpg b/public/assets/imgs/img02.jpg new file mode 100644 index 00000000..ef133349 Binary files /dev/null and b/public/assets/imgs/img02.jpg differ diff --git a/public/assets/imgs/img03.jpg b/public/assets/imgs/img03.jpg new file mode 100644 index 00000000..169d5645 Binary files /dev/null and b/public/assets/imgs/img03.jpg differ diff --git a/public/assets/imgs/img04.jpg b/public/assets/imgs/img04.jpg new file mode 100644 index 00000000..9f3c2d88 Binary files /dev/null and b/public/assets/imgs/img04.jpg differ diff --git a/resources/css/app.css b/resources/css/app.css index 9f2af5d9..ab6b09a3 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -14,20 +14,59 @@ background: #090909; color: #fff; } -.tempo-logo-link { - display: inline-flex; +.tempo-header-inner { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-rows: 72px; + column-gap: 0.5rem; 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; } +.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 { display: flex; + width: 100%; min-height: 44px; align-items: center; - gap: 0.5rem; - padding: 0.6rem 0.75rem; - border-radius: 0.5rem; - font-size: 0.875rem; + justify-content: space-between; + gap: 0.4rem; + padding: 0.5rem 0.75rem; + border-radius: 0.4rem; + font-size: 1rem; + line-height: 1.5; font-weight: 600; + white-space: nowrap; color: #eee; transition: color 0.2s, @@ -45,16 +84,85 @@ .tempo-nav-trigger[data-state='open'] > svg { transform: rotate(180deg); } -@media (max-width: 640px) { - .tempo-nav-trigger { - gap: 0.25rem; - padding-inline: 0.25rem; - font-size: 0.75rem; +.tempo-primary-navigation .tempo-nav-trigger > svg { + transform: none; + flex-shrink: 0; +} +.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; - height: 12px; +} +@media (min-width: 768px) { + .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 { display: flex; @@ -63,7 +171,7 @@ gap: 0.4rem; padding: 0.4rem; border-radius: 0.5rem; - font-size: 0.75rem; + font-size: 0.8125rem; color: #e2e8f0; } .tempo-utility-button:hover { @@ -85,11 +193,6 @@ transform: translateY(-2px); opacity: 0.8; } -@media (max-width: 360px) { - .tempo-header-social { - width: 26px; - } -} .tempo-login-button { display: inline-flex; min-height: 40px; @@ -97,9 +200,10 @@ gap: 0.5rem; border: 1px solid #ffffff50; border-radius: 0.4rem; - padding: 0.5rem 0.85rem; - font-size: 0.8rem; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; font-weight: 600; + white-space: nowrap; color: #fff; transition: background 0.2s, @@ -109,19 +213,23 @@ background: #ed1c24; border-color: #ed1c24; } +.tempo-login-button > span { + max-width: clamp(2rem, calc(100vw - 18rem), 6rem); +} .tempo-header-actions { - order: 2; + grid-area: 1 / 2; display: flex; + min-width: 0; align-items: center; - gap: 0.5rem; - margin-left: auto; } .tempo-interest-button { display: inline-flex; + grid-area: 2 / 2; + justify-self: end; align-items: center; justify-content: center; min-height: 40px; - padding: 0.5rem 0.75rem; + padding: 0.5rem 0.65rem; border: 1px solid #ed1c24; border-radius: 0.4rem; background: #ed1c24; @@ -135,56 +243,119 @@ background: #c9151c; } .tempo-panel-summary { - order: 4; + grid-area: 2 / 1; display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - width: 100%; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 0.1rem; min-height: 44px; - padding-block: 0.5rem; - border-top: 1px solid #ffffff1a; - font-size: 0.75rem; -} -.tempo-panel-summary a { + font-size: 0.8125rem; + line-height: 1.4; color: #fff; text-underline-offset: 3px; } -.tempo-panel-summary a:hover { +.tempo-panel-summary:hover { text-decoration: underline; } -.tempo-panel-summary span { +.tempo-panel-count { color: #cbd5e1; font-size: 0.6875rem; - white-space: nowrap; } -@media (max-width: 480px) { - .tempo-header-actions { - flex-direction: column; - align-items: flex-end; - padding-block: 0.65rem; +.tempo-header-toggle { + grid-area: 1 / 3; + display: inline-flex; + width: 44px; + 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) { - .tempo-header-actions { - order: 6; - margin-left: 0; + .tempo-header-inner { + display: flex; + min-height: 88px; + gap: 0.75rem; + padding-inline: 2rem; } - .tempo-header-inner .tempo-nav-trigger { - padding-inline: 0.5rem; - font-size: 0.8125rem; + .tempo-header-logo { + height: 64px; } - .tempo-panel-summary { - order: 0; - flex-direction: column; - align-items: flex-start; + .tempo-primary-navigation { + display: block; + flex: 1; + border: 0; + } + .tempo-primary-navigation > ul { justify-content: center; - gap: 0.1rem; - width: auto; - min-width: 102px; + gap: 0.25rem; + padding: 0; + } + .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-left: 1px solid #ffffff1a; - padding-left: 0.75rem; + border-inline: 1px solid #ffffff1a; + } + .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 { @@ -223,18 +394,18 @@ color: #fff; } .tempo-footer-heading { - margin-bottom: 1.4rem; - font-size: 0.75rem; + font-size: 0.875rem; + line-height: 1.25rem; font-weight: 600; - letter-spacing: 0.15em; + letter-spacing: 0.2em; text-transform: uppercase; } .tempo-footer-link { display: inline-flex; align-items: baseline; gap: 0.35rem; - font-size: 0.8125rem; - line-height: 1.5; + font-size: 0.875rem; + line-height: 1.25rem; color: #e2e8f0; transition: color 0.2s, @@ -252,7 +423,7 @@ .tempo-contact-link { display: flex; align-items: center; - gap: 0.7rem; + gap: 1rem; transition: color 0.2s; } .tempo-contact-link:hover { @@ -260,8 +431,8 @@ } .tempo-contact-icon { display: flex; - width: 36px; - height: 36px; + width: 40px; + height: 40px; flex-shrink: 0; align-items: center; justify-content: center; @@ -271,7 +442,7 @@ } .tempo-footer-social { display: flex; - width: 40px; + width: 44px; height: 44px; align-items: center; justify-content: center; @@ -283,6 +454,14 @@ border-color 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 { color: var(--social-color); border-color: var(--social-color); @@ -292,6 +471,13 @@ outline: 2px solid #fb7185; outline-offset: 4px; } +.tempo-source-page { + overflow-x: hidden; +} +.tempo-about-page { + --primary: #1f1f1f; + --color-primary: #1f1f1f; +} @media (prefers-reduced-motion: reduce) { .tempo-header *, .tempo-footer *, diff --git a/resources/js/components/institutional-source/AboutStory.tsx b/resources/js/components/institutional-source/AboutStory.tsx new file mode 100644 index 00000000..137a88ef --- /dev/null +++ b/resources/js/components/institutional-source/AboutStory.tsx @@ -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(0); + const countRef = useRef(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 ( + + {count} + {suffix} + + ); +}; + +export const AboutStory = () => { + const [isVisible, setIsVisible] = useState(false); + const [counted, setCounted] = useState(false); + const sectionRef = useRef(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 ( +
+
+
+ {/* Imagem */} +
+
+ Equipe Tempo { + e.currentTarget.src = + '/assets/images/info.png'; + }} + /> +
+ +
+
+
+
+ {counted ? ( + + ) : ( + '50+' + )} +
+
+ Anos de Experiência +
+
+
+
+ {counted ? ( + + ) : ( + '200+' + )} +
+
+ Projetos Realizados +
+
+
+
+
+ +
+
+ + {/* Texto */} +
+

+ + 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. + +

+

+ + 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. + +

+
+
+
+
+ ); +}; + +export default AboutStory; diff --git a/resources/js/components/institutional-source/BackButton.tsx b/resources/js/components/institutional-source/BackButton.tsx new file mode 100644 index 00000000..e3b8fdaf --- /dev/null +++ b/resources/js/components/institutional-source/BackButton.tsx @@ -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 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 = ({ + to, + label = Voltar, // 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 = () => ( + <> +