melhorias de home 1

This commit is contained in:
2026-10-23 10:36:34 +02:00
parent f80d30f478
commit e2b0fb4b2d
13 changed files with 1135 additions and 114 deletions
@@ -0,0 +1,355 @@
{
"date": "2026-10-23",
"decision": "Pedido expresso da Tempo: adaptar hero, imagens e pesquisa da referência, preservando a informação e os comportamentos equivalentes do V2.",
"sourceProject": "D:/Projects/Tempo_Investidor/investidor",
"targetProject": "D:/Projects/Tempo_Investidor/Investidor-V2",
"planAndAnalysis": "docs/investmoz/public-portal-sections.md#adequação-do-hero--23102026",
"implementation": [
"resources/js/components/portal-hero.tsx",
"resources/js/pages/PortalInvestidor.tsx"
],
"assets": [
{
"source": "D:\\Projects\\Tempo_Investidor\\investidor\\public\\images\\hero\\hero-1.png",
"original": "public\\images\\hero\\hero-1.png",
"sha256": "3c9246169025b9b1b4d1d10fb51c1f644123984bde56c6c2d9bf869d851024fc",
"bytes": 2315768,
"webpBytes": 94012,
"mobileBytes": 38804
},
{
"source": "D:\\Projects\\Tempo_Investidor\\investidor\\public\\images\\hero\\hero-3.jpg",
"original": "public\\images\\hero\\hero-3.jpg",
"sha256": "91377213b6286aca7ee4af90860a7dc7a75fa08a165ef7b3192081db43f92dff",
"bytes": 9875137,
"webpBytes": 140342,
"mobileBytes": 32998
}
],
"desktopBytesTotal": 234354,
"mobileBytesTotal": 71802,
"originalBytesTotal": 12190905,
"verification": {
"browser": {
"results": [
{
"width": 320,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 390,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 768,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 1440,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 1920,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 1440,
"reducedMotion": "no-preference",
"status": "passed"
}
],
"errors": [],
"requests": [
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=",
"/oportunidades",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique"
],
"databaseAccess": false
},
"browserScript": "tools/portal-hero-qa.mjs",
"screenshots": [
"outputs/portal-hero-20261023/after-1440-no-preference.png",
"outputs/portal-hero-20261023/after-1440-reduce.png",
"outputs/portal-hero-20261023/after-1440-second-image.png",
"outputs/portal-hero-20261023/after-1920-reduce.png",
"outputs/portal-hero-20261023/after-320-reduce.png",
"outputs/portal-hero-20261023/after-390-reduce.png",
"outputs/portal-hero-20261023/after-768-reduce.png",
"outputs/portal-hero-20261023/before-1440-reduce.png",
"outputs/portal-hero-20261023/before-390-reduce.png"
],
"additionalCases": [
"pesquisa por Enter e botão",
"ordem do teclado: campo → botão",
"query acentuada e pontuação preservadas",
"remoção de espaços exteriores",
"pesquisa vazia abre catálogo",
"CTA abre oportunidades",
"API indisponível mantém indicadores indeterminados",
"contagens zero preservadas",
"duas imagens carregadas",
"selecção manual",
"pausa e retoma explícitas",
"foco pausa rotação",
"rotação automática após perda de foco",
"movimento reduzido",
"entrada documental e cabeçalho preservados"
],
"existingFrontendTests": {
"command": "node --test tests/frontend/portal-api.test.mjs tests/frontend/portal-navigation.test.mjs",
"passed": 20
},
"typecheck": {
"command": "npm run types:check",
"exitCode": 0
},
"lint": {
"files": [
"resources/js/components/portal-hero.tsx",
"resources/js/pages/PortalInvestidor.tsx",
"tools/portal-hero-qa.mjs"
],
"exitCode": 0
},
"qaBuild": {
"command": "INVESTMOZ_QA_BUILD=1 npm run build",
"exitCode": 0
},
"localBuild": {
"command": "npm run build",
"exitCode": 0,
"pageAsset": "assets/PortalInvestidor-MsUeKoJQ.js"
}
},
"matrix": {
"file": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
"referencesAdded": [
"MASTER-9dee08d0-0016",
"MASTER-efd550fd-0104",
"MASTER-55050f60-0001"
],
"rowCount": 3422,
"criteriaAndStatesPreserved": true
},
"scope": {
"appliedToLocalCodeAndBuild": true,
"activeDatabaseChanged": false,
"migrationsRun": false,
"externalDeploymentPerformed": false,
"backendSearchReimplemented": false
},
"limits": "Verificação visual com dados sintéticos; pesquisa continua limitada ao catálogo de oportunidades. Os critérios mais amplos da matriz permanecem no estado anterior.",
"latestRevision": {
"id": "hero-no-controls-20261023",
"date": "2026-10-23",
"decision": "Pedido expresso da Tempo: remover do hero as duas bolinhas e o botão de pausa das imagens.",
"implementation": "Removidos grupo de controlos, ícones Pause/Play e estado de pausa manual. Preservadas troca automática, escrita dinâmica, pesquisa e preferência de movimento reduzido.",
"browser": {
"results": [
{
"width": 320,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 390,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 768,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 1440,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 1920,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 390,
"reducedMotion": "no-preference",
"status": "passed"
},
{
"width": 1440,
"reducedMotion": "no-preference",
"status": "passed"
}
],
"errors": [],
"requests": [
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=",
"/oportunidades",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=",
"/oportunidades",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique"
],
"databaseAccess": false
},
"screenshots": [
"outputs/portal-hero-no-controls-20261023/after-1440-no-preference.png",
"outputs/portal-hero-no-controls-20261023/after-1440-reduce.png",
"outputs/portal-hero-no-controls-20261023/after-1920-reduce.png",
"outputs/portal-hero-no-controls-20261023/after-320-reduce.png",
"outputs/portal-hero-no-controls-20261023/after-390-no-preference.png",
"outputs/portal-hero-no-controls-20261023/after-390-reduce.png",
"outputs/portal-hero-no-controls-20261023/after-768-reduce.png",
"outputs/portal-hero-no-controls-20261023/second-image-1440.png",
"outputs/portal-hero-no-controls-20261023/second-image-390.png",
"outputs/portal-hero-no-controls-20261023/typed-1440.png",
"outputs/portal-hero-no-controls-20261023/typed-390.png"
],
"checks": {
"lintExitCode": 0,
"buildExitCode": 0,
"pageAsset": "assets/PortalInvestidor-T0B6aoV_.js"
},
"scope": {
"appliedLocally": true,
"databaseAccess": false,
"externalDeployment": false
}
},
"revisionHistory": [
{
"id": "hero-typing-20261023",
"date": "2026-10-23",
"decision": "Pedido expresso da Tempo: título e frase dinâmicos como na referência; remover Termos de uso do hero.",
"plan": [
"Analisar a sequência de escrita da referência.",
"Animar o título e depois a frase, mantendo os textos do V2 e reservando a dimensão final.",
"Reiniciar a escrita na troca de imagem e integrar pausa/retoma e preferência de movimento reduzido.",
"Remover o link e o ícone jurídico do hero.",
"Demonstrar comportamento em computador e telemóvel e compilar o pacote local."
],
"files": [
"resources/js/components/portal-hero.tsx",
"resources/css/app.css",
"tools/portal-hero-qa.mjs"
],
"browser": {
"results": [
{
"width": 320,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 390,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 768,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 1440,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 1920,
"reducedMotion": "reduce",
"status": "passed"
},
{
"width": 390,
"reducedMotion": "no-preference",
"status": "passed"
},
{
"width": 1440,
"reducedMotion": "no-preference",
"status": "passed"
}
],
"errors": [],
"requests": [
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=",
"/oportunidades",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
"/oportunidades?q=",
"/oportunidades",
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique"
],
"databaseAccess": false
},
"screenshots": [
"outputs/portal-hero-typing-20261023/after-1440-no-preference.png",
"outputs/portal-hero-typing-20261023/after-1440-reduce.png",
"outputs/portal-hero-typing-20261023/after-1440-second-image.png",
"outputs/portal-hero-typing-20261023/after-1920-reduce.png",
"outputs/portal-hero-typing-20261023/after-320-reduce.png",
"outputs/portal-hero-typing-20261023/after-390-no-preference.png",
"outputs/portal-hero-typing-20261023/after-390-reduce.png",
"outputs/portal-hero-typing-20261023/after-768-reduce.png",
"outputs/portal-hero-typing-20261023/typed-1440.png",
"outputs/portal-hero-typing-20261023/typed-390.png",
"outputs/portal-hero-typing-20261023/typing-1440.png",
"outputs/portal-hero-typing-20261023/typing-390.png"
],
"demonstrated": [
"escrita progressiva",
"título antes da frase",
"texto completo no modo de movimento reduzido",
"nome acessível completo durante a escrita",
"pesquisa sem deslocamento durante a escrita",
"reinício na selecção manual e automática de imagem",
"pausa e retoma da escrita",
"cursor com pausa",
"Termos de uso ausente do hero",
"pesquisa, teclado, CTA, API indisponível e contagens zero preservados"
],
"checks": {
"types": {
"command": "npm run types:check",
"exitCode": 0
},
"lint": {
"command": "vp lint resources/js/components/portal-hero.tsx tools/portal-hero-qa.mjs",
"exitCode": 0
},
"build": {
"command": "npm run build",
"exitCode": 0,
"pageAsset": "assets/PortalInvestidor-BbVHeTeI.js",
"log": "outputs/portal-hero-typing-20261023/build-local.log"
}
},
"scope": {
"appliedLocally": true,
"databaseAccess": false,
"externalDeployment": false
}
}
]
}
+36
View File
@@ -48,3 +48,39 @@ Edge headless: 11 páginas públicas/autenticação verificadas a 390×844, com
Página inicial também verificada a 1440×1000. Cenário fiscal exercitado no navegador: base 1.000 × taxa 10% × (1 − redução 50%) = 50. Resultados em `public-portal-browser-validation.json`.
Antes da migration local foi criado e restaurado o backup de 74 tabelas em `storage/app/private/portal-services-backup-20260927-090641/`. O backup completo anterior permanece em `C:/laragon/backups/portal-investidor-20260927-101224/` e representa o estado anterior a esta entrega.
## Adequação do hero — 23/10/2026
Origem: pedido expresso da Tempo nesta sessão para aproveitar o hero, as imagens e a barra de pesquisa de `D:/Projects/Tempo_Investidor/investidor`, usando a informação existente no V2. Aplicam-se as orientações permanentes do `AGENTS.md`, incluindo a distinção entre decisão da Tempo e escolha técnica, a preservação de dados e a separação de QA e activo.
Análise prévia: a página de entrada real do V2 é `PortalInvestidor.tsx`, servida por `PortalServicesController::home`. A referência é `domains/investor-portal/components/homepage/homepage-hero.tsx`, montada em `InstitutionalHomepage`. A referência usa Inertia 2, Framer Motion, tradução e navegação diferentes; o V2 usa Inertia 3 e pesquisa de oportunidades. Copiar essas dependências e `/pesquisa` ou `/aviso-legal` directamente criaria incompatibilidades e destinos inexistentes.
Plano executado:
1. Registar a apresentação anterior em computador e telemóvel, com os componentes reais e dados sintéticos.
2. Copiar os dois originais efectivamente usados pela referência (`hero-1.png` e `hero-3.jpg`), conferir SHA-256 e gerar derivados WebP para computador e telemóvel. `hero-2.png` não faz parte da rotação da referência.
3. Extrair `components/portal-hero.tsx` e aplicar fotografias em rotação, texto centrado e pesquisa branca com botão vermelho. Preservar os textos do V2, contagens da API, sectores, documentos, apoio, cabeçalho e rodapé.
4. Manter a pesquisa de oportunidades em `/oportunidades?q=`, incluindo pesquisa vazia e caracteres acentuados. Conservar “Consultar oportunidades” e “Planear finanças”. A primeira iteração acrescentou o acesso jurídico `/termos-de-uso` ao hero; foi removido na revisão expressamente pedida pela Tempo abaixo.
5. Verificar apresentação, teclado, imagens, rotação, pausa, movimento reduzido, falha da API, contagens zero, TypeScript, lint e compilação. Compilar primeiro o pacote QA e depois os assets locais em `public/build`.
Escolhas técnicas desta adaptação: React e transições CSS existentes, sem novas dependências; rotação de oito segundos, pausa por controlo explícito, foco, ponteiro ou separador oculto; apresentação estática quando o utilizador pede movimento reduzido. A primeira iteração apresentou o texto estático; a revisão abaixo introduz a escrita dinâmica pedida pela Tempo. O campo indica pesquisa de oportunidades e conserva exemplos que o catálogo suporta; esta alteração não implementa uma pesquisa global de legislação/licenciamento.
Evidência reproduzível: `tools/portal-hero-qa.mjs`, com Vite local e Playwright configurável por `HERO_QA_PLAYWRIGHT`/`HERO_QA_CHROMIUM`. Fixtures e capturas em `outputs/portal-hero-20261023/`; síntese persistente em `public-portal-hero-validation.json`. Os testes de interface interceptam a API e a navegação Inertia, sem acesso a Laravel, base de dados ou serviços externos. A compilação local aplica os assets desta pasta; não representa publicação noutro servidor.
Referências na matriz existente: `MASTER-efd550fd-0104` (composição da página), `MASTER-9dee08d0-0016` (contexto da pesquisa) e `MASTER-55050f60-0001` (entrada documental preservada). Esta prova cobre a adaptação visual e os percursos exercitados; não encerra os critérios funcionais mais amplos dessas linhas.
### Revisão: escrita dinâmica e remoção do acesso jurídico no hero
Decisão expressa da Tempo na continuação da sessão de 23/10/2026: animar “INVISTA EM MOÇAMBIQUE” e “Transforme informação em decisões e ideias em negócios reais.” como na página de referência, e remover “Termos de uso” do hero. A anterior apresentação estática e a inclusão desse link eram escolhas de implementação, agora substituídas pelo pedido do proprietário.
Análise e plano executado: reproduzir a sequência da referência (espera inicial de 250 ms, título a 50 ms por carácter, intervalo de 150 ms e frase a 28 ms por carácter); reiniciar ao trocar de imagem; acrescentar o cursor intermitente; reservar a dimensão final de ambas as linhas para manter a pesquisa imóvel; remover o link e o ícone jurídico do hero; verificar em computador e telemóvel e compilar o pacote local.
Implementação em `portal-hero.tsx`, com React existente e CSS do cursor em `app.css`, sem dependências novas. A pausa explícita suspende também a escrita, retomando do mesmo ponto. Quando o separador fica oculto a escrita pára; a preferência de movimento reduzido apresenta os dois textos completos. Leitores de ecrã recebem o texto completo uma única vez, independentemente do número de caracteres visíveis. Os temporizadores são limpos ao mudar de imagem ou desmontar o componente.
O script `tools/portal-hero-qa.mjs` demonstra a escrita progressiva, a ordem título→frase, o reinício manual/automático, a pausa/retoma, a posição estável da pesquisa, o modo sem movimento e a ausência de “Termos de uso” no hero. As capturas e resultados desta revisão ficam em `outputs/portal-hero-typing-20261023/`, preservando a evidência anterior. Síntese e compilação final registadas na revisão de `public-portal-hero-validation.json`; referência acrescentada à linha existente `MASTER-efd550fd-0104`, sem encerrar o seu critério mais amplo.
### Revisão: remoção dos controlos visíveis das imagens
Pedido expresso da Tempo na continuação de 23/10/2026: remover do hero o grupo com o botão de pausa e as duas bolinhas de selecção de imagem. Removidos o grupo, os ícones e o estado de pausa manual. A troca automática de imagens e a escrita dinâmica permanecem; conservam-se a pausa da rotação por foco/ponteiro, a suspensão em separador oculto e a preferência de movimento reduzido. Esta decisão substitui os controlos visíveis introduzidos como escolha de implementação nas revisões anteriores.
O script existente de interface foi adaptado para demonstrar a ausência dos controlos e a continuação da rotação automática, escrita e pesquisa, com capturas em `outputs/portal-hero-no-controls-20261023/`. Evidência e pacote local registados em `public-portal-hero-validation.json`, mantendo o histórico das revisões e os estados dos requisitos da matriz.
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

+16
View File
@@ -9,6 +9,22 @@
@import './tempo-header.css';
@keyframes portal-hero-caret {
50% {
opacity: 0;
}
}
.portal-hero-caret {
animation: portal-hero-caret 0.8s step-end infinite;
}
@media (prefers-reduced-motion: reduce) {
.portal-hero-caret {
animation: none;
}
}
.tempo-dropdown {
z-index: 60;
border: 1px solid #e2e8f0;
+305
View File
@@ -0,0 +1,305 @@
import { ArrowRight, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import type { HomeResponse } from '@/lib/portal-api';
const slides = [
{
image: '/images/hero/hero-1.webp',
mobile: '/images/hero/hero-1-mobile.webp',
},
{
image: '/images/hero/hero-3.webp',
mobile: '/images/hero/hero-3-mobile.webp',
},
];
const heroTitle = 'INVISTA EM MOÇAMBIQUE';
const heroSubtitle =
'Transforme informação em decisões e ideias em negócios reais.';
const typingStart = 250;
const titleStep = 50;
const subtitleStep = 28;
const subtitleStart = typingStart + heroTitle.length * titleStep + 150;
const typingDuration = subtitleStart + heroSubtitle.length * subtitleStep;
function TypewriterLine({
text,
visibleText,
caret = false,
running,
}: {
text: string;
visibleText: string;
caret?: boolean;
running: boolean;
}) {
return (
<span className="grid">
<span className="sr-only">{text}</span>
{/* Reserve the final wrapping before the first character appears. */}
<span
aria-hidden="true"
className="invisible col-start-1 row-start-1"
>
{text}
{caret && <span className="ml-1 inline-block w-0 border-l-2" />}
</span>
<span
aria-hidden="true"
data-hero-typed
className="col-start-1 row-start-1"
>
{visibleText}
{caret && (
<span
className="portal-hero-caret ml-1 inline-block h-[0.85em] w-0 border-l-2 border-white"
style={{
animationPlayState: running ? 'running' : 'paused',
}}
/>
)}
</span>
</span>
);
}
function HeroAnimatedCopy({
reducedMotion,
running,
}: {
reducedMotion: boolean;
running: boolean;
}) {
const [elapsed, setElapsed] = useState(0);
const complete = elapsed >= typingDuration;
useEffect(() => {
if (reducedMotion || !running || complete) return;
const timer = window.setInterval(() => {
setElapsed((current) => Math.min(current + 25, typingDuration));
}, 25);
return () => window.clearInterval(timer);
}, [reducedMotion, running, complete]);
const titleLength = Math.max(
0,
Math.floor((elapsed - typingStart) / titleStep),
);
const subtitleLength = Math.max(
0,
Math.floor((elapsed - subtitleStart) / subtitleStep),
);
return (
<>
<h1
id="portal-hero-title"
className="max-w-5xl text-3xl leading-tight font-semibold tracking-tight text-white sm:text-5xl lg:text-6xl"
>
<TypewriterLine
text={heroTitle}
visibleText={
reducedMotion
? heroTitle
: heroTitle.slice(0, titleLength)
}
caret={!reducedMotion}
running={running}
/>
</h1>
<p className="mt-5 max-w-3xl text-lg leading-relaxed font-medium text-white sm:text-2xl">
<TypewriterLine
text={heroSubtitle}
visibleText={
reducedMotion
? heroSubtitle
: heroSubtitle.slice(0, subtitleLength)
}
running={running}
/>
</p>
</>
);
}
type PortalHeroProps = {
query: string;
setQuery: (value: string) => void;
onSearch: (event: FormEvent<HTMLFormElement>) => void;
onNavigate: (view: 'opportunities') => void;
stats?: HomeResponse['data']['stats'];
};
/** Reference visuals adapted to the V2 data and navigation contracts. */
export default function PortalHero({
query,
setQuery,
onSearch,
onNavigate,
stats,
}: PortalHeroProps) {
const [currentSlide, setCurrentSlide] = useState(0);
const [interacting, setInteracting] = useState(false);
const [hovering, setHovering] = useState(false);
const [pageVisible, setPageVisible] = useState(true);
// Start static for SSR and until the browser's motion preference is known.
const [reducedMotion, setReducedMotion] = useState(true);
useEffect(() => {
const preference = window.matchMedia(
'(prefers-reduced-motion: reduce)',
);
const updateMotion = () => setReducedMotion(preference.matches);
const updateVisibility = () => setPageVisible(!document.hidden);
updateMotion();
updateVisibility();
preference.addEventListener('change', updateMotion);
document.addEventListener('visibilitychange', updateVisibility);
return () => {
preference.removeEventListener('change', updateMotion);
document.removeEventListener('visibilitychange', updateVisibility);
};
}, []);
useEffect(() => {
if (reducedMotion || interacting || hovering || !pageVisible) return;
const timer = window.setInterval(() => {
setCurrentSlide((current) => (current + 1) % slides.length);
}, 8000);
return () => window.clearInterval(timer);
}, [reducedMotion, interacting, hovering, pageVisible]);
return (
<section
aria-labelledby="portal-hero-title"
className="portal-hero relative isolate overflow-hidden bg-[#101820] text-white"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onFocusCapture={() => setInteracting(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget))
setInteracting(false);
}}
>
<div className="absolute inset-0 -z-20" aria-hidden="true">
{slides.map((slide, index) => (
<picture key={slide.image}>
<source
media="(max-width: 640px)"
srcSet={slide.mobile}
/>
<img
src={slide.image}
alt=""
width={1920}
height={1280}
fetchPriority={index === 0 ? 'high' : 'low'}
decoding="async"
className={`portal-hero-image absolute inset-0 h-full w-full object-cover transition-opacity duration-1500 motion-reduce:transition-none ${currentSlide === index ? 'opacity-100' : 'opacity-0'}`}
/>
</picture>
))}
</div>
<div
className="absolute inset-0 -z-10 bg-linear-to-r from-black/75 via-black/65 to-black/60"
aria-hidden="true"
/>
<div
className="absolute inset-x-0 bottom-0 -z-10 h-48 bg-linear-to-t from-black/65 to-transparent"
aria-hidden="true"
/>
<div className="mx-auto flex min-h-[88svh] max-w-6xl flex-col items-center justify-center px-5 py-12 text-center sm:px-6 sm:py-16 lg:py-20">
<HeroAnimatedCopy
key={currentSlide}
reducedMotion={reducedMotion}
running={pageVisible}
/>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-white/90 sm:text-base">
Oportunidades reais de investimento em Moçambique,
organizadas por sector.
</p>
<p
id="portal-hero-description"
className="mt-2 max-w-2xl text-sm leading-relaxed text-white/80 sm:text-base"
>
Consulte projectos por província e estágio de maturidade,
compreenda os requisitos legais e fiscais, e manifeste
interesse directamente junto da entidade promotora.
</p>
<form
onSubmit={onSearch}
role="search"
aria-label="Pesquisar oportunidades"
className="mt-7 w-full max-w-4xl"
>
<label
htmlFor="portal-opportunity-search"
className="mb-2 block text-left text-sm font-medium text-white/90"
>
Pesquisa rápida de oportunidades
</label>
<div className="flex min-w-0 flex-col gap-2 rounded-2xl border border-white/30 bg-white p-1.5 shadow-2xl shadow-black/20 focus-within:ring-2 focus-within:ring-white focus-within:ring-offset-4 focus-within:ring-offset-[#101820] min-[400px]:flex-row">
<input
id="portal-opportunity-search"
type="search"
name="q"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Ex.: agronegócio, Nampula, energia..."
className="h-12 w-full min-w-0 flex-1 rounded-xl border-0 bg-white px-4 text-base text-slate-900 outline-none placeholder:text-slate-500"
/>
<button
type="submit"
className="inline-flex min-h-12 shrink-0 items-center justify-center gap-2 rounded-xl bg-[#ed1c24] px-5 text-sm font-semibold text-white transition-colors hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
>
<Search size={18} aria-hidden="true" />
Pesquisar
</button>
</div>
</form>
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<button
onClick={() => onNavigate('opportunities')}
className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-white px-5 py-3 text-sm font-semibold text-[#101820] transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
>
Consultar oportunidades{' '}
<ArrowRight size={16} aria-hidden="true" />
</button>
<a
href="/simulador-fiscal"
className="inline-flex min-h-11 items-center rounded-xl border border-white/50 bg-black/15 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
>
Planear finanças
</a>
</div>
<dl
aria-label="Indicadores do portal"
className="mt-9 grid w-full max-w-lg grid-cols-3 gap-3 border-t border-white/25 pt-5"
>
{[
['oportunidades', stats?.opportunities],
['sectores', stats?.sectors],
['províncias', stats?.provinces],
].map(([label, value]) => (
<div
key={label}
className="flex flex-col-reverse gap-1"
>
<dt className="text-xs text-white/85 sm:text-sm">
{label}
</dt>
<dd className="text-2xl font-semibold text-white tabular-nums sm:text-3xl">
{value ?? '—'}
</dd>
</div>
))}
</dl>
</div>
</section>
);
}
+3 -111
View File
@@ -1,3 +1,4 @@
import PortalHero from '@/components/portal-hero';
import SearchState from '@/components/search-state';
import { useInterestDraft } from '@/hooks/use-interest-draft';
import { createContext, useContext, useRef, useState } from 'react';
@@ -30,14 +31,12 @@ import type {
Sector,
} from '@/lib/portal-api';
import {
Search,
Leaf,
Zap,
Hotel,
Truck,
Waves,
Banknote,
ArrowRight,
ArrowLeft,
MapPin,
CheckCircle2,
@@ -137,113 +136,6 @@ function TopBar({
);
}
function Hero({
query,
setQuery,
onSearch,
onNavigate,
stats,
}: {
query: string;
setQuery: (value: string) => void;
onSearch: (event: FormEvent<HTMLFormElement>) => void;
onNavigate: (view: View) => void;
stats?: HomeResponse['data']['stats'];
}) {
return (
<section className="border-b border-slate-200 bg-[#0B2038] text-white">
<div className="mx-auto grid max-w-6xl gap-10 px-5 py-16 md:py-24">
<div className="text-center">
<p className="text-4xl leading-tight font-black tracking-tight text-emerald-300 uppercase md:text-6xl">
INVISTA EM MOÇAMBIQUE
</p>
<h1 className="mx-auto mt-4 max-w-4xl text-4xl leading-[1.05] font-black tracking-tight md:text-5xl">
Transforme informação em decisões
<span className="text-amber-400">
{' '}
e ideias em negócios reais.
</span>
</h1>
<p className="mx-auto mt-6 max-w-3xl text-lg font-semibold text-slate-200">
Oportunidades reais de investimento em Moçambique,
organizadas por sector.
</p>
<p className="mx-auto mt-5 max-w-2xl text-slate-300">
Consulte projectos por província e estágio de
maturidade, compreenda os requisitos legais e fiscais, e
manifeste interesse directamente junto da entidade
promotora.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<button
onClick={() => onNavigate('opportunities')}
className="inline-flex items-center gap-2 rounded-lg bg-amber-400 px-5 py-3 font-bold text-[#0B2038] hover:bg-amber-300"
>
Consultar oportunidades <ArrowRight size={16} />
</button>
<a
href="/simulador-fiscal"
className="rounded-lg border border-emerald-300 px-5 py-3 font-semibold text-emerald-100 hover:bg-emerald-900"
>
Planear finanças
</a>
</div>
</div>
<form
onSubmit={onSearch}
className="h-fit rounded-2xl border border-white/10 bg-white/10 p-5"
>
<label
htmlFor="portal-opportunity-search"
className="mb-2 block text-sm font-semibold text-slate-200"
>
Pesquisa rápida
</label>
<div className="flex gap-2">
<input
id="portal-opportunity-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Ex.: agronegócio, Nampula, energia..."
className="w-full rounded-lg bg-white px-3 py-2.5 text-sm text-slate-900 ring-2 ring-transparent outline-none placeholder:text-slate-500 focus:ring-amber-400"
/>
<button
type="submit"
aria-label="Pesquisar oportunidades"
className="rounded-lg bg-amber-400 px-4 text-[#0B2038]"
>
<Search size={18} />
</button>
</div>
<div className="mt-4 flex flex-wrap gap-3 border-t border-white/10 pt-4 text-center text-xs text-slate-300">
<div>
<p className="text-xl font-black text-white">
{stats?.opportunities ?? '—'}
</p>
oportunidades
</div>
<div className="ml-auto">
<p className="text-xl font-black text-white">
{stats?.sectors ?? '—'}
</p>
sectores
</div>
<div className="ml-auto">
<p className="text-xl font-black text-white">
{stats?.provinces ?? '—'}
</p>
províncias
</div>
</div>
</form>
</div>
</section>
);
}
function SectorGrid({
onSelectSector,
}: {
@@ -1120,7 +1012,7 @@ export default function PortalInvestidorApp({
function handleHeroSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
router.get('/oportunidades', { q: heroQuery });
router.get('/oportunidades', { q: heroQuery.trim() });
}
function handleSelectSector(slug: string) {
@@ -1145,7 +1037,7 @@ export default function PortalInvestidorApp({
<main>
{view === 'home' && (
<>
<Hero
<PortalHero
query={heroQuery}
setQuery={setHeroQuery}
onSearch={handleHeroSearch}
+417
View File
@@ -0,0 +1,417 @@
// Real portal components in Vite, synthetic public data, no Laravel/database access.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { chromium } = require(
process.env.HERO_QA_PLAYWRIGHT ||
'C:/Users/jorge/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright',
);
const root = process.cwd();
const base = process.env.HERO_QA_VITE || 'http://127.0.0.1:5173';
assert.equal(new URL(base).hostname, '127.0.0.1');
const dir = path.join(root, 'outputs/portal-hero-no-controls-20261023');
const before = process.argv.includes('--before');
fs.mkdirSync(dir, { recursive: true });
if (before) assert.ok(!fs.existsSync(path.join(dir, 'before-results.json')));
const fixtureProps = {
auth: { user: null, canManagePortal: false },
essentialResources: [],
countries: [],
};
fs.writeFileSync(
path.join(dir, 'fixture.html'),
`<!doctype html>
<html lang="pt"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Portal QA</title></head>
<body><div id="app"></div><script type="module" src="/outputs/portal-hero-no-controls-20261023/fixture.tsx"></script></body></html>`,
);
fs.writeFileSync(
path.join(dir, 'fixture.tsx'),
`
import React from 'react';
import { createRoot } from 'react-dom/client';
import { createInertiaApp } from '@inertiajs/react';
import Portal from '../../resources/js/pages/PortalInvestidor';
import PortalNavigation from '../../resources/js/layouts/portal-navigation';
import '../../resources/css/app.css';
await createInertiaApp({page:{component:'PortalInvestidor',props:${JSON.stringify(fixtureProps)},url:'/',version:null},
resolve:()=>Portal,layout:()=>PortalNavigation,setup({el,App,props}){createRoot(el).render(<App {...props}/>);}});
`,
);
const browser = await chromium.launch({
headless: true,
...(process.env.HERO_QA_CHROMIUM
? { executablePath: process.env.HERO_QA_CHROMIUM }
: {}),
});
const results = [];
const errors = [];
const requests = [];
let activePage;
try {
const cases = before
? [
[1440, 'reduce'],
[390, 'reduce'],
]
: [
[320, 'reduce'],
[390, 'reduce'],
[768, 'reduce'],
[1440, 'reduce'],
[1920, 'reduce'],
[390, 'no-preference'],
[1440, 'no-preference'],
];
for (const [width, reducedMotion] of cases) {
const context = await browser.newContext({
viewport: { width, height: 1000 },
reducedMotion,
});
await context.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== base) return route.abort();
if (url.pathname.startsWith('/images/')) {
const file = path.resolve(root, 'public', `.${url.pathname}`);
assert.ok(
file.startsWith(
path.join(root, 'public', 'images') + path.sep,
),
);
return fs.existsSync(file)
? route.fulfill({ path: file })
: route.fulfill({ status: 404 });
}
if (url.pathname === '/api/v1/home')
return route.fulfill({
json: {
success: true,
data: {
sectors: [
{
slug: 'energia',
name: 'Energia',
shortDescription: 'Energia de demonstração',
opportunitiesCount: 6,
},
],
stats: {
opportunities: 6,
sectors: 1,
provinces: 3,
},
featuredOpportunities: [],
},
},
});
if (url.pathname.startsWith('/api/'))
return route.fulfill({
json: { data: [], total: 0, current_page: 1, last_page: 1 },
});
if (route.request().headers()['x-inertia']) {
requests.push(url.pathname + url.search);
return route.fulfill({
headers: { 'X-Inertia': 'true' },
json: {
component: 'PortalInvestidor',
props: {
...fixtureProps,
initialView: 'opportunities',
initialQuery: url.searchParams.get('q') || '',
},
url: url.pathname + url.search,
version: null,
},
});
}
return route.continue();
});
const page = await context.newPage();
activePage = page;
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(
`${base}/outputs/portal-hero-no-controls-20261023/fixture.html`,
{
waitUntil: 'networkidle',
timeout: 60000,
},
);
const search = page.locator('#portal-opportunity-search');
await search.waitFor();
await page.getByText('6', { exact: true }).first().waitFor();
assert.equal(await page.locator('h1').count(), 1);
assert.equal(await page.locator('.tempo-header').count(), 1);
await page
.locator('.portal-hero img')
.evaluateAll((images) =>
Promise.all(images.map((image) => image.decode())),
);
await page.screenshot({
path: path.join(
dir,
`${before ? 'before' : 'after'}-${width}-${reducedMotion}.png`,
),
fullPage: false,
});
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth > innerWidth,
),
false,
`overflow at ${width}`,
);
assert.ok(
await page
.getByRole('link', {
name: /^Documentos e Formulários/,
})
.count(),
);
if (!before) {
const hero = page.getByRole('region', {
name: 'INVISTA EM MOÇAMBIQUE',
});
assert.equal(
await hero
.getByRole('group', { name: 'Imagens de apresentação' })
.count(),
0,
);
assert.equal(
await hero
.getByRole('button', {
name: /Mostrar imagem|Pausar apresentação|Retomar apresentação/,
})
.count(),
0,
);
assert.equal(
await hero.getByRole('link', { name: 'Termos de uso' }).count(),
0,
);
const images = hero.locator('img');
assert.equal(await images.count(), 2);
assert.ok(
await images.evaluateAll((nodes) =>
nodes.every(
(image) => image.complete && image.naturalWidth > 0,
),
),
);
const fullTitle = 'INVISTA EM MOÇAMBIQUE';
const fullSubtitle =
'Transforme informação em decisões e ideias em negócios reais.';
const title = hero.locator('h1 [data-hero-typed]');
const subtitle = hero.locator('p [data-hero-typed]');
if (reducedMotion === 'no-preference') {
await search.focus();
const positionBefore = await search.boundingBox();
await page.waitForFunction(
({ heading, tagline }) =>
document.querySelector('h1 [data-hero-typed]')
.textContent === heading &&
document.querySelector('p [data-hero-typed]')
.textContent === tagline,
{ heading: fullTitle, tagline: fullSubtitle },
);
const positionAfter = await search.boundingBox();
assert.ok(
Math.abs(positionAfter.y - positionBefore.y) < 0.5,
'Search moved during typing',
);
await page.screenshot({
path: path.join(dir, 'typed-' + width + '.png'),
});
const active = await images.evaluateAll((nodes) =>
nodes.findIndex((node) =>
node.classList.contains('opacity-100'),
),
);
await page.waitForTimeout(8300);
assert.equal(
await images.evaluateAll((nodes) =>
nodes.findIndex((node) =>
node.classList.contains('opacity-100'),
),
),
active,
);
await search.evaluate((element) => element.blur());
await page.mouse.move(0, 0);
await page.waitForFunction(
(previous) =>
[
...document.querySelectorAll('.portal-hero img'),
].findIndex((image) =>
image.classList.contains('opacity-100'),
) !== previous,
active,
{ timeout: 12000 },
);
assert.ok(
(await title.textContent()).length < fullTitle.length,
'Automatic slide did not replay typing',
);
await page.waitForFunction(
({ heading, tagline }) =>
document.querySelector('h1 [data-hero-typed]')
.textContent === heading &&
document.querySelector('p [data-hero-typed]')
.textContent === tagline,
{ heading: fullTitle, tagline: fullSubtitle },
);
await page.screenshot({
path: path.join(dir, 'second-image-' + width + '.png'),
});
} else {
assert.equal(await title.textContent(), fullTitle);
assert.equal(await subtitle.textContent(), fullSubtitle);
assert.equal(
await hero.locator('.portal-hero-caret').count(),
0,
);
}
}
// Exercise the real parent handler and Inertia request with accents and punctuation.
await search.fill(' energia & Nampula / Moçambique ');
if (!before && width === 390) {
await search.focus();
await page.keyboard.press('Tab');
assert.equal(
await page
.getByRole('button', { name: 'Pesquisar', exact: true })
.evaluate((el) => el === document.activeElement),
true,
);
await page.keyboard.press('Enter');
} else if (!before && width === 768) {
await page
.getByRole('button', { name: 'Pesquisar', exact: true })
.click();
} else {
await search.press('Enter');
}
await page
.getByRole('heading', {
name: 'Oportunidades de investimento',
exact: true,
})
.waitFor();
const last = new URL(requests.at(-1), base);
assert.equal(last.pathname, '/oportunidades');
assert.equal(
last.searchParams.get('q'),
before
? ' energia & Nampula / Moçambique '
: 'energia & Nampula / Moçambique',
);
if (!before && width === 390) {
const home = `${base}/outputs/portal-hero-no-controls-20261023/fixture.html`;
await page.goto(home, { waitUntil: 'networkidle' });
await page.locator('#portal-opportunity-search').fill(' ');
await page
.getByRole('button', { name: 'Pesquisar', exact: true })
.click();
await page
.getByRole('heading', {
name: 'Oportunidades de investimento',
exact: true,
})
.waitFor();
assert.equal(
new URL(requests.at(-1), base).searchParams.get('q'),
'',
);
await page.goto(home, { waitUntil: 'networkidle' });
await page
.getByRole('button', {
name: 'Consultar oportunidades',
exact: true,
})
.click();
await page
.getByRole('heading', {
name: 'Oportunidades de investimento',
exact: true,
})
.waitFor();
assert.equal(
new URL(requests.at(-1), base).pathname,
'/oportunidades',
);
await context.route('**/api/v1/home', (route) =>
route.fulfill({
status: 500,
json: { message: 'Synthetic failure' },
}),
);
await page.goto(home, { waitUntil: 'networkidle' });
await page.getByRole('alert').waitFor();
assert.deepEqual(
await page
.locator('dl[aria-label="Indicadores do portal"] dd')
.allTextContents(),
['—', '—', '—'],
);
await context.route('**/api/v1/home', (route) =>
route.fulfill({
json: {
success: true,
data: {
sectors: [],
featuredOpportunities: [],
stats: {
opportunities: 0,
sectors: 0,
provinces: 0,
},
},
},
}),
);
await page.goto(home, { waitUntil: 'networkidle' });
assert.deepEqual(
await page
.locator('dl[aria-label="Indicadores do portal"] dd')
.allTextContents(),
['0', '0', '0'],
);
}
results.push({ width, reducedMotion, status: 'passed' });
await context.close();
}
assert.deepEqual(errors, []);
fs.writeFileSync(
path.join(dir, before ? 'before-results.json' : 'qa-results.json'),
JSON.stringify(
{ results, errors, requests, databaseAccess: false },
null,
2,
),
);
console.log(JSON.stringify({ results, errors }));
} catch (error) {
if (activePage && !activePage.isClosed()) {
await activePage.screenshot({ path: path.join(dir, 'failure.png') });
fs.writeFileSync(
path.join(dir, 'failure.html'),
await activePage.content(),
);
console.error(
JSON.stringify(
await activePage.locator('h1').evaluateAll((nodes) =>
nodes.map((node) => ({
text: node.textContent,
html: node.outerHTML,
})),
),
),
);
console.error(JSON.stringify(errors));
}
throw error;
} finally {
await browser.close();
}