melhorias de home 1
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user