Compare commits
126
Commits
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
# Texto sempre com LF no repositório e na cópia de trabalho (o Windows com core.autocrlf=true gerava CRLF e o Biome acusava)
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Binários: nunca converter quebras de linha
|
||||||
|
*.mp4 binary
|
||||||
|
*.webm binary
|
||||||
|
*.webp binary
|
||||||
|
*.jpg binary
|
||||||
|
*.png binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
"on":
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm lint
|
||||||
|
- run: pnpm typecheck
|
||||||
|
- run: pnpm test -- --runInBand
|
||||||
|
- run: pnpm build
|
||||||
|
- run: pnpm audit --prod --audit-level=high
|
||||||
|
- run: pnpm audit --dev --audit-level=high
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm exec playwright install --with-deps chromium
|
||||||
|
- run: pnpm build
|
||||||
|
- name: Subir o servidor de produção
|
||||||
|
run: pnpm start -p 3001 &
|
||||||
|
- name: Esperar a porta 3001
|
||||||
|
run: |
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if curl --silent --fail --output /dev/null http://localhost:3001/; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "O servidor não respondeu na porta 3001 em 120 s" >&2
|
||||||
|
exit 1
|
||||||
|
- name: Testes de ponta a ponta
|
||||||
|
run: pnpm test:e2e
|
||||||
|
env:
|
||||||
|
BASE_URL: http://localhost:3001
|
||||||
|
- name: Publicar test-results/
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-test-results
|
||||||
|
path: test-results/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
+22
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
@@ -39,3 +41,23 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# local agent/editor state
|
||||||
|
/.augment/
|
||||||
|
/.kiro/
|
||||||
|
.opencode/
|
||||||
|
.claude/settings.local.json
|
||||||
|
.superpowers/
|
||||||
|
/.worktrees/
|
||||||
|
/.impeccable/
|
||||||
|
/.serena/
|
||||||
|
|
||||||
|
# generated analysis artifacts
|
||||||
|
/graphify-out/
|
||||||
|
*.graphify.json
|
||||||
|
*.analysis.json
|
||||||
|
*.tmp.json
|
||||||
|
*.debug.json
|
||||||
|
|
||||||
|
.omo/
|
||||||
|
.playwright-mcp/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
coverage/
|
||||||
|
.git/
|
||||||
|
.pnpm-store/
|
||||||
|
.cache/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
target/
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Credits / Third-party assets
|
||||||
|
|
||||||
|
## Project screenshots
|
||||||
|
- `public/assets/prints/*.png` are captures taken with headless Chrome from
|
||||||
|
production deployments owned by the site author (evandro.dev.br), used as
|
||||||
|
on-screen content inside the notebook browser surface.
|
||||||
|
|
||||||
|
## WhatsApp icon
|
||||||
|
- The WhatsApp silhouette in `app/components/Contact.tsx` comes from
|
||||||
|
[Simple Icons](https://github.com/simple-icons/simple-icons/blob/develop/icons/whatsapp.svg).
|
||||||
|
It identifies the contact channel; it does not imply endorsement by WhatsApp.
|
||||||
@@ -1,36 +1,212 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
<div align="center">
|
||||||
|
|
||||||
## Getting Started
|
# Evandro.dev.br: software sob medida
|
||||||
|
|
||||||
First, run the development server:
|
Site comercial de Evandro Fonseca Junior para projetos de software sob medida: sistemas empresariais, aplicativos e integrações, com cases e contato direto por WhatsApp.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Node.js `22.x`
|
||||||
|
- pnpm `9.15.9`, fixado em `package.json#packageManager`
|
||||||
|
|
||||||
|
O projeto usa Next.js `16.3.3`, patch de segurança que corrige [GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36) e [GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4). A atualização substitui `16.3.0`, afetada por esses avisos. As auditorias de produção e desenvolvimento continuam sendo verificadas na CI.
|
||||||
|
|
||||||
|
## Início rápido
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
corepack [email protected] install --frozen-lockfile
|
||||||
# or
|
corepack [email protected] dev
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
O servidor de desenvolvimento usa Turbopack. Para executar o artefato de produção:
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
```bash
|
||||||
|
corepack [email protected] build
|
||||||
|
corepack [email protected] start
|
||||||
|
```
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
## Scripts
|
||||||
|
|
||||||
## Learn More
|
| Comando | Comportamento |
|
||||||
|
| --- | --- |
|
||||||
|
| `pnpm dev` | Inicia o Next.js em desenvolvimento com Turbopack. |
|
||||||
|
| `pnpm build` | Gera o build de produção com Turbopack. |
|
||||||
|
| `pnpm start` | Serve um build já gerado. |
|
||||||
|
| `pnpm lint` | Executa `biome check`. |
|
||||||
|
| `pnpm typecheck` | Gera os tipos de rota com `next typegen` e executa TypeScript sem emitir arquivos. |
|
||||||
|
| `pnpm format` | Formata o repositório com Biome. |
|
||||||
|
| `pnpm test` | Executa a suíte Jest. |
|
||||||
|
| `pnpm test:watch` | Executa Jest em modo watch. |
|
||||||
|
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
## Vitrine de vídeos dos projetos
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
Cada projeto com site gravado aparece de três formas na seção Projetos da home. Tudo mora em `app/components/showcase/`, os dados em `lib/project-showcase.ts` e os estilos em `showcase.css`.
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
- **Cards.** O vídeo de rolagem fica numa moldura de navegador com o endereço real do site. Só um vídeo toca por vez: o do card sob o mouse (ou com foco) e, sem mouse, o mais perto do centro da tela. Nada é baixado até o card tocar pela primeira vez, e um botão pausa os vídeos que se mexem sozinhos. Clicar na moldura abre o vídeo ampliado (`<dialog>` nativo, com troca computador/celular que mantém o ponto do site, capítulos clicáveis e controles próprios).
|
||||||
|
- **Vitrine animada.** Logo abaixo dos cards, uma cena presa na rolagem (`position: sticky`) em que o site de cada projeto rola dentro de um notebook, com um celular ao lado, conforme o visitante rola a página. Os capítulos de cada projeto (`chapters` em `lib/project-showcase.ts`, marcados nos quadros dos vídeos) nomeiam o trecho na tela e mantêm notebook e celular no mesmo ponto do site. Só o projeto em cena e os vizinhos baixam vídeo, e nada baixa com "economizar dados" ligado. Com `prefers-reduced-motion` ou sem JavaScript ela some e os cards continuam mostrando os posters.
|
||||||
|
- **Capa de compartilhamento.** `/cases/<id>` usa o poster do projeto como fundo da imagem Open Graph (`lib/og-card.tsx`), quando existe.
|
||||||
|
|
||||||
## Deploy on Vercel
|
Arquivos de cada projeto em `public/videos/<id>/`:
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| Arquivo | Para quê |
|
||||||
|
| --- | --- |
|
||||||
|
| `desktop.{webm,mp4,webp}` e `mobile.{webm,mp4,webp}` | cards e vídeo ampliado (até 3 MB cada vídeo) |
|
||||||
|
| `scrub-desktop.mp4` | vitrine animada: quadro-chave a cada 6 quadros, sem B-frames, para o tempo mudar a cada quadro da rolagem (o celular da moldura mostra só o pôster) |
|
||||||
|
| `og.jpg` | capa 1200x630 de compartilhamento |
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Para gravar ou regravar um projeto, use `scripts/record-project-videos.mjs`; para gerar os `scrub-*.mp4`, `scripts/encode-scrub-video.mjs` (as instruções de instalação do Playwright e do ffmpeg estão no cabeçalho de cada script). Depois de gravar, marque os capítulos do projeto em `lib/project-showcase.ts`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Área | Pacotes usados |
|
||||||
|
| --- | --- |
|
||||||
|
| Aplicação | Next.js 16.3.3, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. |
|
||||||
|
| Estilos e composição | Tailwind CSS 4, `clsx` e `tailwind-merge`. |
|
||||||
|
| Movimento | Motion 12 e GSAP 3. |
|
||||||
|
| 3D e gráficos | Three.js, React Three Fiber e Drei. |
|
||||||
|
| Interface | Lucide React. |
|
||||||
|
| Qualidade | Biome, Jest, Testing Library e ambiente JSDOM. |
|
||||||
|
|
||||||
|
Todas as dependências diretas estão fixadas em versões exatas no manifesto; o lockfile é instalado sem atualização implícita.
|
||||||
|
|
||||||
|
## Rotas
|
||||||
|
|
||||||
|
| Rota | Conteúdo |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | Homepage comercial com hero, notebook 3D, projetos, soluções, processo de trabalho, engenharia com IA, critérios de entrega, perfil e contato. |
|
||||||
|
| `/cases` | Índice dos seis cases. |
|
||||||
|
| `/cases/nutriscan` | Case NutriScan. |
|
||||||
|
| `/cases/twins-lab` | Case Twins Lab. |
|
||||||
|
| `/cases/reddit-explorer` | Case Reddit Posts Explorer. |
|
||||||
|
| `/cases/sc-plus` | Case SC Plus Aquicultura. |
|
||||||
|
| `/cases/gcc-installer` | Case MinGW GCC Installer. |
|
||||||
|
| `/cases/as-servicos` | Case AS Serviços. |
|
||||||
|
| `/servicos` | Página de destino comercial: sites, sistemas e aplicativos, contato direto no WhatsApp, cases, processo e acesso aos seis serviços. |
|
||||||
|
| `/servicos/criacao-de-sites` | Criação de sites. |
|
||||||
|
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida. |
|
||||||
|
| `/servicos/sob-medida` | Redirecionamento permanente (308) para `/servicos/engenharia-de-software`, preservando query string, UTMs e identificadores de clique. |
|
||||||
|
| `/servicos/consultoria-nextjs` | Consultoria Next.js. |
|
||||||
|
| `/servicos/tauri-rust` | Aplicativos desktop para Windows e Linux, com compatibilidade definida por projeto. |
|
||||||
|
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT. |
|
||||||
|
| `/servicos/arquitetura-de-software` | Arquitetura de software. |
|
||||||
|
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. |
|
||||||
|
|
||||||
|
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.png`, `/apple-icon.png` e `/favicon.ico`. Caminhos desconhecidos usam a página 404 do projeto. Não há API route ativa.
|
||||||
|
|
||||||
|
As páginas de serviços mantêm escopo, exemplos ilustrativos e etapas visíveis; tecnologias e decisões de implementação ficam em um `<details>` nativo. Na homepage, competências e experiência também ficam recolhidas. O header prioriza Soluções (`/#modalities`), Projetos (`/cases`), Como trabalho (`/#process`), Sobre (`/#about`) e Contato (`/#contact`), com ajuste de chegada após a definição do layout do notebook. Currículo e download do PDF são links secundários no final do rodapé.
|
||||||
|
|
||||||
|
## Comportamento da aplicação
|
||||||
|
|
||||||
|
### Conteúdo e project health
|
||||||
|
|
||||||
|
O conteúdo principal vem de `public/portfolio-data.json` e `lib/site-content.ts`. A seleção da homepage usa o campo `featured`; o snapshot de disponibilidade não remove cards. Ele apenas controla o estado do CTA de cada demo: disponível, indisponível, desconhecido ou sem demo.
|
||||||
|
|
||||||
|
Em produção, a renderização só lê o snapshot versionado e não consulta demos externas. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é atualizado em memória. A atualização manual opcional é `pnpm check:projects` e grava o arquivo por rename atômico. O contrato completo está em [`docs/project-health.md`](docs/project-health.md).
|
||||||
|
|
||||||
|
### Posicionamento comercial e uso de IA
|
||||||
|
|
||||||
|
A comunicação apresenta Evandro como responsável direto pelo projeto, sem sugerir equipe, disponibilidade garantida, prazo fixo ou certificação. Os cards de projetos reutilizam os desafios e resumos dos cases quando disponíveis. O antigo painel de telemetria fictícia foi removido; os critérios de qualidade não representam notas de auditoria.
|
||||||
|
|
||||||
|
Cada card de projeto aparece ao entrar na tela, observado individualmente e sem recorte por `clip-path`. A revelação não depende de uma porcentagem visível do grid inteiro: em celulares, a lista de uma coluna pode ser várias vezes mais alta que a tela e tornar esse gatilho inalcançável.
|
||||||
|
|
||||||
|
`app/ia/page.tsx` (rota `/ia`) explica arquitetura, permissões, validação e recuperação de dados, com três relatos históricos de 2025 (na seção recolhida "O que casos documentados ensinam") e links junto de cada afirmação:
|
||||||
|
|
||||||
|
- Lovable: exposição relatada pela Semafor e CVE-2025-48757, incluindo a contestação do fornecedor no NVD; exposição não é apresentada como roubo comprovado.
|
||||||
|
- Replit: exclusão de dados e restauração integral, conforme a resposta oficial da empresa.
|
||||||
|
- Base44: falha de autenticação identificada pela Wiz, corrigida em menos de 24 horas, sem evidência reportada de exploração maliciosa.
|
||||||
|
|
||||||
|
As obrigações dos pequenos agentes de tratamento são referenciadas à Resolução CD/ANPD nº 2/2022, arts. 6º, 12 e 13. Ao atualizar esse conteúdo, preservar datas, limites dos relatos e a distinção entre vulnerabilidade, exposição e incidente confirmado.
|
||||||
|
|
||||||
|
O formulário valida os campos no navegador e abre um rascunho no WhatsApp para revisão. Ele não envia a mensagem automaticamente e orienta a não informar senhas nem dados pessoais de clientes.
|
||||||
|
|
||||||
|
A seção de contato tem título estático único, canais diretos à esquerda e formulário à direita (empilhados no celular). A silhueta do WhatsApp aparece no contato direto, no cabeçalho do formulário e no botão verde. O formulário não se move no hover; campos mantêm rótulos, validação nativa e foco visível. O link direto dispensa preenchimento e o e-mail usa `mailto:`.
|
||||||
|
|
||||||
|
Na página `/servicos` e nos seis detalhamentos, `WhatsAppLink` oferece um link nativo com mensagem contextual, sem exigir o formulário da home. Funciona mesmo sem consentimento de analytics ou JavaScript; a medição depende da hidratação e da tag. A página principal prioriza as três intenções dos anúncios antes das especialidades técnicas e usa os cases existentes NutriScan e SC Plus como exemplos de trabalho, não como promessa de retorno comercial.
|
||||||
|
|
||||||
|
### 3D progressivo e movimento reduzido
|
||||||
|
|
||||||
|
O hero combina a mensagem comercial com três placas 3D: interface, integrações e dados. A interface usa a captura real do NutriScan, recortada acima do rodapé; as outras camadas são ilustrativas, não uma declaração da arquitetura do projeto. Ponteiro, arraste horizontal e quatro botões permitem explorar a composição. O toque vertical continua rolando a página. A renderização para quando os movimentos terminam, fora do viewport e com a aba oculta. Movimento reduzido ou falha gráfica mantêm a captura HTML, os controles e o link do case disponíveis.
|
||||||
|
|
||||||
|
O notebook 3D permanece em celulares, tablets e desktop: tampa, tela, abas e cursor seguem o progresso do scroll. No mobile, a câmera adapta o enquadramento e botões externos à tela do notebook permitem selecionar os projetos. A cena renderiza sob demanda e pausa fora do viewport ou com a página oculta. Movimento reduzido, falha de WebGL ou perda de contexto ativam a navegação HTML sem animação.
|
||||||
|
|
||||||
|
As quatro capturas desktop reais ficam em `public/assets/notebook/`, em WebP de 1600 × 1000. A troca aguarda a decodificação da próxima imagem, mantendo a anterior visível durante o carregamento ou uma falha.
|
||||||
|
|
||||||
|
Uma entrada normal na homepage começa no topo. A recarga também volta ao início, removendo o fragmento deixado por uma navegação anterior. Links diretos como `/#about` continuam chegando à seção após a estabilização do notebook; rolagem deliberada durante a abertura cancela esse ajuste. A política inicial de scroll é aplicada antes da hidratação, sem disputar com a rolagem suave dos links posteriores. Assim que a chegada se resolve, a restauração volta a pertencer ao navegador: Voltar e Avançar restauram a posição do histórico em vez de forçar o topo.
|
||||||
|
|
||||||
|
### Identidade visual
|
||||||
|
|
||||||
|
O header usa `--site-header-height`: 60px abaixo de 1280px e 68px a partir desse breakpoint. A mesma variável define a posição do menu, o respiro inicial do hero e a compensação das âncoras. O menu mobile tem fundo opaco; a barra fica levemente translúcida ao rolar. Header e hero usam Space Grotesk para leitura comercial, reservando a fonte monoespaçada aos detalhes técnicos das demais seções.
|
||||||
|
|
||||||
|
Os quatro arquivos recebidos estão preservados, com nomes descritivos, em `imagens/originais/`. As versões prontas para uso ficam em `public/brand/`:
|
||||||
|
|
||||||
|
| Arquivo | Aplicação |
|
||||||
|
| --- | --- |
|
||||||
|
| `wordmark-dark.webp` | Logo horizontal transparente, branca e lima, no cabeçalho escuro. |
|
||||||
|
| `wordmark-light.webp` | Logo horizontal transparente, escura e lima, no rodapé claro. |
|
||||||
|
| `stacked-light.webp` | Logo empilhada transparente, reservada para composições maiores em fundo claro. |
|
||||||
|
| `mark-dark.svg` / `mark-light.svg` | Símbolo vetorial para fundos escuros e claros, traçado a partir do original limpo. |
|
||||||
|
| `mark-dark.png` | Símbolo de 512px incorporado aos cards Open Graph de todas as rotas. |
|
||||||
|
| `icon-192.png` | Ícone de instalação do manifesto. |
|
||||||
|
|
||||||
|
`app/icon.png` (512px), `app/apple-icon.png` (180px) e `app/favicon.ico` (16–256px) usam o símbolo sobre fundo escuro. As variantes mantêm as formas originais; o recorte transparente com bordas danificadas permanece arquivado, sem uso na interface.
|
||||||
|
|
||||||
|
### Contato
|
||||||
|
|
||||||
|
O formulário da homepage valida os campos nativos de nome, e-mail, tipo e contexto. Ao enviar, ele abre `wa.me` em uma nova aba com o briefing preenchido; não existe backend de formulário. O rodapé mantém contato direto por e-mail.
|
||||||
|
|
||||||
|
### Consentimento de analytics
|
||||||
|
|
||||||
|
Google Analytics só é carregado depois de aceite explícito. A decisão fica em `evandro.analytics-consent.v1`; recusar não monta o loader. As preferências permanecem acessíveis e a revogação envia consentimento negado, ativa a flag de desabilitação, remove o loader e expira apenas cookies `_ga`/`_ga_*` acessíveis.
|
||||||
|
|
||||||
|
O único loader `gtag.js` configura GA4 `G-QTT9QT0T3S` e Google Ads `AW-18432694647`. Os IDs `GT-M3K4CKQT` e `GT-NBBR3PC2` informados no painel são identificadores das tags, não instalações adicionais. A integração segue https://developers.google.com/tag-platform/gtagjs/configure. `ad_storage`, `ad_user_data` e `ad_personalization` permanecem negados, com `ads_data_redaction` ativo. A tag só é montada em produção.
|
||||||
|
|
||||||
|
### Medição do contato e Google Ads
|
||||||
|
|
||||||
|
`lib/analytics.ts` envia `whatsapp_open` exclusivamente ao GA4 após o acionamento de um link ou a submissão válida do formulário. O evento representa **tentativa de abrir uma conversa**, não mensagem recebida, lead confirmado ou venda. O contato não espera a resposta do Analytics. Eventos recusados ou ocorridos antes do consentimento não são guardados para envio posterior.
|
||||||
|
|
||||||
|
Parâmetros enviados: `service` (rótulo estático), `placement` (posição do CTA) e `page_path`. A URL do evento não inclui query, fragmento ou referenciador; rotas dinâmicas são agrupadas como `/servicos/[slug]` e `/cases/[slug]`. Nome, e-mail, mensagem digitada e URL de destino do WhatsApp não entram nesse evento. A distinção do serviço usa o parâmetro `service`.
|
||||||
|
|
||||||
|
Posições: `service_hub_hero`, `service_hub_intent`, `service_hub_footer`, `service_detail_hero`, `service_detail_footer`, `contact_direct` e `contact_form`. Isso permite comparar entradas de contato pelo tipo de serviço e pela posição na página. Cliques pelo menu de contexto, bloqueadores e falhas de rede podem não ser medidos.
|
||||||
|
|
||||||
|
**Configuração externa ainda necessária:** verificar `whatsapp_open` no GA4, registrá-lo como evento principal, vincular a propriedade à conta Ads correta e importar essa ação de conversão. Conferir a ação existente antes para evitar dupla contagem; não importar simultaneamente um clique automático como a mesma conversão. Para analisar os parâmetros em Explorações, cadastrar dimensões personalizadas de escopo de evento para `service` e `placement`. Nenhum rótulo de conversão Ads foi inventado e nenhuma configuração da conta foi alterada. A medição otimiza intenção de contato, não vendas.
|
||||||
|
|
||||||
|
Também revisar as opções de medição otimizada da propriedade, sobretudo eventos automáticos de cliques externos e formulários, pois essa configuração não é controlada por este código. Referência dos eventos: https://developers.google.com/analytics/devguides/collection/ga4/events.
|
||||||
|
|
||||||
|
Verificação local: build de produção, navegação responsiva, redirecionamento com parâmetros e requisição real `whatsapp_open` gerada pela tag. Requisições de coleta foram interceptadas antes do envio para não contaminar os relatórios; isso **não comprova recebimento no GA4 nem atribuição no Ads**.
|
||||||
|
|
||||||
|
## Estrutura
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/ App Router, rotas e componentes da interface
|
||||||
|
lib/ Dados derivados, navegação, JSON-LD e project health
|
||||||
|
public/ Conteúdo JSON, PDF, fontes, imagens e modelo 3D
|
||||||
|
scripts/check-projects.mjs
|
||||||
|
Atualização manual do snapshot de demos
|
||||||
|
data/project-health.json
|
||||||
|
Última observação versionada das demos
|
||||||
|
docs/project-health.md Contrato operacional do snapshot
|
||||||
|
.github/workflows/ci.yml
|
||||||
|
Gate único de integração contínua
|
||||||
|
nixpacks.toml Build e start para Nixpacks
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI e deploy
|
||||||
|
|
||||||
|
O workflow único em `.github/workflows/ci.yml` roda em cada `push` e `pull_request` com permissão `contents: read`. Ele instala Node 22 e pnpm conforme `packageManager`, restaura o cache do pnpm e executa, nesta ordem: frozen install, lint, typecheck, Jest serial, build e auditorias de produção e desenvolvimento com limite high. O passo de typecheck gera os tipos de rota antes do `tsc`: `next-env.d.ts` não é versionado e, em uma árvore recém-clonada, é ele que declara os módulos de imagem importados pelo código.
|
||||||
|
|
||||||
|
O Nixpacks usa `nodejs_22`, `pnpm-9_x`, `pnpm install --frozen-lockfile`, `pnpm run build` e `pnpm run start`. Não há etapa de deploy nem secret de CI neste repositório.
|
||||||
|
|
||||||
|
## Validação local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack [email protected] install --frozen-lockfile
|
||||||
|
corepack [email protected] lint
|
||||||
|
corepack [email protected] typecheck
|
||||||
|
corepack [email protected] test -- --runInBand
|
||||||
|
corepack [email protected] build
|
||||||
|
corepack [email protected] audit --prod --audit-level=high
|
||||||
|
corepack [email protected] audit --dev --audit-level=high
|
||||||
|
```
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Dublê central e completo de `next/navigation` para o Jest (jest.config.ts: moduleNameMapper). Todo teste que importa
|
||||||
|
* o módulo, direta ou indiretamente (HoverPrefetchLink, Header, páginas com `notFound`), recebe este arquivo: nenhum
|
||||||
|
* teste precisa montar um `jest.mock("next/navigation", ...)` parcial, e nenhum componente vê o módulo sem um export.
|
||||||
|
*
|
||||||
|
* Estado: `usePathname`, `useSearchParams`, `useParams` e `useSelectedLayoutSegment(s)` leem `navigationState`.
|
||||||
|
* Troque-o com `setNavigation({ pathname: "/cases" })` (ou `jest.mocked(usePathname).mockReturnValue("/cases")`) e
|
||||||
|
* volte ao padrão ("/", sem parâmetros) com `resetNavigation()` num `beforeEach`.
|
||||||
|
* Erros: `notFound` e `redirect` lançam como os do Next (com `digest`), e `notFound` lança com a mensagem
|
||||||
|
* "NEXT_NOT_FOUND", que os testes das páginas conferem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Params = Record<string, string | string[]>;
|
||||||
|
|
||||||
|
interface NavigationState {
|
||||||
|
pathname: string | null;
|
||||||
|
searchParams: URLSearchParams;
|
||||||
|
params: Params;
|
||||||
|
segments: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = (): NavigationState => ({
|
||||||
|
pathname: "/",
|
||||||
|
searchParams: new URLSearchParams(),
|
||||||
|
params: {},
|
||||||
|
segments: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const navigationState: NavigationState = initialState();
|
||||||
|
|
||||||
|
/** Cada `jest.fn` com a implementação padrão, para o `resetNavigation` devolvê-la depois de um `mockReset`. */
|
||||||
|
const defaults: Array<[jest.Mock, (...args: never[]) => unknown]> = [];
|
||||||
|
function mockFn<Args extends unknown[], Result>(
|
||||||
|
implementation: (...args: Args) => Result,
|
||||||
|
) {
|
||||||
|
const fn = jest.fn(implementation);
|
||||||
|
defaults.push([fn as jest.Mock, implementation as never]);
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setNavigation(
|
||||||
|
next: Partial<Omit<NavigationState, "searchParams">> & {
|
||||||
|
searchParams?: string | URLSearchParams;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { searchParams, ...rest } = next;
|
||||||
|
Object.assign(navigationState, rest);
|
||||||
|
if (searchParams !== undefined)
|
||||||
|
navigationState.searchParams = new URLSearchParams(searchParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetNavigation() {
|
||||||
|
Object.assign(navigationState, initialState());
|
||||||
|
for (const [fn, implementation] of defaults) {
|
||||||
|
fn.mockReset();
|
||||||
|
fn.mockImplementation(implementation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const router = {
|
||||||
|
push: mockFn(() => undefined),
|
||||||
|
replace: mockFn(() => undefined),
|
||||||
|
back: mockFn(() => undefined),
|
||||||
|
forward: mockFn(() => undefined),
|
||||||
|
refresh: mockFn(() => undefined),
|
||||||
|
prefetch: mockFn(() => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePathname = mockFn(() => navigationState.pathname);
|
||||||
|
export const useRouter = mockFn(() => router);
|
||||||
|
export const useSearchParams = mockFn(() => navigationState.searchParams);
|
||||||
|
export const useParams = mockFn(() => navigationState.params);
|
||||||
|
export const useSelectedLayoutSegments = mockFn(() => navigationState.segments);
|
||||||
|
export const useSelectedLayoutSegment = mockFn(
|
||||||
|
() => navigationState.segments[0] ?? null,
|
||||||
|
);
|
||||||
|
export const useServerInsertedHTML = mockFn(() => undefined);
|
||||||
|
export const unstable_isUnrecognizedActionError = mockFn(() => false);
|
||||||
|
export const unstable_rethrow = mockFn((error: unknown): void => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RedirectType = { push: "push", replace: "replace" } as const;
|
||||||
|
export const ReadonlyURLSearchParams = URLSearchParams;
|
||||||
|
|
||||||
|
/** Erro com `digest`, como os que o Next lança para interromper a renderização. */
|
||||||
|
function nextError(message: string, digest: string): never {
|
||||||
|
throw Object.assign(new Error(message), { digest });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notFound = mockFn((): never => {
|
||||||
|
return nextError("NEXT_NOT_FOUND", "NEXT_HTTP_ERROR_FALLBACK;404");
|
||||||
|
});
|
||||||
|
export const forbidden = mockFn((): never => {
|
||||||
|
return nextError("NEXT_FORBIDDEN", "NEXT_HTTP_ERROR_FALLBACK;403");
|
||||||
|
});
|
||||||
|
export const unauthorized = mockFn((): never => {
|
||||||
|
return nextError("NEXT_UNAUTHORIZED", "NEXT_HTTP_ERROR_FALLBACK;401");
|
||||||
|
});
|
||||||
|
export const redirect = mockFn(
|
||||||
|
(url: string, type: "push" | "replace" = "replace"): never => {
|
||||||
|
return nextError("NEXT_REDIRECT", `NEXT_REDIRECT;${type};${url};307;`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
export const permanentRedirect = mockFn(
|
||||||
|
(url: string, type: "push" | "replace" = "replace"): never => {
|
||||||
|
return nextError("NEXT_REDIRECT", `NEXT_REDIRECT;${type};${url};308;`);
|
||||||
|
},
|
||||||
|
);
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,165 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import {
|
||||||
|
OG_IMAGE_MAX_BYTES,
|
||||||
|
OG_SIZE,
|
||||||
|
OgCard,
|
||||||
|
readProjectOgImage,
|
||||||
|
} from "@/lib/og-card";
|
||||||
|
import { caseStudies } from "@/lib/site-content";
|
||||||
|
import Image, { generateImageMetadata } from "./opengraph-image";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de Image (imagem de compartilhamento do case):
|
||||||
|
* slug desconhecido, id da imagem diferente do case, case com capa gravada (entregue como está, em JPEG,
|
||||||
|
* abaixo de 150 KB), capa pesada demais (cartão simples) e projeto de laboratório.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** O jsdom não tem `Response`; a rota roda no Node e devolve uma. Aqui basta guardar o corpo e os cabeçalhos. */
|
||||||
|
class FakeResponse {
|
||||||
|
constructor(
|
||||||
|
readonly body: Uint8Array,
|
||||||
|
readonly init: { headers: Record<string, string> },
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.assign(globalThis, { Response: FakeResponse });
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("@/lib/og-card", () => {
|
||||||
|
const actual = jest.requireActual("@/lib/og-card");
|
||||||
|
return { ...actual, readProjectOgImage: jest.fn(actual.readProjectOgImage) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedReadProjectOgImage = jest.mocked(readProjectOgImage);
|
||||||
|
const actualReadProjectOgImage =
|
||||||
|
jest.requireActual<typeof import("@/lib/og-card")>(
|
||||||
|
"@/lib/og-card",
|
||||||
|
).readProjectOgImage;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedReadProjectOgImage.mockImplementation(actualReadProjectOgImage);
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("next/og", () => ({ ImageResponse: jest.fn() }));
|
||||||
|
|
||||||
|
const mockedImageResponse = jest.mocked(ImageResponse);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(notFound).mockClear();
|
||||||
|
mockedImageResponse.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCase(id: string) {
|
||||||
|
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||||
|
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||||
|
return caseStudy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImage(slug: string, imageId: string) {
|
||||||
|
return Image({
|
||||||
|
params: Promise.resolve({ slug }),
|
||||||
|
id: Promise.resolve(imageId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentCard() {
|
||||||
|
const [element, options] = mockedImageResponse.mock.calls[0];
|
||||||
|
if (!isValidElement<React.ComponentProps<typeof OgCard>>(element)) {
|
||||||
|
throw new Error("ImageResponse did not receive an element");
|
||||||
|
}
|
||||||
|
return { card: element, options };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("opengraph-image Image", () => {
|
||||||
|
it("path: case with a recorded cover -> the cover itself, in JPEG, under 150 KB, and no generated card", async () => {
|
||||||
|
const caseStudy = getCase("sev-modaintima");
|
||||||
|
const response = (await renderImage(
|
||||||
|
caseStudy.id,
|
||||||
|
caseStudy.id,
|
||||||
|
)) as unknown as FakeResponse;
|
||||||
|
|
||||||
|
expect(notFound).not.toHaveBeenCalled();
|
||||||
|
expect(mockedImageResponse).not.toHaveBeenCalled();
|
||||||
|
expect(response).toBeInstanceOf(FakeResponse);
|
||||||
|
expect(response.init.headers["Content-Type"]).toBe("image/jpeg");
|
||||||
|
const cover = readFileSync(
|
||||||
|
join(process.cwd(), "public/videos/sev-modaintima/og.jpg"),
|
||||||
|
);
|
||||||
|
expect(Buffer.from(response.body).equals(cover)).toBe(true);
|
||||||
|
expect(response.body.length).toBeLessThan(150_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: cover that weighs 150 KB or more -> the plain generated card, with no background", async () => {
|
||||||
|
const caseStudy = getCase("sev-modaintima");
|
||||||
|
mockedReadProjectOgImage.mockReturnValue(undefined);
|
||||||
|
expect(OG_IMAGE_MAX_BYTES).toBe(150_000);
|
||||||
|
|
||||||
|
const response = await renderImage(caseStudy.id, caseStudy.id);
|
||||||
|
|
||||||
|
expect(response).not.toBeInstanceOf(FakeResponse);
|
||||||
|
expect(mockedImageResponse).toHaveBeenCalledTimes(1);
|
||||||
|
const { card, options } = sentCard();
|
||||||
|
expect(card.type).toBe(OgCard);
|
||||||
|
expect(card.props).toMatchObject({
|
||||||
|
eyebrow: "Case de cliente",
|
||||||
|
title: caseStudy.title,
|
||||||
|
description: caseStudy.summary,
|
||||||
|
});
|
||||||
|
expect(card.props.backgroundImage).toBeUndefined();
|
||||||
|
expect(options).toEqual(OG_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: image metadata -> JPEG for covers, PNG for generated cards, and every case listed without a slug", () => {
|
||||||
|
const [sev] = generateImageMetadata({ params: { slug: "sev-modaintima" } });
|
||||||
|
const [twins] = generateImageMetadata({ params: { slug: "twins-lab" } });
|
||||||
|
expect(sev).toMatchObject({
|
||||||
|
id: "sev-modaintima",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
size: OG_SIZE,
|
||||||
|
});
|
||||||
|
expect(twins).toMatchObject({
|
||||||
|
id: "twins-lab",
|
||||||
|
contentType: "image/png",
|
||||||
|
size: OG_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = generateImageMetadata({ params: {} });
|
||||||
|
expect(all.map(({ id }) => id)).toEqual(caseStudies.map(({ id }) => id));
|
||||||
|
expect(
|
||||||
|
all.filter(({ contentType }) => contentType === "image/jpeg"),
|
||||||
|
).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unknown slug -> notFound() and no image", async () => {
|
||||||
|
await expect(renderImage("nao-existe", "nao-existe")).rejects.toThrow(
|
||||||
|
"NEXT_NOT_FOUND",
|
||||||
|
);
|
||||||
|
expect(notFound).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedImageResponse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: known slug but another case's image id -> notFound() and no image", async () => {
|
||||||
|
await expect(renderImage("sev-modaintima", "lavacao-047")).rejects.toThrow(
|
||||||
|
"NEXT_NOT_FOUND",
|
||||||
|
);
|
||||||
|
expect(notFound).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedImageResponse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: laboratory case -> card labelled as a laboratory project, no cover when none is recorded", async () => {
|
||||||
|
const caseStudy = getCase("twins-lab");
|
||||||
|
await renderImage(caseStudy.id, caseStudy.id);
|
||||||
|
|
||||||
|
const { card } = sentCard();
|
||||||
|
expect(card.props).toMatchObject({
|
||||||
|
eyebrow: "Projeto de laboratório",
|
||||||
|
title: caseStudy.title,
|
||||||
|
});
|
||||||
|
expect(card.props.backgroundImage).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import {
|
||||||
|
OG_CONTENT_TYPE,
|
||||||
|
OG_PHOTO_CONTENT_TYPE,
|
||||||
|
OG_SIZE,
|
||||||
|
OgCard,
|
||||||
|
readProjectOgImage,
|
||||||
|
} from "@/lib/og-card";
|
||||||
|
import { caseStudies, isClientCase } from "@/lib/site-content";
|
||||||
|
|
||||||
|
function getCaseStudy(slug: string) {
|
||||||
|
return caseStudies.find(({ id }) => id === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quem tem a capa gravada (public/videos/<id>/og.jpg, abaixo de 150 KB) a entrega como está, em JPEG: o cartão em PNG com a capa de fundo passava de 270 KB.
|
||||||
|
function getImageMetadata(caseStudy: (typeof caseStudies)[number]) {
|
||||||
|
return {
|
||||||
|
id: caseStudy.id,
|
||||||
|
alt: `Case ${caseStudy.title} — Evandro Fonseca Junior`,
|
||||||
|
size: OG_SIZE,
|
||||||
|
contentType: readProjectOgImage(caseStudy.id)
|
||||||
|
? OG_PHOTO_CONTENT_TYPE
|
||||||
|
: OG_CONTENT_TYPE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateImageMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { slug?: string };
|
||||||
|
}) {
|
||||||
|
if (!params.slug) {
|
||||||
|
return caseStudies.map(getImageMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseStudy = getCaseStudy(params.slug);
|
||||||
|
if (!caseStudy) notFound();
|
||||||
|
return [getImageMetadata(caseStudy)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Image({
|
||||||
|
params,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
id: Promise<string>;
|
||||||
|
}) {
|
||||||
|
const [{ slug }, imageId] = await Promise.all([params, id]);
|
||||||
|
const caseStudy = getCaseStudy(slug);
|
||||||
|
if (!caseStudy || imageId !== caseStudy.id) notFound();
|
||||||
|
|
||||||
|
const photo = readProjectOgImage(caseStudy.id);
|
||||||
|
if (photo) {
|
||||||
|
return new Response(new Uint8Array(photo), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": OG_PHOTO_CONTENT_TYPE,
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<OgCard
|
||||||
|
eyebrow={
|
||||||
|
isClientCase(caseStudy) ? "Case de cliente" : "Projeto de laboratório"
|
||||||
|
}
|
||||||
|
title={caseStudy.title}
|
||||||
|
description={caseStudy.summary}
|
||||||
|
/>,
|
||||||
|
OG_SIZE,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||||
|
import { caseStudies } from "@/lib/site-content";
|
||||||
|
import CasePage from "./page";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teste de caminhos-base de CasePage: os "fatos do projeto" no topo.
|
||||||
|
* O teste principal cobre a lista ausente (`undefined`) e a lista preenchida; aqui, a lista vazia (`[]`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setNavigation({ pathname: "/cases" });
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCase(id: string) {
|
||||||
|
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||||
|
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||||
|
return caseStudy;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CasePage: fatos do projeto", () => {
|
||||||
|
it("path: empty facts list -> no list in the hero, summary stays", async () => {
|
||||||
|
const caseStudy = getCase("lavacao-047");
|
||||||
|
caseStudy.facts = [];
|
||||||
|
try {
|
||||||
|
const ui = await CasePage({
|
||||||
|
params: Promise.resolve({ slug: caseStudy.id }),
|
||||||
|
});
|
||||||
|
render(ui);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("list", { name: "Fatos do projeto" }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(screen.getByText(caseStudy.summary)).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
delete caseStudy.facts;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||||
|
import { caseStudies, getRelatedServices } from "@/lib/site-content";
|
||||||
|
import { getCaseMedia } from "../_components/case-media";
|
||||||
|
import CasePage, { generateMetadata } from "./page";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de CasePage e generateMetadata (complemento de page.test.tsx).
|
||||||
|
* Cobrem só o que o arquivo principal não exercita: slug desconhecido, mídia só no celular,
|
||||||
|
* demonstração sem captura, resultado vazio e case sem serviço relacionado.
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setNavigation({ pathname: "/cases" });
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../_components/case-media", () => {
|
||||||
|
const actual = jest.requireActual("../_components/case-media");
|
||||||
|
return { ...actual, getCaseMedia: jest.fn(actual.getCaseMedia) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualGetCaseMedia = jest.requireActual<
|
||||||
|
typeof import("../_components/case-media")
|
||||||
|
>("../_components/case-media").getCaseMedia;
|
||||||
|
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(notFound).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(actualGetCaseMedia);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCase(id: string) {
|
||||||
|
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||||
|
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||||
|
return caseStudy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCase(id: string) {
|
||||||
|
const ui = await CasePage({ params: Promise.resolve({ slug: id }) });
|
||||||
|
return render(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CasePage: caminhos que o teste principal não cobre", () => {
|
||||||
|
it("path: unknown slug -> calls notFound() and renders nothing", async () => {
|
||||||
|
await expect(
|
||||||
|
CasePage({ params: Promise.resolve({ slug: "nao-existe" }) }),
|
||||||
|
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||||
|
expect(notFound).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no demo and no desktop capture, but a mobile capture -> shows only the phone", async () => {
|
||||||
|
const caseStudy = getCase("as-servicos");
|
||||||
|
expect(caseStudy.demoUrl).toBeNull();
|
||||||
|
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||||
|
device === "mobile" ? actualGetCaseMedia(id, device) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = await renderCase(caseStudy.id);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", {
|
||||||
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
// sem captura do computador, o topo fica em uma coluna, sem moldura de navegador
|
||||||
|
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector("section")?.querySelector(".grid"),
|
||||||
|
).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-phone")).not.toBeNull();
|
||||||
|
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: demo published but no capture on disk -> keeps the block with the site button and no video", async () => {
|
||||||
|
const caseStudy = getCase("sev-modaintima");
|
||||||
|
expect(caseStudy.demoUrl).toBeTruthy();
|
||||||
|
mockedGetCaseMedia.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const { container } = await renderCase(caseStudy.id);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
|
||||||
|
).toHaveAttribute("href", caseStudy.demoUrl);
|
||||||
|
expect(container.querySelector("video")).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-phone")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: result is an empty list -> no result section", async () => {
|
||||||
|
const caseStudy = getCase("lavacao-047");
|
||||||
|
caseStudy.result = [];
|
||||||
|
try {
|
||||||
|
await renderCase(caseStudy.id);
|
||||||
|
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
|
||||||
|
).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
delete caseStudy.result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: case that no service points to -> no related service links, contact stays", async () => {
|
||||||
|
const caseStudy = getCase("nutriscan");
|
||||||
|
expect(getRelatedServices(caseStudy.id)).toHaveLength(0);
|
||||||
|
|
||||||
|
await renderCase(caseStudy.id);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
|
||||||
|
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
|
||||||
|
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
|
||||||
|
// o NutriScan tem "Como ficou", mas o botão de contato é um só, o do fecho (sem dois seguidos)
|
||||||
|
const contacts = screen.getAllByRole("link", {
|
||||||
|
name: "Falar sobre meu projeto",
|
||||||
|
});
|
||||||
|
expect(contacts).toHaveLength(1);
|
||||||
|
expect(contacts[0]).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Ver outros cases" }),
|
||||||
|
).toHaveAttribute("href", "/cases");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateMetadata: caminho do slug desconhecido", () => {
|
||||||
|
it("path: unknown slug -> only a 'not found' title, no canonical or social tags", async () => {
|
||||||
|
const metadata = await generateMetadata({
|
||||||
|
params: Promise.resolve({ slug: "nao-existe" }),
|
||||||
|
});
|
||||||
|
expect(metadata).toEqual({ title: "Case não encontrado" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||||
|
import {
|
||||||
|
buttonLabels,
|
||||||
|
caseStudies,
|
||||||
|
clientCasesForListing,
|
||||||
|
getRelatedServices,
|
||||||
|
siteUrl,
|
||||||
|
} from "@/lib/site-content";
|
||||||
|
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||||
|
import CasesPage from "../page";
|
||||||
|
import CasePage, { generateMetadata, generateStaticParams } from "./page";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setNavigation({ pathname: "/cases" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientCaseIds = [
|
||||||
|
"sev-modaintima",
|
||||||
|
"lavacao-047",
|
||||||
|
"sc-plus",
|
||||||
|
"as-servicos",
|
||||||
|
];
|
||||||
|
const labCaseIds = [
|
||||||
|
"nutriscan",
|
||||||
|
"twins-lab",
|
||||||
|
"reddit-explorer",
|
||||||
|
"gcc-installer",
|
||||||
|
];
|
||||||
|
const publicCaseIds = [...clientCaseIds, ...labCaseIds];
|
||||||
|
|
||||||
|
function getCase(id: string) {
|
||||||
|
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||||
|
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||||
|
return caseStudy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCase(id: string) {
|
||||||
|
const ui = await CasePage({ params: Promise.resolve({ slug: id }) });
|
||||||
|
return render(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBefore(first: Element, second: Element) {
|
||||||
|
return Boolean(
|
||||||
|
first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
|
||||||
|
const scripts = container.querySelectorAll<HTMLScriptElement>(
|
||||||
|
'script[type="application/ld+json"]',
|
||||||
|
);
|
||||||
|
if (scripts.length === 0) throw new Error("Missing JSON-LD");
|
||||||
|
|
||||||
|
return Array.from(scripts).flatMap((script) => {
|
||||||
|
if (!script.textContent) throw new Error("Empty JSON-LD");
|
||||||
|
const value = JSON.parse(script.textContent) as Record<string, unknown>;
|
||||||
|
return Array.isArray(value["@graph"]) ? value["@graph"] : [value];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo
|
||||||
|
* ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
|
||||||
|
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Falar sobre meu projeto" seguidos.
|
||||||
|
*/
|
||||||
|
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
||||||
|
const hrefs = screen
|
||||||
|
.getAllByRole("link")
|
||||||
|
.map((link) => link.getAttribute("href") ?? "");
|
||||||
|
expect(hrefs).not.toContain("/#contact");
|
||||||
|
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
||||||
|
|
||||||
|
const ctas = screen.getAllByRole("link", { name: buttonLabels.contact });
|
||||||
|
expect(ctas).toHaveLength(1);
|
||||||
|
for (const cta of ctas) {
|
||||||
|
const destination = new URL(cta.getAttribute("href") ?? "");
|
||||||
|
expect(destination.origin).toBe("https://wa.me");
|
||||||
|
expect(destination.searchParams.get("text")).toContain(
|
||||||
|
similarProjectSubject(caseStudy.title),
|
||||||
|
);
|
||||||
|
expect(cta).toHaveAttribute("target", "_blank");
|
||||||
|
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
||||||
|
// limão preenchido só no contato: o botão de play e os demais não o usam
|
||||||
|
expect(cta.className).toContain("bg-[var(--slash)]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("exposes the eight public cases, the two new client cases among them", async () => {
|
||||||
|
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
|
||||||
|
expect(staticSlugs).toHaveLength(8);
|
||||||
|
expect(new Set(staticSlugs).size).toBe(8);
|
||||||
|
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
|
||||||
|
expect(staticSlugs).toContain("sev-modaintima");
|
||||||
|
expect(staticSlugs).toContain("lavacao-047");
|
||||||
|
|
||||||
|
for (const id of ["sev-modaintima", "lavacao-047", "as-servicos"]) {
|
||||||
|
const caseStudy = getCase(id);
|
||||||
|
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||||
|
const { metaTitle: title, metaDescription: description } = caseStudy;
|
||||||
|
const metadata = await generateMetadata({
|
||||||
|
params: Promise.resolve({ slug: caseStudy.id }),
|
||||||
|
});
|
||||||
|
expect(metadata).toMatchObject({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: { canonical: url },
|
||||||
|
openGraph: { title, description, url },
|
||||||
|
twitter: { title, description },
|
||||||
|
});
|
||||||
|
expect(metadata).not.toHaveProperty("keywords");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a client case in the sales order, with media, contact and collapsed technical details", async () => {
|
||||||
|
const caseStudy = getCase("sev-modaintima");
|
||||||
|
const { container } = await renderCase(caseStudy.id);
|
||||||
|
|
||||||
|
const heading = screen.getByRole("heading", { level: 1 });
|
||||||
|
expect(heading).toHaveTextContent(caseStudy.headline);
|
||||||
|
expect(isBefore(heading, screen.getByText(caseStudy.summary))).toBe(true);
|
||||||
|
expect(screen.getByText(/Case de cliente/)).toBeVisible();
|
||||||
|
|
||||||
|
const sectionNames = [
|
||||||
|
"O cliente e o contexto",
|
||||||
|
"O problema",
|
||||||
|
"O que foi feito",
|
||||||
|
"Como ficou",
|
||||||
|
"O que o cliente passou a poder fazer",
|
||||||
|
"Quer um projeto parecido com este?",
|
||||||
|
];
|
||||||
|
const sectionHeadings = sectionNames.map((name) =>
|
||||||
|
screen.getByRole("heading", { level: 2, name }),
|
||||||
|
);
|
||||||
|
for (let index = 1; index < sectionHeadings.length; index += 1) {
|
||||||
|
expect(isBefore(sectionHeadings[index - 1], sectionHeadings[index])).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(isBefore(heading, sectionHeadings[0])).toBe(true);
|
||||||
|
|
||||||
|
expect(screen.getByText(caseStudy.context)).toBeVisible();
|
||||||
|
expect(screen.getByText(caseStudy.problem)).toBeVisible();
|
||||||
|
for (const delivery of caseStudy.deliveries) {
|
||||||
|
expect(screen.getByText(delivery)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// sem dado real, não há seção de resultado
|
||||||
|
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
|
||||||
|
|
||||||
|
// mídia: o pôster do computador e do celular com um botão de play; nenhum vídeo parado com controles
|
||||||
|
expect(container.querySelector("video")).toBeNull();
|
||||||
|
const desktopPlay = screen.getByRole("button", {
|
||||||
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
||||||
|
});
|
||||||
|
const mobilePlay = screen.getByRole("button", {
|
||||||
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
|
||||||
|
});
|
||||||
|
const mediaSection = screen
|
||||||
|
.getByRole("heading", { level: 2, name: "Como ficou" })
|
||||||
|
.closest("section");
|
||||||
|
expect(mediaSection?.querySelector(".ds-bframe")).toContainElement(
|
||||||
|
desktopPlay,
|
||||||
|
);
|
||||||
|
expect(mediaSection?.querySelector(".ds-phone")).toContainElement(mobilePlay);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
|
||||||
|
).toHaveAttribute("href", caseStudy.demoUrl);
|
||||||
|
|
||||||
|
// um botão de contato só, no fecho: as capacidades (logo depois de "Como ficou") não repetem o botão
|
||||||
|
expectContactAndNoDeadLinks(caseStudy);
|
||||||
|
const capabilitiesSection = sectionHeadings[4].closest("section");
|
||||||
|
expect(
|
||||||
|
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
||||||
|
name: buttonLabels.contact,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
const closingSection = sectionHeadings[5].closest("section");
|
||||||
|
expect(closingSection).toContainElement(
|
||||||
|
within(closingSection as HTMLElement).getByRole("link", {
|
||||||
|
name: buttonLabels.contact,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const capability of caseStudy.capabilities ?? []) {
|
||||||
|
expect(
|
||||||
|
within(capabilitiesSection as HTMLElement).getByText(capability),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte
|
||||||
|
const details = container.querySelector("details");
|
||||||
|
if (!details) throw new Error("Missing technical details");
|
||||||
|
expect(details).not.toHaveAttribute("open");
|
||||||
|
expect(within(details).getByText("Detalhes técnicos")).toBeVisible();
|
||||||
|
for (const technology of caseStudy.stack) {
|
||||||
|
expect(within(details).getByText(technology)).not.toBeVisible();
|
||||||
|
}
|
||||||
|
for (const decision of caseStudy.decisions) {
|
||||||
|
expect(within(details).getByText(decision)).not.toBeVisible();
|
||||||
|
}
|
||||||
|
const doneSection = sectionHeadings[2].closest("section");
|
||||||
|
expect(doneSection).toContainElement(details);
|
||||||
|
expect(isBefore(screen.getByText(caseStudy.deliveries[0]), details)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isBefore(details, sectionHeadings[3])).toBe(true);
|
||||||
|
expect(container.querySelectorAll("details")).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
container.querySelector("section[aria-label='Detalhes técnicos']"),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
// o que saiu: desafio verificável, limites da evidência, fontes e commits
|
||||||
|
for (const name of [
|
||||||
|
"Desafio verificável",
|
||||||
|
"Decisões de implementação",
|
||||||
|
"Limites desta evidência",
|
||||||
|
"Fontes",
|
||||||
|
]) {
|
||||||
|
expect(screen.queryByRole("heading", { name })).toBeNull();
|
||||||
|
}
|
||||||
|
expect(screen.queryByRole("link", { name: /^Fonte:/ })).toBeNull();
|
||||||
|
expect(container.textContent).not.toMatch(/Commit:|[0-9a-f]{40}/);
|
||||||
|
|
||||||
|
const graph = readJsonLdEntities(container);
|
||||||
|
expect(graph.map((node) => node["@type"])).toEqual([
|
||||||
|
"CreativeWork",
|
||||||
|
"BreadcrumbList",
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(graph)).not.toMatch(
|
||||||
|
/Article|FAQPage|ProfessionalService|LocalBusiness/,
|
||||||
|
);
|
||||||
|
const breadcrumb = graph[1] as {
|
||||||
|
itemListElement: Array<{
|
||||||
|
"@type": string;
|
||||||
|
position: number;
|
||||||
|
name: string;
|
||||||
|
item: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
const canonicalUrl = `${siteUrl}/cases/${caseStudy.id}`;
|
||||||
|
expect(breadcrumb.itemListElement).toEqual([
|
||||||
|
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 2,
|
||||||
|
name: "Cases",
|
||||||
|
item: `${siteUrl}/cases`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 3,
|
||||||
|
name: caseStudy.title,
|
||||||
|
item: canonicalUrl,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||||
|
name: "Trilha de navegação",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
within(visibleBreadcrumb)
|
||||||
|
.getAllByRole("link")
|
||||||
|
.map((link) => ({
|
||||||
|
name: link.textContent,
|
||||||
|
href: link.getAttribute("href"),
|
||||||
|
current: link.getAttribute("aria-current"),
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ name: "Início", href: "/", current: null },
|
||||||
|
{ name: "Cases", href: "/cases", current: null },
|
||||||
|
{
|
||||||
|
name: caseStudy.title,
|
||||||
|
href: `/cases/${caseStudy.id}`,
|
||||||
|
current: "page",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the two new client cases from the portfolio data", async () => {
|
||||||
|
for (const id of ["sev-modaintima", "lavacao-047"]) {
|
||||||
|
const caseStudy = getCase(id);
|
||||||
|
const { unmount } = await renderCase(id);
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||||
|
caseStudy.headline,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
|
||||||
|
).toHaveAttribute("href", caseStudy.demoUrl);
|
||||||
|
// um botão de contato só, no fecho, mesmo com "Como ficou" na página
|
||||||
|
expectContactAndNoDeadLinks(caseStudy);
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels a laboratory case, skips the media block and shows its service as a card", async () => {
|
||||||
|
const caseStudy = getCase("twins-lab");
|
||||||
|
const { container } = await renderCase(caseStudy.id);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "O contexto do projeto" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.queryByRole("heading", { name: "Como ficou" })).toBeNull();
|
||||||
|
expect(container.querySelector("video")).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||||
|
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
|
||||||
|
expect(screen.queryByText(/indispon/i)).toBeNull();
|
||||||
|
|
||||||
|
const relatedLinks = screen.getAllByRole("link", {
|
||||||
|
name: /^Ver o serviço:/,
|
||||||
|
});
|
||||||
|
expect(relatedLinks.map((link) => link.getAttribute("href"))).toEqual(
|
||||||
|
getRelatedServices(caseStudy.id).map(({ slug }) => `/servicos/${slug}`),
|
||||||
|
);
|
||||||
|
expect(relatedLinks).toHaveLength(1);
|
||||||
|
expectContactAndNoDeadLinks(caseStudy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns the contact of a laboratory case into the 'Fale sobre o seu caso' invitation, with the same button label as a client case", async () => {
|
||||||
|
for (const id of labCaseIds) {
|
||||||
|
const { unmount } = await renderCase(id);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Quer um projeto parecido com este?")).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", {
|
||||||
|
name: /Conversar sobre um projeto parecido|Tem um problema parecido/,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
||||||
|
).toBeVisible();
|
||||||
|
// um botão só, com ou sem "Como ficou" (NutriScan tem)
|
||||||
|
expectContactAndNoDeadLinks(getCase(id));
|
||||||
|
// o laboratório não tem "o que o cliente passou a poder fazer"
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", {
|
||||||
|
name: "O que o cliente passou a poder fazer",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes what the client can do now right after 'Como ficou', with no contact button of its own, for every client case", async () => {
|
||||||
|
for (const id of clientCaseIds) {
|
||||||
|
const caseStudy = getCase(id);
|
||||||
|
const { unmount } = await renderCase(id);
|
||||||
|
|
||||||
|
const media = screen.getByRole("heading", { level: 2, name: "Como ficou" });
|
||||||
|
const capabilities = screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "O que o cliente passou a poder fazer",
|
||||||
|
});
|
||||||
|
// a seção vem logo depois da mídia, sem outra seção no meio
|
||||||
|
const mediaSection = media.closest("section");
|
||||||
|
const capabilitiesSection = capabilities.closest("section");
|
||||||
|
expect(mediaSection?.nextElementSibling).toBe(capabilitiesSection);
|
||||||
|
const items = within(capabilitiesSection as HTMLElement)
|
||||||
|
.getAllByRole("listitem")
|
||||||
|
.map((item) => item.textContent);
|
||||||
|
expect(items).toEqual(caseStudy.capabilities);
|
||||||
|
expect(items.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(items.length).toBeLessThanOrEqual(3);
|
||||||
|
// sem número inventado
|
||||||
|
expect(items.join(" ")).not.toMatch(/\d/);
|
||||||
|
// o único botão de contato é o do fecho, depois da lista: não há dois "Falar sobre meu projeto" seguidos
|
||||||
|
expect(
|
||||||
|
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
||||||
|
name: buttonLabels.contact,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
const closingSection = screen
|
||||||
|
.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Quer um projeto parecido com este?",
|
||||||
|
})
|
||||||
|
.closest("section");
|
||||||
|
expect(capabilitiesSection?.nextElementSibling).toBe(closingSection);
|
||||||
|
expect(
|
||||||
|
within(closingSection as HTMLElement).getAllByRole("link", {
|
||||||
|
name: buttonLabels.contact,
|
||||||
|
}),
|
||||||
|
).toHaveLength(1);
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// a frase do dono da Lavação 047 vem dos dados do projeto
|
||||||
|
await renderCase("lavacao-047");
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"O dono muda preços, textos, fotos e o mapa sem pedir a ninguém, e a mudança aparece no site sem nova publicação.",
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the author's role and no site button for a client project without a public site", async () => {
|
||||||
|
const caseStudy = getCase("as-servicos");
|
||||||
|
expect(caseStudy.demoUrl).toBeNull();
|
||||||
|
await renderCase(caseStudy.id);
|
||||||
|
|
||||||
|
expect(screen.getByText("Minha participação:")).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByText(caseStudy.role ?? "", { exact: false }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
|
||||||
|
expect(screen.queryByText(/indispon/i)).toBeNull();
|
||||||
|
// a capa continua nas molduras
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the result section only when the case has real data", async () => {
|
||||||
|
const caseStudy = getCase("lavacao-047");
|
||||||
|
const { unmount } = await renderCase(caseStudy.id);
|
||||||
|
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
caseStudy.result = ["Dado real informado pelo cliente."];
|
||||||
|
try {
|
||||||
|
await renderCase(caseStudy.id);
|
||||||
|
const heading = screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Resultado",
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Dado real informado pelo cliente.")).toBeVisible();
|
||||||
|
expect(
|
||||||
|
isBefore(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
|
||||||
|
heading,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isBefore(
|
||||||
|
heading,
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Quer um projeto parecido com este?",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
delete caseStudy.result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows at most two related services, as cards under the contact button", async () => {
|
||||||
|
// lavacao-047 tem três serviços ligados; só os dois primeiros viram cards
|
||||||
|
const sev = getCase("lavacao-047");
|
||||||
|
const all = getRelatedServices(sev.id);
|
||||||
|
expect(all.length).toBeGreaterThan(2);
|
||||||
|
const { unmount } = await renderCase(sev.id);
|
||||||
|
|
||||||
|
const cards = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
|
||||||
|
expect(cards).toHaveLength(2);
|
||||||
|
expect(cards.map((link) => link.getAttribute("href"))).toEqual(
|
||||||
|
all.slice(0, 2).map(({ slug }) => `/servicos/${slug}`),
|
||||||
|
);
|
||||||
|
for (const link of cards) {
|
||||||
|
const card = link.closest("article");
|
||||||
|
expect(card).toHaveClass("ds-card");
|
||||||
|
expect(
|
||||||
|
within(card as HTMLElement).getByRole("heading", { level: 3 }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
|
||||||
|
).toBeNull();
|
||||||
|
// o botão do fecho (o único) vem antes dos cartões
|
||||||
|
const contact = screen.getByRole("link", { name: buttonLabels.contact });
|
||||||
|
expect(isBefore(contact, cards[0])).toBe(true);
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// sc-plus e a SEV têm dois (a SEV liga à oferta de site e à de sistema); a AS Serviços, um
|
||||||
|
for (const [id, expected] of [
|
||||||
|
["sc-plus", 2],
|
||||||
|
["as-servicos", 1],
|
||||||
|
["sev-modaintima", 2],
|
||||||
|
] as const) {
|
||||||
|
const { unmount: unmountCase } = await renderCase(id);
|
||||||
|
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
|
||||||
|
expect(links).toHaveLength(expected);
|
||||||
|
expect(links.map((link) => link.getAttribute("href"))).toEqual(
|
||||||
|
getRelatedServices(id)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(({ slug }) => `/servicos/${slug}`),
|
||||||
|
);
|
||||||
|
expectContactAndNoDeadLinks(getCase(id));
|
||||||
|
unmountCase();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links the SEV case to the site offer and to the system offer, each by the name of the offer", async () => {
|
||||||
|
await renderCase("sev-modaintima");
|
||||||
|
|
||||||
|
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
|
||||||
|
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||||
|
"/servicos/criacao-de-sites",
|
||||||
|
"/servicos/engenharia-de-software",
|
||||||
|
]);
|
||||||
|
expect(links.map((link) => link.textContent)).toEqual([
|
||||||
|
"Ver o serviço: Site que traz pedidos pelo WhatsApp",
|
||||||
|
"Ver o serviço: Sistema para tirar a operação da planilha",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the CreativeWork of a client case with creator, client and keywords", async () => {
|
||||||
|
const caseStudy = getCase("lavacao-047");
|
||||||
|
const { container } = await renderCase(caseStudy.id);
|
||||||
|
|
||||||
|
const work = readJsonLdEntities(container).find(
|
||||||
|
(node) => node["@type"] === "CreativeWork",
|
||||||
|
);
|
||||||
|
expect(work).toEqual({
|
||||||
|
"@type": "CreativeWork",
|
||||||
|
"@id": `${siteUrl}/cases/lavacao-047#creative-work`,
|
||||||
|
name: "Lavação 047: Site e painel para lavação automotiva",
|
||||||
|
description: caseStudy.metaDescription,
|
||||||
|
url: `${siteUrl}/cases/lavacao-047`,
|
||||||
|
image: `${siteUrl}/cases/lavacao-047/opengraph-image/lavacao-047`,
|
||||||
|
inLanguage: "pt-BR",
|
||||||
|
creator: { "@id": `${siteUrl}/#person` },
|
||||||
|
sourceOrganization: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "Lavação 047",
|
||||||
|
url: "https://landing-page-lavacao047.vercel.app",
|
||||||
|
},
|
||||||
|
keywords: caseStudy.stack.join(", "),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the CreativeWork of a laboratory case without a client", async () => {
|
||||||
|
const caseStudy = getCase("twins-lab");
|
||||||
|
const { container } = await renderCase(caseStudy.id);
|
||||||
|
|
||||||
|
const work = readJsonLdEntities(container).find(
|
||||||
|
(node) => node["@type"] === "CreativeWork",
|
||||||
|
);
|
||||||
|
expect(work).toMatchObject({
|
||||||
|
name: "Twins Lab: Painel ao vivo de um reator simulado",
|
||||||
|
url: `${siteUrl}/cases/twins-lab`,
|
||||||
|
creator: { "@id": `${siteUrl}/#person` },
|
||||||
|
keywords: "Next.js, TypeScript, NextAuth, MongoDB, SSE",
|
||||||
|
});
|
||||||
|
expect(work).not.toHaveProperty("sourceOrganization");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the project cover next to the title, and below it on a phone, in a browser frame", async () => {
|
||||||
|
const caseStudy = getCase("sev-modaintima");
|
||||||
|
const { container } = await renderCase(caseStudy.id);
|
||||||
|
|
||||||
|
const hero = container.querySelector("section");
|
||||||
|
if (!hero) throw new Error("Missing hero");
|
||||||
|
const frame = hero.querySelector(".ds-bframe");
|
||||||
|
if (!frame) throw new Error("Missing hero frame");
|
||||||
|
expect(frame.querySelector("img")).toHaveAttribute(
|
||||||
|
"alt",
|
||||||
|
`Captura de tela do site ${caseStudy.title}`,
|
||||||
|
);
|
||||||
|
expect(frame.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||||
|
"sevmodaintima.com.br",
|
||||||
|
);
|
||||||
|
// a capa abre a página: carrega antes das demais imagens
|
||||||
|
expect(frame.querySelector("img")).not.toHaveAttribute("loading", "lazy");
|
||||||
|
|
||||||
|
// ordem do documento (a do celular): título, mídia, resumo
|
||||||
|
const heading = screen.getByRole("heading", { level: 1 });
|
||||||
|
const summary = screen.getByText(caseStudy.summary);
|
||||||
|
expect(hero).toContainElement(summary);
|
||||||
|
expect(isBefore(heading, frame)).toBe(true);
|
||||||
|
expect(isBefore(frame, summary)).toBe(true);
|
||||||
|
// no computador: duas colunas, texto à esquerda e mídia à direita
|
||||||
|
const grid = frame.closest(".grid");
|
||||||
|
expect(grid?.className).toContain("lg:grid-cols-[");
|
||||||
|
expect(frame.parentElement?.className).toContain("lg:col-start-2");
|
||||||
|
expect(heading.parentElement?.className).toContain("lg:col-start-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the hero in one column when the case has no capture", async () => {
|
||||||
|
const { container } = await renderCase("twins-lab");
|
||||||
|
|
||||||
|
const hero = container.querySelector("section");
|
||||||
|
expect(hero?.querySelector(".ds-bframe")).toBeNull();
|
||||||
|
expect(hero?.querySelector(".grid")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the optional facts only when they are filled in", async () => {
|
||||||
|
const caseStudy = getCase("lavacao-047");
|
||||||
|
const empty = await renderCase(caseStudy.id);
|
||||||
|
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
|
||||||
|
empty.unmount();
|
||||||
|
|
||||||
|
caseStudy.facts = ["Fato de teste um", "Fato de teste dois"];
|
||||||
|
try {
|
||||||
|
await renderCase(caseStudy.id);
|
||||||
|
const list = screen.getByRole("list", { name: "Fatos do projeto" });
|
||||||
|
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
||||||
|
expect(within(list).getByText("Fato de teste um")).toBeVisible();
|
||||||
|
expect(isBefore(screen.getByText(caseStudy.summary), list)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
delete caseStudy.facts;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts the video only when the visitor presses play, and moves focus to it", async () => {
|
||||||
|
const caseStudy = getCase("sev-modaintima");
|
||||||
|
const { container } = await renderCase(caseStudy.id);
|
||||||
|
|
||||||
|
const play = screen.getByRole("button", {
|
||||||
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
||||||
|
});
|
||||||
|
expect(play).toBeVisible();
|
||||||
|
expect(play.className).not.toContain("--slash");
|
||||||
|
expect(container.querySelector("video")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(play);
|
||||||
|
|
||||||
|
const video = screen.getByLabelText(
|
||||||
|
`Vídeo do site ${caseStudy.title} no computador`,
|
||||||
|
);
|
||||||
|
expect(video.tagName).toBe("VIDEO");
|
||||||
|
expect(video).toHaveAttribute(
|
||||||
|
"poster",
|
||||||
|
`/videos/${caseStudy.id}/desktop.webp`,
|
||||||
|
);
|
||||||
|
expect(video).toHaveAttribute("controls");
|
||||||
|
expect(video).toHaveFocus();
|
||||||
|
expect(
|
||||||
|
video.querySelectorAll("source[src^='/videos/sev-modaintima/']"),
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(video.closest(".ds-bframe")).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", {
|
||||||
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
// o celular continua com o pôster até a pessoa tocar nele
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the case page the same shell as the home, with no narrow max-width", async () => {
|
||||||
|
const { container } = await renderCase("sev-modaintima");
|
||||||
|
for (const shell of container.querySelectorAll(".ds-section-shell")) {
|
||||||
|
expect(shell.className).not.toMatch(/max-w-/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => {
|
||||||
|
const { container } = render(<CasesPage />);
|
||||||
|
const heading = screen.getByRole("heading", { level: 1 });
|
||||||
|
expect(heading.nextElementSibling).toHaveTextContent(/Abra um case/);
|
||||||
|
// o título e o subtítulo não repetem "Veja o que já"
|
||||||
|
expect(heading.nextElementSibling).not.toHaveTextContent(/Veja o que já/);
|
||||||
|
expect(container.textContent).not.toMatch(/Selected work/i);
|
||||||
|
|
||||||
|
const clientHeading = screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Projetos de clientes",
|
||||||
|
});
|
||||||
|
const labHeading = screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Laboratório",
|
||||||
|
});
|
||||||
|
expect(isBefore(heading, clientHeading)).toBe(true);
|
||||||
|
expect(isBefore(clientHeading, labHeading)).toBe(true);
|
||||||
|
expect(screen.getByText(/Projetos pessoais e de estudo/)).toBeVisible();
|
||||||
|
|
||||||
|
// cases de cliente, na ordem da vitrine (SEV primeiro): capa (pôster) em moldura de navegador e botão "Ver case" em contorno
|
||||||
|
const listedIds = clientCasesForListing.map(({ id }) => id);
|
||||||
|
expect([...listedIds].sort()).toEqual([...clientCaseIds].sort());
|
||||||
|
expect(listedIds).toEqual([
|
||||||
|
"sev-modaintima",
|
||||||
|
"lavacao-047",
|
||||||
|
"sc-plus",
|
||||||
|
"as-servicos",
|
||||||
|
]);
|
||||||
|
const clientLinks = listedIds.map((id) => {
|
||||||
|
const card = container
|
||||||
|
.querySelector(`a[href="/cases/${id}"]`)
|
||||||
|
?.closest("article");
|
||||||
|
if (!card) throw new Error(`Missing client card ${id}`);
|
||||||
|
expect(isBefore(clientHeading, card)).toBe(true);
|
||||||
|
expect(isBefore(card, labHeading)).toBe(true);
|
||||||
|
expect(card.querySelector(".ds-bframe img")).toHaveAttribute(
|
||||||
|
"alt",
|
||||||
|
`Captura de tela do site ${getCase(id).title}`,
|
||||||
|
);
|
||||||
|
return within(card).getByRole("link", {
|
||||||
|
name: `${buttonLabels.case} ${getCase(id).title}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(clientLinks.map((link) => link.getAttribute("href"))).toEqual(
|
||||||
|
listedIds.map((id) => `/cases/${id}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// laboratório: lista compacta, uma linha por projeto (nome, o que é, "Projeto próprio" e o link), sem capa nem cartão
|
||||||
|
const labLinks = within(
|
||||||
|
labHeading.closest("section") as HTMLElement,
|
||||||
|
).getAllByRole("link", { name: /^Ver case / });
|
||||||
|
expect(labLinks.map((link) => link.getAttribute("href"))).toEqual(
|
||||||
|
labCaseIds.map((id) => `/cases/${id}`),
|
||||||
|
);
|
||||||
|
const labList = labLinks[0].closest("ul");
|
||||||
|
expect(labList?.querySelectorAll(":scope > li")).toHaveLength(
|
||||||
|
labCaseIds.length,
|
||||||
|
);
|
||||||
|
expect(labList?.querySelector("article, .ds-card, .ds-bframe")).toBeNull();
|
||||||
|
labLinks.forEach((link, index) => {
|
||||||
|
const row = link.closest("li");
|
||||||
|
const caseStudy = getCase(labCaseIds[index]);
|
||||||
|
expect(isBefore(labHeading, link)).toBe(true);
|
||||||
|
expect(link).toHaveAccessibleName(`Ver case ${caseStudy.title}`);
|
||||||
|
expect(
|
||||||
|
within(row as HTMLElement).getByRole("heading", {
|
||||||
|
level: 3,
|
||||||
|
name: caseStudy.title,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
within(row as HTMLElement).getByText(caseStudy.subtitle),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
within(row as HTMLElement).getByText("Projeto próprio"),
|
||||||
|
).toBeVisible();
|
||||||
|
// sem o resumo longo do cartão antigo
|
||||||
|
expect(row).not.toHaveTextContent(caseStudy.summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
// contato pelo WhatsApp, sem /#contact nem GitHub
|
||||||
|
const hrefs = screen
|
||||||
|
.getAllByRole("link")
|
||||||
|
.map((link) => link.getAttribute("href") ?? "");
|
||||||
|
expect(hrefs).not.toContain("/#contact");
|
||||||
|
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
||||||
|
expect(hrefs).toContain("/servicos");
|
||||||
|
const cta = screen.getByRole("link", { name: buttonLabels.contact });
|
||||||
|
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
|
||||||
|
|
||||||
|
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||||
|
name: "Trilha de navegação",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
within(visibleBreadcrumb)
|
||||||
|
.getAllByRole("link")
|
||||||
|
.map((link) => [link.textContent, link.getAttribute("href")]),
|
||||||
|
).toEqual([
|
||||||
|
["Início", "/"],
|
||||||
|
["Cases", "/cases"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const graph = readJsonLdEntities(container);
|
||||||
|
expect(graph.map((node) => node["@type"])).toEqual([
|
||||||
|
"CollectionPage",
|
||||||
|
"ItemList",
|
||||||
|
"BreadcrumbList",
|
||||||
|
]);
|
||||||
|
const itemList = graph[1] as {
|
||||||
|
itemListElement: Array<{
|
||||||
|
"@type": string;
|
||||||
|
position: number;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
const visibleItems = [...clientLinks, ...labLinks].map((link, index) => {
|
||||||
|
const href = link.getAttribute("href");
|
||||||
|
// cartão do cliente ou linha da lista do laboratório
|
||||||
|
const card = link.closest("article, li");
|
||||||
|
if (!card) throw new Error(`Missing visible card for ${href}`);
|
||||||
|
const expected = getCase((href ?? "").replace("/cases/", ""));
|
||||||
|
expect(card).toHaveTextContent(expected.title);
|
||||||
|
return {
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: index + 1,
|
||||||
|
name: expected.title,
|
||||||
|
url: `${siteUrl}${href}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(itemList.itemListElement).toEqual(visibleItems);
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
|
import {
|
||||||
|
buildBreadcrumbList,
|
||||||
|
buildCaseJsonLd,
|
||||||
|
buildPageSocialMetadata,
|
||||||
|
} from "@/lib/seo";
|
||||||
|
import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content";
|
||||||
|
import { CaseCapabilitiesSection } from "../_components/CaseCapabilitiesSection";
|
||||||
|
import { CaseContactSection } from "../_components/CaseContactSection";
|
||||||
|
import { CaseHero } from "../_components/CaseHero";
|
||||||
|
import { CaseMediaSection } from "../_components/CaseMediaSection";
|
||||||
|
import { CaseResultSection } from "../_components/CaseResultSection";
|
||||||
|
|
||||||
|
interface CasePageProps {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return caseStudies.map((caseStudy) => ({ slug: caseStudy.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: CasePageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const caseStudy = caseStudies.find((item) => item.id === slug);
|
||||||
|
|
||||||
|
if (!caseStudy) return { title: "Case não encontrado" };
|
||||||
|
|
||||||
|
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||||
|
return {
|
||||||
|
title: caseStudy.metaTitle,
|
||||||
|
description: caseStudy.metaDescription,
|
||||||
|
...buildPageSocialMetadata({
|
||||||
|
url,
|
||||||
|
title: caseStudy.metaTitle,
|
||||||
|
description: caseStudy.metaDescription,
|
||||||
|
type: "article",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CasePage({ params }: CasePageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const caseStudy = caseStudies.find((item) => item.id === slug);
|
||||||
|
if (!caseStudy) notFound();
|
||||||
|
|
||||||
|
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
|
||||||
|
const isClient = isClientCase(caseStudy);
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
buildCaseJsonLd(caseStudy),
|
||||||
|
buildBreadcrumbList(pageUrl, [
|
||||||
|
["Início", siteUrl],
|
||||||
|
["Cases", `${siteUrl}/cases`],
|
||||||
|
[caseStudy.title, pageUrl],
|
||||||
|
] as const),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||||
|
<article>
|
||||||
|
<CaseHero caseStudy={caseStudy} isClient={isClient} />
|
||||||
|
|
||||||
|
<section className="ds-section" aria-labelledby="case-context">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<div className="ds-grid-2 items-start">
|
||||||
|
<div className="max-w-[68ch]">
|
||||||
|
<h2 id="case-context" className="ds-card-title !mt-0">
|
||||||
|
{isClient
|
||||||
|
? "O cliente e o contexto"
|
||||||
|
: "O contexto do projeto"}
|
||||||
|
</h2>
|
||||||
|
<p className="ds-copy">{caseStudy.context}</p>
|
||||||
|
{caseStudy.role ? (
|
||||||
|
<p className="ds-copy">
|
||||||
|
<strong className="font-semibold text-[var(--ink)]">
|
||||||
|
Minha participação:
|
||||||
|
</strong>{" "}
|
||||||
|
{caseStudy.role}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[68ch]">
|
||||||
|
<h2 id="case-problem" className="ds-card-title !mt-0">
|
||||||
|
O problema
|
||||||
|
</h2>
|
||||||
|
<p className="ds-copy">{caseStudy.problem}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ds-section" aria-labelledby="case-done">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<div className="max-w-[860px]">
|
||||||
|
<h2 id="case-done" className="ds-card-title !mt-0">
|
||||||
|
O que foi feito
|
||||||
|
</h2>
|
||||||
|
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
|
||||||
|
{caseStudy.deliveries.map((delivery) => (
|
||||||
|
<li key={delivery}>{delivery}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<details className="mt-sp-8 border border-[var(--border)] bg-white/70 p-sp-5 md:p-sp-6">
|
||||||
|
<summary className="cursor-pointer py-sp-2 text-base font-semibold leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ink)]">
|
||||||
|
Detalhes técnicos
|
||||||
|
</summary>
|
||||||
|
<div className="max-w-[80ch]">
|
||||||
|
<p className="ds-kicker mt-sp-5">Tecnologias</p>
|
||||||
|
<ul
|
||||||
|
className="ds-tags !mt-sp-3 !pt-0"
|
||||||
|
aria-label="Tecnologias do projeto"
|
||||||
|
>
|
||||||
|
{caseStudy.stack.map((item) => (
|
||||||
|
<li key={item} className="ds-tag !whitespace-normal">
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="ds-kicker mt-sp-6">Decisões técnicas</p>
|
||||||
|
<ul className="ds-list !mt-sp-3 space-y-sp-2">
|
||||||
|
{caseStudy.decisions.map((decision) => (
|
||||||
|
<li key={decision}>{decision}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CaseMediaSection caseStudy={caseStudy} />
|
||||||
|
|
||||||
|
<CaseCapabilitiesSection caseStudy={caseStudy} isClient={isClient} />
|
||||||
|
|
||||||
|
<CaseResultSection result={caseStudy.result} />
|
||||||
|
|
||||||
|
<CaseContactSection caseStudy={caseStudy} isClient={isClient} />
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||||
|
import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de CaseCapabilitiesSection: com itens (a lista sob o título do cliente ou do laboratório, sem
|
||||||
|
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Falar sobre meu projeto"
|
||||||
|
* seguidos) e sem itens (a seção não aparece, tenha o case site no ar ou captura ou não).
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setNavigation({ pathname: "/cases" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const capabilities = [
|
||||||
|
"O dono muda os preços sem pedir a ninguém.",
|
||||||
|
"O cliente pede pelo WhatsApp com a mensagem pronta.",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("CaseCapabilitiesSection", () => {
|
||||||
|
it("path: items -> the list under the client title, with no contact button of its own", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CaseCapabilitiesSection caseStudy={{ capabilities }} isClient />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "O que o cliente passou a poder fazer",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
for (const item of capabilities) {
|
||||||
|
expect(screen.getByText(item)).toBeVisible();
|
||||||
|
}
|
||||||
|
// o botão "Falar sobre meu projeto" é do fecho do case (CaseContactSection), não desta seção
|
||||||
|
expect(screen.queryByRole("link")).toBeNull();
|
||||||
|
expect(container.querySelectorAll("a")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a laboratory case with items -> the title does not talk about a client", () => {
|
||||||
|
render(
|
||||||
|
<CaseCapabilitiesSection caseStudy={{ capabilities }} isClient={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "O que dá para fazer com ele",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.queryByText(/o cliente passou a poder fazer/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no items -> renders nothing, with no button on its own even when the case has a capture or a site", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CaseCapabilitiesSection caseStudy={{}} isClient />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: an empty list of items -> renders nothing", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CaseCapabilitiesSection caseStudy={{ capabilities: [] }} isClient />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { CaseStudyContent } from "@/lib/site-content";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam).
|
||||||
|
* Sem botão de contato: o fecho do case (CaseContactSection) vem logo depois e é o único convite ao WhatsApp, para não haver dois
|
||||||
|
* "Falar sobre meu projeto" seguidos. Sem itens, a seção não aparece.
|
||||||
|
*/
|
||||||
|
export function CaseCapabilitiesSection({
|
||||||
|
caseStudy,
|
||||||
|
isClient,
|
||||||
|
}: {
|
||||||
|
caseStudy: Pick<CaseStudyContent, "capabilities">;
|
||||||
|
isClient: boolean;
|
||||||
|
}) {
|
||||||
|
const capabilities = caseStudy.capabilities ?? [];
|
||||||
|
if (capabilities.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ds-section" aria-labelledby="case-capabilities">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<h2 id="case-capabilities" className="ds-card-title !mt-0">
|
||||||
|
{isClient
|
||||||
|
? "O que o cliente passou a poder fazer"
|
||||||
|
: "O que dá para fazer com ele"}
|
||||||
|
</h2>
|
||||||
|
<ul className="ds-list max-w-[78ch] space-y-sp-3 pl-[1.15rem]">
|
||||||
|
{capabilities.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { getRelatedServices } from "@/lib/site-content";
|
||||||
|
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||||
|
import { CaseContactSection } from "./CaseContactSection";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de CaseContactSection: o convite de um case de cliente ("projeto parecido") e o de
|
||||||
|
* um projeto de laboratório ("problema parecido" no botão), o link para a lista de cases e os serviços ligados.
|
||||||
|
* A mensagem do WhatsApp é a mesma frase nos dois: "um projeto parecido com o {título}".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const sev = { id: "sev-modaintima", title: "SEV Moda Íntima" };
|
||||||
|
const nutri = { id: "nutriscan", title: "NutriScan" };
|
||||||
|
|
||||||
|
function messageOf(link: HTMLElement) {
|
||||||
|
return new URL(link.getAttribute("href") ?? "").searchParams.get("text");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CaseContactSection", () => {
|
||||||
|
it("path: client case -> 'projeto parecido' invitation, WhatsApp with the case title, services listed", () => {
|
||||||
|
render(<CaseContactSection caseStudy={sev} isClient />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Quer um projeto parecido com este?",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
|
||||||
|
expect(cta).toHaveAttribute("target", "_blank");
|
||||||
|
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
|
||||||
|
expect(messageOf(cta)).toContain(
|
||||||
|
`Quero conversar sobre ${similarProjectSubject(sev.title)}.`,
|
||||||
|
);
|
||||||
|
expect(getRelatedServices(sev.id).length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole("link", { name: /^Ver o serviço:/ }).length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Ver outros cases" }),
|
||||||
|
).toHaveAttribute("href", "/cases");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: laboratory case -> 'problema parecido' invitation and no mention of a similar project", () => {
|
||||||
|
render(<CaseContactSection caseStudy={nutri} isClient={false} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
||||||
|
).toBeVisible();
|
||||||
|
// o rótulo do botão é o mesmo do case de cliente: uma lista só
|
||||||
|
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
|
||||||
|
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
|
||||||
|
expect(messageOf(cta)).toContain(
|
||||||
|
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
|
||||||
|
);
|
||||||
|
expect(messageOf(cta)).not.toMatch(/problema parecido|com o da |com o do /);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", {
|
||||||
|
name: /Tem um problema parecido|Conversar sobre um projeto parecido/,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
// o NutriScan não é citado por nenhum serviço: sem cartões, o convite continua
|
||||||
|
expect(getRelatedServices(nutri.id)).toHaveLength(0);
|
||||||
|
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Ver outros cases" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
primaryButton,
|
||||||
|
secondaryButtonDark,
|
||||||
|
} from "@/app/components/button-styles";
|
||||||
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
|
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||||
|
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
|
||||||
|
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||||
|
import { CaseRelatedServices } from "./CaseRelatedServices";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê.
|
||||||
|
* O botão tem o mesmo rótulo nos dois (`buttonLabels.contact`), e o assunto da conversa de WhatsApp também
|
||||||
|
* (e no botão do cabeçalho): `similarProjectSubject`.
|
||||||
|
*/
|
||||||
|
const contactCopy = {
|
||||||
|
client: { title: "Quer um projeto parecido com este?" },
|
||||||
|
lab: { title: "Fale sobre o seu caso" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Fecho do case: convite ao WhatsApp, volta para a lista de cases e os serviços ligados ao projeto. */
|
||||||
|
export function CaseContactSection({
|
||||||
|
caseStudy,
|
||||||
|
isClient,
|
||||||
|
}: {
|
||||||
|
caseStudy: Pick<CaseStudyContent, "id" | "title">;
|
||||||
|
isClient: boolean;
|
||||||
|
}) {
|
||||||
|
const copy = contactCopy[isClient ? "client" : "lab"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="ds-section ds-section-dark"
|
||||||
|
aria-labelledby="case-contact"
|
||||||
|
>
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<h2 id="case-contact" className="ds-card-title !mt-0 text-white">
|
||||||
|
{copy.title}
|
||||||
|
</h2>
|
||||||
|
<p className="ds-copy max-w-[60ch]">
|
||||||
|
Conte o que você precisa. A conversa começa pelo WhatsApp, sem
|
||||||
|
formulário.
|
||||||
|
</p>
|
||||||
|
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||||
|
<WhatsAppLink
|
||||||
|
service={similarProjectSubject(caseStudy.title)}
|
||||||
|
placement="case_detail"
|
||||||
|
className={primaryButton}
|
||||||
|
>
|
||||||
|
{buttonLabels.contact}
|
||||||
|
</WhatsAppLink>
|
||||||
|
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
||||||
|
Ver outros cases
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</div>
|
||||||
|
<CaseRelatedServices caseId={caseStudy.id} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { CaseCover, coverSizes } from "./CaseCover";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster)
|
||||||
|
* e os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||||
|
|
||||||
|
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
|
const props = {
|
||||||
|
id: "sev-modaintima",
|
||||||
|
title: "SEV Moda Íntima",
|
||||||
|
demoUrl: "https://sevmodaintima.com.br",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedGetCaseMedia.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CaseCover", () => {
|
||||||
|
it("path: no desktop capture -> renders nothing", () => {
|
||||||
|
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||||
|
const { container } = render(<CaseCover {...props} />);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: capture found -> poster inside the browser frame, with the real domain in the bar", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||||
|
const { container } = render(<CaseCover {...props} />);
|
||||||
|
|
||||||
|
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
|
||||||
|
expect(container.querySelector(".ds-bframe")).toContainElement(image);
|
||||||
|
expect(image.getAttribute("src")).toContain(
|
||||||
|
encodeURIComponent(`/videos/${props.id}/desktop.webp`),
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||||
|
"sevmodaintima.com.br",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: sizes and priority omitted -> default sizes and lazy loading", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||||
|
render(<CaseCover {...props} />);
|
||||||
|
|
||||||
|
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
|
||||||
|
expect(image).toHaveAttribute("sizes", coverSizes.card);
|
||||||
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
|
expect(image).not.toHaveAttribute("fetchpriority");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: sizes and priority given -> uses them, with eager loading and high fetch priority", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||||
|
render(<CaseCover {...props} sizes="320px" priority />);
|
||||||
|
|
||||||
|
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
|
||||||
|
expect(image).toHaveAttribute("sizes", "320px");
|
||||||
|
expect(image).toHaveAttribute("loading", "eager");
|
||||||
|
expect(image).toHaveAttribute("fetchpriority", "high");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no 100vw sizes -> every preset describes the width the cover really has", () => {
|
||||||
|
// 100vw baixava a imagem de 750 px para uma capa de 300 px no celular
|
||||||
|
for (const sizes of Object.values(coverSizes)) {
|
||||||
|
expect(sizes).not.toMatch(/(^|, )100vw$/);
|
||||||
|
expect(sizes).toMatch(/calc\(100vw - \d+px\)$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import {
|
||||||
|
BrowserFrame,
|
||||||
|
displayUrl,
|
||||||
|
} from "@/app/components/showcase/BrowserFrame";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
|
||||||
|
* (com 100vw, um celular de 390 px pedia a versão de 750 px para uma capa de 300 px). As contas seguem o
|
||||||
|
* padding das seções (20 px até 768 px; depois 6vw, no máximo 64 px), a coluna central de 1240 px e os cartões
|
||||||
|
* (borda e 20 a 28 px de padding).
|
||||||
|
*/
|
||||||
|
export const coverSizes = {
|
||||||
|
/** Topo do case e do serviço: coluna da direita a partir de 1024 px, e a largura da página abaixo disso. */
|
||||||
|
hero: "(min-width: 1440px) 560px, (min-width: 1024px) 40vw, (min-width: 769px) 88vw, calc(100vw - 40px)",
|
||||||
|
/** Capa dentro de um cartão (`ds-card`) da grade de duas colunas, que vira uma só até 1024 px. */
|
||||||
|
card: "(min-width: 1440px) 560px, (min-width: 1025px) calc(50vw - 134px), (min-width: 769px) calc(88vw - 56px), calc(100vw - 88px)",
|
||||||
|
/** Capa solta numa grade de duas colunas, sem cartão em volta (a partir de 768 px). */
|
||||||
|
column:
|
||||||
|
"(min-width: 1440px) 604px, (min-width: 1068px) calc(50vw - 80px), (min-width: 768px) calc(44vw - 16px), calc(100vw - 40px)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props da imagem que abre a página (a maior acima da dobra): carrega na hora e com prioridade de rede alta.
|
||||||
|
* No Next 16 a prop `priority` do `Image` só gera o `<link rel="preload">`; a prioridade de rede vem do `fetchPriority`.
|
||||||
|
*/
|
||||||
|
export const priorityImageProps = {
|
||||||
|
loading: "eager",
|
||||||
|
fetchPriority: "high",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada.
|
||||||
|
* `priority` é para a imagem que abre a página: aplica `priorityImageProps`.
|
||||||
|
*/
|
||||||
|
export function CaseCover({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
demoUrl,
|
||||||
|
sizes = coverSizes.card,
|
||||||
|
priority = false,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
demoUrl?: string | null;
|
||||||
|
sizes?: string;
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
|
const media = getCaseMedia(id, "desktop");
|
||||||
|
if (!media) return null;
|
||||||
|
const priorityProps = priority ? priorityImageProps : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserFrame url={displayUrl({ demoUrl, title })}>
|
||||||
|
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
|
||||||
|
<Image
|
||||||
|
src={media.poster}
|
||||||
|
alt={`Captura de tela do site ${title}`}
|
||||||
|
fill
|
||||||
|
sizes={sizes}
|
||||||
|
{...priorityProps}
|
||||||
|
className="object-cover object-top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { coverSizes } from "./CaseCover";
|
||||||
|
import { CaseCoverLink } from "./CaseCoverLink";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de CaseCoverLink: sem captura (nada), com captura (link fora da leitura e do teclado,
|
||||||
|
* com a capa dentro), os valores padrão de `sizes` e `priority`, o pré-carregamento só ao chegar no link
|
||||||
|
* e os valores dados.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
|
const props = {
|
||||||
|
id: "sev-modaintima",
|
||||||
|
title: "SEV Moda Íntima",
|
||||||
|
demoUrl: "https://sevmodaintima.com.br",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedGetCaseMedia.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CaseCoverLink", () => {
|
||||||
|
it("path: no desktop capture -> renders nothing, not even the link", () => {
|
||||||
|
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||||
|
const { container } = render(<CaseCoverLink {...props} />);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: capture found -> a link to the case, out of reading order and tab order, around the cover", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||||
|
const { container } = render(<CaseCoverLink {...props} />);
|
||||||
|
|
||||||
|
const link = container.querySelector("a");
|
||||||
|
expect(link).toHaveAttribute("href", `/cases/${props.id}`);
|
||||||
|
expect(link).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect(link).toHaveAttribute("tabindex", "-1");
|
||||||
|
expect(link?.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||||
|
"sevmodaintima.com.br",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByAltText(`Captura de tela do site ${props.title}`),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: sizes and priority omitted -> default sizes and lazy image", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||||
|
render(<CaseCoverLink {...props} />);
|
||||||
|
|
||||||
|
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
|
||||||
|
expect(image).toHaveAttribute("sizes", coverSizes.card);
|
||||||
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
|
expect(image).not.toHaveAttribute("fetchpriority");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: link -> pre-fetches the case only when the pointer or focus reaches it", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||||
|
const { container } = render(<CaseCoverLink {...props} />);
|
||||||
|
|
||||||
|
const link = container.querySelector("a");
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "false");
|
||||||
|
fireEvent.mouseEnter(link as HTMLAnchorElement);
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: sizes and priority given -> both are used", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||||
|
render(<CaseCoverLink {...props} sizes="320px" priority />);
|
||||||
|
|
||||||
|
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
|
||||||
|
expect(image).toHaveAttribute("sizes", "320px");
|
||||||
|
expect(image).toHaveAttribute("loading", "eager");
|
||||||
|
expect(image).toHaveAttribute("fetchpriority", "high");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no demo address -> the browser bar falls back to the project title", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||||
|
const { container } = render(
|
||||||
|
<CaseCoverLink id={props.id} title={props.title} demoUrl={null} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||||
|
props.title,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
|
import { CaseCover, coverSizes } from "./CaseCover";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capa do case dentro de um cartão: a imagem leva ao case, mas fica fora da leitura e da ordem de tabulação,
|
||||||
|
* porque o link de texto do mesmo cartão já leva ao mesmo lugar. Sem captura gravada, não mostra nada.
|
||||||
|
* A rota do case só é pré-carregada quando a pessoa chega no link (HoverPrefetchLink).
|
||||||
|
*/
|
||||||
|
export function CaseCoverLink({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
demoUrl,
|
||||||
|
sizes = coverSizes.card,
|
||||||
|
priority = false,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
demoUrl?: string | null;
|
||||||
|
sizes?: string;
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!getCaseMedia(id, "desktop")) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href={`/cases/${id}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="mb-sp-5 block"
|
||||||
|
>
|
||||||
|
<CaseCover
|
||||||
|
id={id}
|
||||||
|
title={title}
|
||||||
|
demoUrl={demoUrl}
|
||||||
|
sizes={sizes}
|
||||||
|
priority={priority}
|
||||||
|
/>
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { caseStudies } from "@/lib/site-content";
|
||||||
|
import { CaseHero } from "./CaseHero";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
import { compactHero } from "./hero-styles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de CaseHero e dos fatos do projeto: com e sem captura gravada, case de cliente e de
|
||||||
|
* laboratório, e a lista de fatos ausente, vazia ou preenchida. A existência da captura é simulada em getCaseMedia.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||||
|
|
||||||
|
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
|
|
||||||
|
function getCase(id: string) {
|
||||||
|
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||||
|
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||||
|
return caseStudy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getCase("sev-modaintima");
|
||||||
|
const lab = getCase("twins-lab");
|
||||||
|
|
||||||
|
function withCapture(has: boolean) {
|
||||||
|
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||||
|
has ? getShowcaseAssets(id, device) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedGetCaseMedia.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CaseHero: captura gravada e tipo de case", () => {
|
||||||
|
it("path: client case with a capture -> two columns, the cover on the right, the client eyebrow", () => {
|
||||||
|
withCapture(true);
|
||||||
|
const { container } = render(<CaseHero caseStudy={client} isClient />);
|
||||||
|
|
||||||
|
expect(container.querySelector(".ds-section-shell")).toHaveClass("grid");
|
||||||
|
expect(container.querySelector(".ds-bframe")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Case de cliente/)).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 1, name: client.headline }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: client case without a capture -> one column and no cover", () => {
|
||||||
|
withCapture(false);
|
||||||
|
const { container } = render(<CaseHero caseStudy={client} isClient />);
|
||||||
|
|
||||||
|
expect(container.querySelector(".ds-section-shell")).not.toHaveClass(
|
||||||
|
"grid",
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||||
|
expect(screen.getByText(client.summary)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: laboratory case with a capture -> the cover shows, with the laboratory eyebrow", () => {
|
||||||
|
withCapture(true);
|
||||||
|
const { container } = render(<CaseHero caseStudy={lab} isClient={false} />);
|
||||||
|
|
||||||
|
expect(container.querySelector(".ds-bframe")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
|
||||||
|
expect(screen.queryByText(/Case de cliente/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: laboratory case without a capture -> one column, laboratory eyebrow, title and summary", () => {
|
||||||
|
withCapture(false);
|
||||||
|
const { container } = render(<CaseHero caseStudy={lab} isClient={false} />);
|
||||||
|
|
||||||
|
expect(container.querySelector(".ds-section-shell")).not.toHaveClass(
|
||||||
|
"grid",
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||||
|
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
|
||||||
|
expect(screen.getByText(lab.summary)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: with a capture -> the top strip keeps the default height", () => {
|
||||||
|
withCapture(true);
|
||||||
|
const { container } = render(<CaseHero caseStudy={client} isClient />);
|
||||||
|
|
||||||
|
expect(container.querySelector("section")).not.toHaveClass(compactHero);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: without a capture -> the top strip is the lower one-column strip, for client and laboratory cases", () => {
|
||||||
|
withCapture(false);
|
||||||
|
for (const [caseStudy, isClient] of [
|
||||||
|
[client, true],
|
||||||
|
[lab, false],
|
||||||
|
] as const) {
|
||||||
|
const { container, unmount } = render(
|
||||||
|
<CaseHero caseStudy={caseStudy} isClient={isClient} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("section")).toHaveClass(compactHero);
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: breadcrumb -> Início, Cases and the case itself as the current page", () => {
|
||||||
|
withCapture(false);
|
||||||
|
render(<CaseHero caseStudy={client} isClient />);
|
||||||
|
|
||||||
|
const links = screen.getAllByRole("link");
|
||||||
|
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||||
|
"/",
|
||||||
|
"/cases",
|
||||||
|
`/cases/${client.id}`,
|
||||||
|
]);
|
||||||
|
expect(links[2]).toHaveAttribute("aria-current", "page");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CaseHero: fatos do projeto", () => {
|
||||||
|
const withFacts = (facts: string[] | undefined) => ({
|
||||||
|
...client,
|
||||||
|
facts,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no facts field -> no list", () => {
|
||||||
|
withCapture(false);
|
||||||
|
render(<CaseHero caseStudy={withFacts(undefined)} isClient />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: empty facts list -> no list", () => {
|
||||||
|
withCapture(false);
|
||||||
|
render(<CaseHero caseStudy={withFacts([])} isClient />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: filled facts -> one item each, after the summary", () => {
|
||||||
|
withCapture(false);
|
||||||
|
render(
|
||||||
|
<CaseHero
|
||||||
|
caseStudy={withFacts(["No ar desde 2025", "Dois painéis"])}
|
||||||
|
isClient
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = screen.getByRole("list", { name: "Fatos do projeto" });
|
||||||
|
expect(list.querySelectorAll("li")).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
screen.getByText(client.summary).compareDocumentPosition(list) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||||
|
import { pageTop } from "@/app/components/button-styles";
|
||||||
|
import type { CaseStudyContent } from "@/lib/site-content";
|
||||||
|
import { CaseCover, coverSizes } from "./CaseCover";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
import { compactHero } from "./hero-styles";
|
||||||
|
|
||||||
|
/** Fatos verificáveis do projeto. Sem nenhum, a lista não aparece. */
|
||||||
|
function CaseFacts({ facts }: { facts?: string[] }) {
|
||||||
|
if (!facts?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className="mt-sp-5 flex flex-wrap gap-x-sp-6 gap-y-sp-2 text-[15px] leading-6 text-white/80"
|
||||||
|
aria-label="Fatos do projeto"
|
||||||
|
>
|
||||||
|
{facts.map((fact) => (
|
||||||
|
<li key={fact}>{fact}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pôster do site na coluna da direita (no celular, logo abaixo do título). */
|
||||||
|
function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
|
||||||
|
<CaseCover
|
||||||
|
id={caseStudy.id}
|
||||||
|
title={caseStudy.title}
|
||||||
|
demoUrl={caseStudy.demoUrl}
|
||||||
|
sizes={coverSizes.hero}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topo do case: trilha, título, resumo e fatos. Com captura gravada, o texto fica à esquerda e o pôster à direita;
|
||||||
|
* no celular a mídia vem logo abaixo do título. Sem captura, o topo é uma coluna só e mais baixo.
|
||||||
|
*/
|
||||||
|
export function CaseHero({
|
||||||
|
caseStudy,
|
||||||
|
isClient,
|
||||||
|
}: {
|
||||||
|
caseStudy: CaseStudyContent;
|
||||||
|
isClient: boolean;
|
||||||
|
}) {
|
||||||
|
const hasMedia = Boolean(getCaseMedia(caseStudy.id, "desktop"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`ds-section ds-section-dark ${pageTop} ${
|
||||||
|
hasMedia ? "" : compactHero
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`ds-section-shell ${
|
||||||
|
hasMedia
|
||||||
|
? "grid items-center gap-y-sp-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-x-sp-16 lg:gap-y-0"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="max-w-[860px] lg:col-start-1 lg:row-start-1 lg:self-end">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Início", href: "/" },
|
||||||
|
{ label: "Cases", href: "/cases" },
|
||||||
|
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p className="ds-eyebrow">
|
||||||
|
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
|
||||||
|
{caseStudy.title}
|
||||||
|
</p>
|
||||||
|
<h1 className="ds-section-title text-balance text-white">
|
||||||
|
{caseStudy.headline}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{hasMedia ? <CaseHeroCover caseStudy={caseStudy} /> : null}
|
||||||
|
<div className="max-w-[68ch] lg:col-start-1 lg:row-start-2 lg:self-start">
|
||||||
|
<p className="ds-copy !text-[rgba(255,255,255,.78)]">
|
||||||
|
{caseStudy.summary}
|
||||||
|
</p>
|
||||||
|
<CaseFacts facts={caseStudy.facts} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { CaseMedia } from "./CaseMedia";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de CaseMedia: com computador e celular, só um dos dois, ou nenhum.
|
||||||
|
* Cada quadro mostra o pôster com um botão de play; o vídeo só aparece depois do clique.
|
||||||
|
* A existência dos arquivos em disco é simulada em getCaseMedia, para o teste não depender de public/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||||
|
|
||||||
|
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
|
|
||||||
|
function withCaptures(devices: ReadonlyArray<"desktop" | "mobile">) {
|
||||||
|
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||||
|
devices.includes(device) ? getShowcaseAssets(id, device) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
id: "sev-modaintima",
|
||||||
|
title: "SEV Moda Íntima",
|
||||||
|
demoUrl: "https://sevmodaintima.com.br",
|
||||||
|
};
|
||||||
|
const desktopPlay = `Reproduzir o vídeo do site ${props.title} no computador`;
|
||||||
|
const mobilePlay = `Reproduzir o vídeo do site ${props.title} no celular`;
|
||||||
|
const desktopVideo = `Vídeo do site ${props.title} no computador`;
|
||||||
|
const mobileVideo = `Vídeo do site ${props.title} no celular`;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedGetCaseMedia.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CaseMedia", () => {
|
||||||
|
it("path: desktop and mobile captures -> both frames, the browser bar shows the real domain", () => {
|
||||||
|
withCaptures(["desktop", "mobile"]);
|
||||||
|
const { container } = render(<CaseMedia {...props} />);
|
||||||
|
|
||||||
|
expect(container.querySelector(".ds-bframe")).toContainElement(
|
||||||
|
screen.getByRole("button", { name: desktopPlay }),
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".ds-phone")).toContainElement(
|
||||||
|
screen.getByRole("button", { name: mobilePlay }),
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||||
|
"sevmodaintima.com.br",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(`O site ${props.title} no computador.`),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.getByText("No celular.")).toBeVisible();
|
||||||
|
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
|
||||||
|
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "mobile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no capture at all -> renders nothing", () => {
|
||||||
|
withCaptures([]);
|
||||||
|
const { container } = render(<CaseMedia {...props} />);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: only the mobile capture -> phone frame without the browser frame", () => {
|
||||||
|
withCaptures(["mobile"]);
|
||||||
|
const { container } = render(<CaseMedia {...props} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: mobilePlay }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: desktopPlay })).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-phone")).not.toBeNull();
|
||||||
|
expect(screen.queryByText(/no computador\./)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: only the desktop capture -> browser frame without the phone frame", () => {
|
||||||
|
withCaptures(["desktop"]);
|
||||||
|
const { container } = render(<CaseMedia {...props} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: desktopPlay }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: mobilePlay })).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-phone")).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-bframe")).not.toBeNull();
|
||||||
|
expect(screen.queryByText("No celular.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: both captures -> no video until play, then only the pressed frame plays", () => {
|
||||||
|
withCaptures(["desktop", "mobile"]);
|
||||||
|
const { container } = render(<CaseMedia {...props} />);
|
||||||
|
|
||||||
|
expect(container.querySelector("video")).toBeNull();
|
||||||
|
// o pôster do computador vem com texto alternativo vazio: o botão já descreve o quadro
|
||||||
|
expect(container.querySelector(".ds-bframe img")).toHaveAttribute(
|
||||||
|
"alt",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: mobilePlay }));
|
||||||
|
|
||||||
|
const phone = screen.getByLabelText(mobileVideo);
|
||||||
|
expect(container.querySelector(".ds-phone")).toContainElement(phone);
|
||||||
|
expect(phone).toHaveAttribute("poster", `/videos/${props.id}/mobile.webp`);
|
||||||
|
expect(phone.querySelectorAll("source")).toHaveLength(2);
|
||||||
|
expect(screen.queryByLabelText(desktopVideo)).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: desktopPlay }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(container.querySelectorAll("video")).toHaveLength(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: desktopPlay }));
|
||||||
|
expect(screen.getByLabelText(desktopVideo)).toHaveAttribute(
|
||||||
|
"poster",
|
||||||
|
`/videos/${props.id}/desktop.webp`,
|
||||||
|
);
|
||||||
|
expect(container.querySelectorAll("video")).toHaveLength(2);
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no demo address -> the browser bar falls back to the project title", () => {
|
||||||
|
withCaptures(["desktop"]);
|
||||||
|
const { container } = render(
|
||||||
|
<CaseMedia id={props.id} title={props.title} demoUrl={null} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||||
|
props.title,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
BrowserFrame,
|
||||||
|
displayUrl,
|
||||||
|
} from "@/app/components/showcase/BrowserFrame";
|
||||||
|
import { PhoneFrame } from "@/app/components/showcase/PhoneFrame";
|
||||||
|
import { CasePlayer } from "./CasePlayer";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pôster e vídeo do projeto: o site no computador (moldura de navegador) e no celular (moldura de celular).
|
||||||
|
* Cada quadro mostra o pôster com um botão de play; o vídeo só baixa quando a pessoa aperta o botão.
|
||||||
|
* Sem captura gravada, não mostra nada.
|
||||||
|
*/
|
||||||
|
export function CaseMedia({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
demoUrl,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
demoUrl?: string | null;
|
||||||
|
}) {
|
||||||
|
const desktop = getCaseMedia(id, "desktop");
|
||||||
|
const mobile = getCaseMedia(id, "mobile");
|
||||||
|
if (!desktop && !mobile) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-sp-8 grid max-w-[1040px] items-end gap-sp-8 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
{desktop ? (
|
||||||
|
<figure className="min-w-0">
|
||||||
|
<BrowserFrame url={displayUrl({ demoUrl, title })}>
|
||||||
|
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
|
||||||
|
<CasePlayer assets={desktop} title={title} device="desktop" />
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
<figcaption className="mt-sp-3 font-[family-name:var(--font-body)] text-[15px] text-white/70">
|
||||||
|
O site {title} no computador.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
) : null}
|
||||||
|
{mobile ? (
|
||||||
|
<figure className="mx-auto w-[min(62vw,220px)] md:mx-0">
|
||||||
|
<PhoneFrame poster={mobile.poster}>
|
||||||
|
<CasePlayer assets={mobile} title={title} device="mobile" />
|
||||||
|
</PhoneFrame>
|
||||||
|
<figcaption className="mt-sp-3 text-center font-[family-name:var(--font-body)] text-[15px] text-white/70">
|
||||||
|
No celular.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { secondaryButtonDark } from "@/app/components/button-styles";
|
||||||
|
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
|
||||||
|
import { CaseMedia } from "./CaseMedia";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
|
||||||
|
/** O bloco "Como ficou" existe quando há site no ar ou captura gravada, no computador ou no celular. */
|
||||||
|
export function hasCaseMedia({
|
||||||
|
id,
|
||||||
|
demoUrl,
|
||||||
|
}: Pick<CaseStudyContent, "id" | "demoUrl">) {
|
||||||
|
return (
|
||||||
|
Boolean(demoUrl) ||
|
||||||
|
Boolean(getCaseMedia(id, "desktop")) ||
|
||||||
|
Boolean(getCaseMedia(id, "mobile"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Como ficou": pôster e vídeo do projeto e o botão para abrir o site. Sem site nem captura, não mostra nada. */
|
||||||
|
export function CaseMediaSection({
|
||||||
|
caseStudy,
|
||||||
|
}: {
|
||||||
|
caseStudy: Pick<CaseStudyContent, "id" | "title" | "demoUrl">;
|
||||||
|
}) {
|
||||||
|
if (!hasCaseMedia(caseStudy)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="ds-section ds-section-dark"
|
||||||
|
aria-labelledby="case-media"
|
||||||
|
>
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<h2 id="case-media" className="ds-card-title !mt-0 text-white">
|
||||||
|
Como ficou
|
||||||
|
</h2>
|
||||||
|
<CaseMedia
|
||||||
|
id={caseStudy.id}
|
||||||
|
title={caseStudy.title}
|
||||||
|
demoUrl={caseStudy.demoUrl}
|
||||||
|
/>
|
||||||
|
{caseStudy.demoUrl ? (
|
||||||
|
<div className="mt-sp-8">
|
||||||
|
<a
|
||||||
|
className={secondaryButtonDark}
|
||||||
|
href={caseStudy.demoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{buttonLabels.site}
|
||||||
|
<span className="sr-only"> {caseStudy.title}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { CasePlayer } from "./CasePlayer";
|
||||||
|
|
||||||
|
const title = "SEV Moda Íntima";
|
||||||
|
|
||||||
|
describe("CasePlayer", () => {
|
||||||
|
it("shows the poster and an accessible play button instead of a paused video", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<div className="relative">
|
||||||
|
<CasePlayer
|
||||||
|
assets={getShowcaseAssets("sev-modaintima", "desktop")}
|
||||||
|
title={title}
|
||||||
|
device="desktop"
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("video")).toBeNull();
|
||||||
|
// a imagem é decorativa: o botão já diz o que o quadro mostra
|
||||||
|
const poster = container.querySelector("img");
|
||||||
|
expect(poster).toHaveAttribute("alt", "");
|
||||||
|
expect(poster?.getAttribute("src")).toContain(
|
||||||
|
encodeURIComponent("/videos/sev-modaintima/desktop.webp"),
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: `Reproduzir o vídeo do site ${title} no computador`,
|
||||||
|
});
|
||||||
|
expect(button).toHaveAttribute("type", "button");
|
||||||
|
// só botão de contato é limão: o de play é escuro, com contorno branco
|
||||||
|
expect(button.innerHTML).not.toContain("--slash");
|
||||||
|
expect(button.querySelector("[aria-hidden='true']")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
|
||||||
|
const assets = getShowcaseAssets("lavacao-047", "desktop");
|
||||||
|
render(
|
||||||
|
<div className="relative">
|
||||||
|
<CasePlayer assets={assets} title="Lavação 047" device="desktop" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
|
||||||
|
const video = screen.getByLabelText(
|
||||||
|
"Vídeo do site Lavação 047 no computador",
|
||||||
|
);
|
||||||
|
expect(video).toHaveFocus();
|
||||||
|
expect(video).toHaveAttribute("controls");
|
||||||
|
expect(video).toHaveAttribute("poster", assets.poster);
|
||||||
|
expect(video).toHaveAttribute("width", String(assets.width));
|
||||||
|
expect(video).toHaveAttribute("height", String(assets.height));
|
||||||
|
expect((video as HTMLVideoElement).muted).toBe(true);
|
||||||
|
expect((video as HTMLVideoElement).loop).toBe(true);
|
||||||
|
expect(
|
||||||
|
[...video.querySelectorAll("source")].map((source) => [
|
||||||
|
source.getAttribute("src"),
|
||||||
|
source.getAttribute("type"),
|
||||||
|
]),
|
||||||
|
).toEqual(assets.sources.map(({ src, type }) => [src, type]));
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the poster to the phone frame on mobile and names the device in the labels", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<div className="relative">
|
||||||
|
<CasePlayer
|
||||||
|
assets={getShowcaseAssets("sc-plus", "mobile")}
|
||||||
|
title="SC Plus Aquicultura"
|
||||||
|
device="mobile"
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("img")).toBeNull();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Reproduzir o vídeo do site SC Plus Aquicultura no celular",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText("Vídeo do site SC Plus Aquicultura no celular"),
|
||||||
|
).toHaveAttribute("poster", "/videos/sc-plus/mobile.webp");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Play } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Device, ShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
|
||||||
|
const deviceLabel: Record<Device, string> = {
|
||||||
|
desktop: "no computador",
|
||||||
|
mobile: "no celular",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pôster com um botão de play. O vídeo só é montado, e só baixa, quando a pessoa aperta o botão:
|
||||||
|
* antes disso a página mostra a imagem do site, não um vídeo parado em 0:00 com controles do navegador.
|
||||||
|
* Vai dentro de um quadro com `position: relative` (moldura de navegador ou de celular) que dá o tamanho.
|
||||||
|
* `sizes` segue a grade de CaseMedia: a coluna da esquerda de um bloco de até 1040 px (88 vw da página abaixo de 1182 px)
|
||||||
|
* com a moldura do celular ao lado (220 px mais 32 px de vão); abaixo de 768 px, a largura da página menos 40 px.
|
||||||
|
*/
|
||||||
|
export function CasePlayer({
|
||||||
|
assets,
|
||||||
|
title,
|
||||||
|
device,
|
||||||
|
sizes = "(min-width: 1182px) 788px, (min-width: 768px) calc(88vw - 252px), calc(100vw - 40px)",
|
||||||
|
}: {
|
||||||
|
assets: ShowcaseAssets;
|
||||||
|
title: string;
|
||||||
|
device: Device;
|
||||||
|
sizes?: string;
|
||||||
|
}) {
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
// o botão sai de cena ao tocar: o foco vai para o vídeo, para quem usa o teclado não o perder
|
||||||
|
useEffect(() => {
|
||||||
|
if (playing) videoRef.current?.focus();
|
||||||
|
}, [playing]);
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover object-top"
|
||||||
|
autoPlay
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
poster={assets.poster}
|
||||||
|
width={assets.width}
|
||||||
|
height={assets.height}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Vídeo do site ${title} ${deviceLabel[device]}`}
|
||||||
|
disablePictureInPicture
|
||||||
|
>
|
||||||
|
{assets.sources.map((source) => (
|
||||||
|
<source key={source.src} src={source.src} type={source.type} />
|
||||||
|
))}
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* o quadro do celular já mostra o pôster como fundo; no computador a imagem entra aqui */}
|
||||||
|
{device === "desktop" ? (
|
||||||
|
<Image
|
||||||
|
src={assets.poster}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes={sizes}
|
||||||
|
className="object-cover object-top"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPlaying(true)}
|
||||||
|
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
|
||||||
|
className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-colors group-hover:bg-black/85 motion-reduce:transition-none sm:size-16"
|
||||||
|
>
|
||||||
|
<Play className="ml-1 size-6" fill="currentColor" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { textLinkDark } from "@/app/components/button-styles";
|
||||||
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
|
import { getRelatedServices } from "@/lib/site-content";
|
||||||
|
|
||||||
|
/** Quantos serviços aparecem no fim do case: dois cards bastam para levar a pessoa ao serviço certo sem virar um menu. */
|
||||||
|
export const MAX_RELATED_SERVICES = 2;
|
||||||
|
|
||||||
|
/** Cards dos serviços que citam este case (no máximo dois), sobre fundo escuro. Sem serviço relacionado, não mostra nada. */
|
||||||
|
export function CaseRelatedServices({ caseId }: { caseId: string }) {
|
||||||
|
const services = getRelatedServices(caseId).slice(0, MAX_RELATED_SERVICES);
|
||||||
|
if (services.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-sp-10">
|
||||||
|
<p className="ds-kicker">Serviços ligados a este projeto</p>
|
||||||
|
<ul className="mt-sp-4 grid gap-sp-4 md:grid-cols-2">
|
||||||
|
{services.map((service) => (
|
||||||
|
<li key={service.slug} className="flex">
|
||||||
|
<article className="ds-card w-full">
|
||||||
|
<h3 className="ds-card-title !mt-0">{service.navLabel}</h3>
|
||||||
|
<p className="ds-copy">{service.intro}</p>
|
||||||
|
<HoverPrefetchLink
|
||||||
|
className={`${textLinkDark} mt-sp-4 self-start`}
|
||||||
|
href={`/servicos/${service.slug}`}
|
||||||
|
>
|
||||||
|
Ver o serviço: {service.navLabel}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** Resultado do case, só com dado real e autorizado. Sem dado, a seção não aparece. */
|
||||||
|
export function CaseResultSection({ result }: { result?: string[] }) {
|
||||||
|
if (!result || result.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ds-section" aria-labelledby="case-result">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<h2 id="case-result" className="ds-card-title !mt-0">
|
||||||
|
Resultado
|
||||||
|
</h2>
|
||||||
|
<ul className="ds-list max-w-[78ch] space-y-sp-3 pl-[1.15rem]">
|
||||||
|
{result.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { coverSizes } from "./CaseCover";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
import { getMosaicPosters, MOSAIC_SIZE, PosterMosaic } from "./PosterMosaic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mosaico de pôsteres do topo de /servicos e /cases: quais pôsteres entram (só os com captura, no máximo três),
|
||||||
|
* e como cada imagem carrega (a primeira, a maior, na hora e com prioridade alta; as outras só quando aparecem).
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||||
|
|
||||||
|
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedGetCaseMedia.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMosaicPosters", () => {
|
||||||
|
it("path: every id has a capture -> keeps the order and stops at three", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||||
|
|
||||||
|
const posters = getMosaicPosters(["a", "b", "c", "d"]);
|
||||||
|
|
||||||
|
expect(MOSAIC_SIZE).toBe(3);
|
||||||
|
expect(posters).toEqual([
|
||||||
|
{ id: "a", poster: "/videos/a/desktop.webp" },
|
||||||
|
{ id: "b", poster: "/videos/b/desktop.webp" },
|
||||||
|
{ id: "c", poster: "/videos/c/desktop.webp" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: some ids have no capture -> skips them and fills with the next ones", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||||
|
id === "b" ? undefined : getShowcaseAssets(id, device),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getMosaicPosters(["a", "b", "c", "d"]).map(({ id }) => id)).toEqual([
|
||||||
|
"a",
|
||||||
|
"c",
|
||||||
|
"d",
|
||||||
|
]);
|
||||||
|
expect(mockedGetCaseMedia).toHaveBeenCalledWith("b", "desktop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no id has a capture -> an empty list", () => {
|
||||||
|
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
expect(getMosaicPosters(["a", "b"])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PosterMosaic", () => {
|
||||||
|
const three = ["a", "b", "c"].map((id) => ({
|
||||||
|
id,
|
||||||
|
poster: `/videos/${id}/desktop.webp`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("path: no posters -> renders nothing", () => {
|
||||||
|
const { container } = render(<PosterMosaic posters={[]} />);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: three posters -> decorative, the first one eager with high priority and the real column width", () => {
|
||||||
|
const { container } = render(<PosterMosaic posters={three} />);
|
||||||
|
|
||||||
|
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
|
||||||
|
const images = Array.from(container.querySelectorAll("img"));
|
||||||
|
expect(images).toHaveLength(3);
|
||||||
|
for (const image of images) {
|
||||||
|
expect(image).toHaveAttribute("alt", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [main, ...side] = images;
|
||||||
|
expect(main).toHaveAttribute("loading", "eager");
|
||||||
|
expect(main).toHaveAttribute("fetchpriority", "high");
|
||||||
|
expect(main).toHaveAttribute("sizes", coverSizes.hero);
|
||||||
|
expect(main.closest("div")).toHaveClass("col-span-2");
|
||||||
|
for (const image of side) {
|
||||||
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
|
expect(image).not.toHaveAttribute("fetchpriority");
|
||||||
|
// metade da coluna, e nunca a largura toda da tela
|
||||||
|
expect(image.getAttribute("sizes")).toBe(
|
||||||
|
"(min-width: 1440px) 274px, 20vw",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the smaller posters are hidden below 1024 px, so they are not downloaded on a phone", () => {
|
||||||
|
const { container } = render(<PosterMosaic posters={three} />);
|
||||||
|
|
||||||
|
const [main, ...side] = Array.from(container.querySelectorAll("img")).map(
|
||||||
|
(image) => image.closest("div"),
|
||||||
|
);
|
||||||
|
expect(main).not.toHaveClass("hidden");
|
||||||
|
for (const frame of side) {
|
||||||
|
expect(frame).toHaveClass("hidden", "lg:block");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a single poster -> only the main one, with no side posters", () => {
|
||||||
|
const { container } = render(<PosterMosaic posters={three.slice(0, 1)} />);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll("img")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { coverSizes, priorityImageProps } from "./CaseCover";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
|
||||||
|
export interface MosaicPoster {
|
||||||
|
id: string;
|
||||||
|
poster: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quantos pôsteres entram no mosaico: um grande em cima e dois menores embaixo. */
|
||||||
|
export const MOSAIC_SIZE = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pôsteres gravados dos cases, na ordem dada, só dos que têm captura no disco (no máximo `MOSAIC_SIZE`).
|
||||||
|
* Roda no servidor, como `getCaseMedia`.
|
||||||
|
*/
|
||||||
|
export function getMosaicPosters(ids: readonly string[]): MosaicPoster[] {
|
||||||
|
return ids
|
||||||
|
.flatMap((id) => {
|
||||||
|
const media = getCaseMedia(id, "desktop");
|
||||||
|
return media ? [{ id, poster: media.poster }] : [];
|
||||||
|
})
|
||||||
|
.slice(0, MOSAIC_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame =
|
||||||
|
"relative aspect-[16/10] overflow-hidden border border-white/15 bg-[#0a0a0a]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mosaico de pôsteres dos sites no topo das páginas internas, na coluna que ficaria vazia ao lado do título.
|
||||||
|
* É decorativo (texto alternativo vazio): quem lê os cases tem os links logo abaixo. O primeiro pôster é o maior
|
||||||
|
* e o que abre a página, então carrega na hora e com prioridade alta; no celular só ele aparece, e os outros dois
|
||||||
|
* (que não são baixados enquanto estão ocultos) entram a partir de 1024 px.
|
||||||
|
*/
|
||||||
|
export function PosterMosaic({
|
||||||
|
posters,
|
||||||
|
}: {
|
||||||
|
posters: readonly MosaicPoster[];
|
||||||
|
}) {
|
||||||
|
const [main, ...others] = posters;
|
||||||
|
if (!main) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-w-0 grid-cols-2 gap-sp-3" aria-hidden="true">
|
||||||
|
<div className={`${frame} col-span-2`}>
|
||||||
|
<Image
|
||||||
|
src={main.poster}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes={coverSizes.hero}
|
||||||
|
{...priorityImageProps}
|
||||||
|
className="object-cover object-top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{others.map(({ id, poster }) => (
|
||||||
|
<div key={id} className={`${frame} hidden lg:block`}>
|
||||||
|
<Image
|
||||||
|
src={poster}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1440px) 274px, 20vw"
|
||||||
|
className="object-cover object-top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { getCaseMedia } from "./case-media";
|
||||||
|
|
||||||
|
/** Testes de caminhos-base de getCaseMedia: id inválido, pôster ausente e pôster presente em disco. */
|
||||||
|
|
||||||
|
jest.mock("node:fs", () => {
|
||||||
|
const actual = jest.requireActual<typeof import("node:fs")>("node:fs");
|
||||||
|
return { ...actual, existsSync: jest.fn(actual.existsSync) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedExistsSync = jest.mocked(existsSync);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedExistsSync.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCaseMedia", () => {
|
||||||
|
it.each([
|
||||||
|
["with path traversal", "../../etc/passwd"],
|
||||||
|
["with upper case", "Sev-Modaintima"],
|
||||||
|
["with a slash", "sev/modaintima"],
|
||||||
|
["empty", ""],
|
||||||
|
])("path: id %s -> undefined without touching the disk", (_label, id) => {
|
||||||
|
expect(getCaseMedia(id, "desktop")).toBeUndefined();
|
||||||
|
expect(mockedExistsSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: valid id with no poster on disk -> undefined", () => {
|
||||||
|
expect(getCaseMedia("twins-lab", "desktop")).toBeUndefined();
|
||||||
|
expect(mockedExistsSync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: valid id with the poster on disk -> the showcase assets of that device", () => {
|
||||||
|
const desktop = getCaseMedia("sev-modaintima", "desktop");
|
||||||
|
const mobile = getCaseMedia("sev-modaintima", "mobile");
|
||||||
|
|
||||||
|
expect(desktop?.poster).toBe("/videos/sev-modaintima/desktop.webp");
|
||||||
|
expect(mobile?.poster).toBe("/videos/sev-modaintima/mobile.webp");
|
||||||
|
expect(desktop?.sources.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
type Device,
|
||||||
|
getShowcaseAssets,
|
||||||
|
type ShowcaseAssets,
|
||||||
|
} from "@/lib/project-showcase";
|
||||||
|
|
||||||
|
const idPattern = /^[a-z0-9-]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pôster e vídeo gravados em public/videos/<id>/, ou `undefined` quando o projeto não tem captura.
|
||||||
|
* Roda no servidor (na geração das páginas): olha se o pôster existe no disco.
|
||||||
|
*/
|
||||||
|
export function getCaseMedia(
|
||||||
|
id: string,
|
||||||
|
device: Device,
|
||||||
|
): ShowcaseAssets | undefined {
|
||||||
|
if (!idPattern.test(id)) return undefined;
|
||||||
|
const assets = getShowcaseAssets(id, device);
|
||||||
|
const file = join(process.cwd(), "public", assets.poster);
|
||||||
|
return existsSync(file) ? assets : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Faixa de topo de uma página interna que não tem imagem ao lado: uma coluna só e mais baixa que a padrão.
|
||||||
|
* O "!" vence o CSS sem camada de globals.css (.ds-section: 72 a 112 px de folga em cima e embaixo).
|
||||||
|
* Em cima fica a altura do cabeçalho fixo mais um respiro, para a trilha não ficar por baixo dele; embaixo, 48 px.
|
||||||
|
* No celular vale o padding de `pageTop` (button-styles.ts) e o padrão de baixo.
|
||||||
|
*/
|
||||||
|
export const compactHero =
|
||||||
|
"md:!pt-[calc(var(--site-header-height)+var(--sp-10))] md:!pb-sp-12";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topo com imagem: texto à esquerda e a mídia à direita a partir de 1024 px (1,1 parte para 1), uma coluna abaixo disso.
|
||||||
|
* Os `sizes` das imagens (CaseCover e PosterMosaic) seguem esta grade.
|
||||||
|
*/
|
||||||
|
export const heroWithMediaGrid =
|
||||||
|
"grid items-center gap-y-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-x-sp-16";
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||||
|
|
||||||
|
export const alt =
|
||||||
|
"Sites e sistemas feitos para empresas — Evandro Fonseca Junior";
|
||||||
|
export const size = OG_SIZE;
|
||||||
|
export const contentType = OG_CONTENT_TYPE;
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
return new ImageResponse(
|
||||||
|
<OgCard
|
||||||
|
eyebrow="Trabalhos entregues"
|
||||||
|
title="Sites e sistemas feitos para empresas"
|
||||||
|
description="Veja como cada projeto ficou e converse sobre o seu."
|
||||||
|
/>,
|
||||||
|
OG_SIZE,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { clientCasesForListing as clientCases } from "@/lib/site-content";
|
||||||
|
import CasesPage from "./page";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carregamento da lista de cases: sem mosaico no topo, a capa do primeiro cartão (a maior imagem acima da dobra) carrega
|
||||||
|
* na hora e com prioridade alta, as outras capas são preguiçosas, e os links não pré-carregam as páginas dos cases sozinhos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LinkProps = ComponentProps<typeof import("next/link").default>;
|
||||||
|
|
||||||
|
/** Propriedades de cada `Link` renderizado: o `prefetch` não aparece no HTML, então a rota é espiada aqui. */
|
||||||
|
const mockLinkProps: LinkProps[] = [];
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ children, href, prefetch, ...anchor }: LinkProps) => {
|
||||||
|
mockLinkProps.push({ children, href, prefetch, ...anchor });
|
||||||
|
return (
|
||||||
|
<a href={String(href)} {...(anchor as Record<string, unknown>)}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockLinkProps.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function coverImages(container: HTMLElement) {
|
||||||
|
return clientCases.map((caseStudy) => {
|
||||||
|
const image = container
|
||||||
|
.querySelector(`a[href="/cases/${caseStudy.id}"][aria-hidden="true"]`)
|
||||||
|
?.querySelector("img");
|
||||||
|
if (!image) throw new Error(`Missing cover of ${caseStudy.id}`);
|
||||||
|
return image;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CasesPage: carregamento das capas", () => {
|
||||||
|
it("loads the first card cover at once with high priority, and every other image lazily", () => {
|
||||||
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
|
// sem mosaico: nenhuma imagem decorativa (alt vazio) no topo da página
|
||||||
|
const decorative = Array.from(
|
||||||
|
container.querySelectorAll<HTMLImageElement>("img"),
|
||||||
|
).filter((image) => image.getAttribute("alt") === "");
|
||||||
|
expect(decorative).toHaveLength(0);
|
||||||
|
|
||||||
|
const [first, ...others] = coverImages(container);
|
||||||
|
expect(first).toHaveAttribute("loading", "eager");
|
||||||
|
expect(first).toHaveAttribute("fetchpriority", "high");
|
||||||
|
expect(others.length).toBeGreaterThan(0);
|
||||||
|
for (const image of others) {
|
||||||
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
|
expect(image).not.toHaveAttribute("fetchpriority");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not prefetch the case pages from the cover links, nor from the card buttons", () => {
|
||||||
|
render(<CasesPage />);
|
||||||
|
|
||||||
|
const toCases = mockLinkProps.filter(({ href }) =>
|
||||||
|
String(href).startsWith("/cases/"),
|
||||||
|
);
|
||||||
|
// uma capa e um botão por case de cliente, e um botão por projeto do laboratório
|
||||||
|
expect(toCases.length).toBeGreaterThanOrEqual(clientCases.length * 2);
|
||||||
|
for (const props of toCases) {
|
||||||
|
expect(props.prefetch).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
buttonLabels,
|
||||||
|
clientCasesForListing,
|
||||||
|
labCases,
|
||||||
|
} from "@/lib/site-content";
|
||||||
|
import { getCaseMedia } from "./_components/case-media";
|
||||||
|
import CasesPage from "./page";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teste de caminhos-base da lista de cases de cliente em CasesPage:
|
||||||
|
* o caminho "case de cliente sem captura gravada" (a página principal só vê cases com captura).
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("./_components/case-media", () => {
|
||||||
|
const actual = jest.requireActual("./_components/case-media");
|
||||||
|
return { ...actual, getCaseMedia: jest.fn(actual.getCaseMedia) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualGetCaseMedia = jest.requireActual<
|
||||||
|
typeof import("./_components/case-media")
|
||||||
|
>("./_components/case-media").getCaseMedia;
|
||||||
|
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(actualGetCaseMedia);
|
||||||
|
});
|
||||||
|
|
||||||
|
function clientCard(container: HTMLElement, id: string) {
|
||||||
|
const card = container
|
||||||
|
.querySelector(`a[href="/cases/${id}"]`)
|
||||||
|
?.closest("article");
|
||||||
|
if (!card) throw new Error(`Missing client card ${id}`);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CasesPage: cases de cliente", () => {
|
||||||
|
it("path: client case without a desktop capture -> card keeps text and links but no cover", () => {
|
||||||
|
const [withoutCapture, withCapture] = clientCasesForListing;
|
||||||
|
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||||
|
id === withoutCapture.id ? undefined : actualGetCaseMedia(id, device),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
|
const bare = clientCard(container, withoutCapture.id);
|
||||||
|
expect(bare.querySelector(".ds-bframe")).toBeNull();
|
||||||
|
expect(bare.querySelector("img")).toBeNull();
|
||||||
|
expect(bare.querySelector("a[aria-hidden='true']")).toBeNull();
|
||||||
|
expect(within(bare).getByText(withoutCapture.headline)).toBeVisible();
|
||||||
|
expect(
|
||||||
|
within(bare).getByRole("link", {
|
||||||
|
name: `${buttonLabels.case} ${withoutCapture.title}`,
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("href", `/cases/${withoutCapture.id}`);
|
||||||
|
|
||||||
|
// os outros cases de cliente continuam com a capa
|
||||||
|
const covered = clientCard(container, withCapture.id);
|
||||||
|
expect(covered.querySelector(".ds-bframe img")).toHaveAttribute(
|
||||||
|
"alt",
|
||||||
|
`Captura de tela do site ${withCapture.title}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: every case -> one 'Ver case' button, named after its project, and no 'Ver o case' or 'Ler case'", () => {
|
||||||
|
render(<CasesPage />);
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole("link", { name: /^Ver case / });
|
||||||
|
expect(buttons).toHaveLength(
|
||||||
|
clientCasesForListing.length + labCases.length,
|
||||||
|
);
|
||||||
|
for (const link of buttons) {
|
||||||
|
expect(link.textContent?.startsWith(buttonLabels.case)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", { name: /Ver o case|Ler case|Ver o projeto/ }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: contact -> 'Falar sobre meu projeto', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus and AS Serviços", () => {
|
||||||
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: buttonLabels.contact }),
|
||||||
|
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
|
||||||
|
const clientTitles = Array.from(
|
||||||
|
container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"),
|
||||||
|
).map((kicker) => kicker.textContent);
|
||||||
|
expect(clientTitles).toEqual(
|
||||||
|
clientCasesForListing.map(({ title }) => title),
|
||||||
|
);
|
||||||
|
expect(clientTitles).toEqual([
|
||||||
|
"SEV Moda Íntima",
|
||||||
|
"Lavação 047",
|
||||||
|
"SC Plus Aquicultura",
|
||||||
|
"AS Serviços",
|
||||||
|
]);
|
||||||
|
// um botão de contato só na página: o do fecho
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole("link", { name: buttonLabels.contact }),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CasesPage: topo sem mosaico", () => {
|
||||||
|
it("path: captures found -> no poster mosaic (the cards below already carry the covers), a lower one-column strip, no 'Veja o que já' twice", () => {
|
||||||
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
|
const hero = container.querySelector("section");
|
||||||
|
expect(hero?.querySelector("img")).toBeNull();
|
||||||
|
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
|
||||||
|
expect(hero?.className).toContain("md:!pb-sp-12");
|
||||||
|
// o título e o subtítulo não repetem a mesma frase
|
||||||
|
const heading = within(hero as HTMLElement).getByRole("heading", {
|
||||||
|
level: 1,
|
||||||
|
});
|
||||||
|
expect(heading).toHaveTextContent("Veja o que já fiz para empresas.");
|
||||||
|
expect(hero?.textContent?.match(/Veja o que já/g)).toHaveLength(1);
|
||||||
|
expect(hero?.textContent).not.toMatch(/\bdireto\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no capture for the other clients -> still no mosaic, and the first card cover takes the priority", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||||
|
id === clientCasesForListing[0].id
|
||||||
|
? actualGetCaseMedia(id, device)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
|
const hero = container.querySelector("section");
|
||||||
|
expect(hero?.querySelector("img")).toBeNull();
|
||||||
|
const firstCover = clientCard(
|
||||||
|
container,
|
||||||
|
clientCasesForListing[0].id,
|
||||||
|
).querySelector(".ds-bframe img");
|
||||||
|
expect(firstCover).toHaveAttribute("loading", "eager");
|
||||||
|
expect(firstCover).toHaveAttribute("fetchpriority", "high");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CasesPage: prioridade da capa do primeiro cartão", () => {
|
||||||
|
it("path: no mosaic -> the first card cover is the page's biggest image, so it loads at once with high priority; the others stay lazy", () => {
|
||||||
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
|
const [first, ...others] = clientCasesForListing;
|
||||||
|
const firstCover = clientCard(container, first.id).querySelector(
|
||||||
|
".ds-bframe img",
|
||||||
|
);
|
||||||
|
expect(firstCover).toHaveAttribute("loading", "eager");
|
||||||
|
expect(firstCover).toHaveAttribute("fetchpriority", "high");
|
||||||
|
for (const { id } of others) {
|
||||||
|
const cover = clientCard(container, id).querySelector(".ds-bframe img");
|
||||||
|
expect(cover).toHaveAttribute("loading", "lazy");
|
||||||
|
expect(cover).not.toHaveAttribute("fetchpriority");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||||
|
import {
|
||||||
|
pageTop,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButtonDark,
|
||||||
|
secondaryButtonLight,
|
||||||
|
textLink,
|
||||||
|
} from "@/app/components/button-styles";
|
||||||
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
|
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||||
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
|
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||||
|
import {
|
||||||
|
buttonLabels,
|
||||||
|
type CaseStudyContent,
|
||||||
|
clientCasesForListing,
|
||||||
|
labCases,
|
||||||
|
siteUrl,
|
||||||
|
} from "@/lib/site-content";
|
||||||
|
import { CASES_SUBJECT } from "@/lib/whatsapp";
|
||||||
|
import { CaseCoverLink } from "./_components/CaseCoverLink";
|
||||||
|
import { compactHero } from "./_components/hero-styles";
|
||||||
|
|
||||||
|
const pageUrl = `${siteUrl}/cases`;
|
||||||
|
const pageTitle = "Cases: sites e sistemas para empresas";
|
||||||
|
const pageDescription =
|
||||||
|
"Cases de sites e sistemas para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono e páginas institucionais.";
|
||||||
|
/** Abertura da página: o que o visitante encontra nos cases, sem repetir a frase do título. */
|
||||||
|
const pageLead =
|
||||||
|
"Abra um case para ver o problema do cliente, o que foi feito e como o site ficou, da loja virtual ao site que o dono edita sozinho.";
|
||||||
|
const allCases = [...clientCasesForListing, ...labCases];
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: pageTitle,
|
||||||
|
description: pageDescription,
|
||||||
|
...buildPageSocialMetadata({
|
||||||
|
url: pageUrl,
|
||||||
|
title: pageTitle,
|
||||||
|
description: pageDescription,
|
||||||
|
type: "website",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case. */
|
||||||
|
function ClientCaseCard({
|
||||||
|
caseStudy,
|
||||||
|
priority = false,
|
||||||
|
}: {
|
||||||
|
caseStudy: CaseStudyContent;
|
||||||
|
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className="ds-card !h-auto">
|
||||||
|
<CaseCoverLink
|
||||||
|
id={caseStudy.id}
|
||||||
|
title={caseStudy.title}
|
||||||
|
demoUrl={caseStudy.demoUrl}
|
||||||
|
priority={priority}
|
||||||
|
/>
|
||||||
|
<div className="ds-kicker">{caseStudy.title}</div>
|
||||||
|
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||||
|
<p className="ds-copy">{caseStudy.summary}</p>
|
||||||
|
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href={`/cases/${caseStudy.id}`}
|
||||||
|
className={secondaryButtonLight}
|
||||||
|
>
|
||||||
|
{buttonLabels.case}
|
||||||
|
<span className="sr-only"> {caseStudy.title}</span>
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
{caseStudy.demoUrl ? (
|
||||||
|
<a
|
||||||
|
href={caseStudy.demoUrl}
|
||||||
|
className={textLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{buttonLabels.site}
|
||||||
|
<span className="sr-only"> {caseStudy.title}</span>
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uma linha por projeto do laboratório: nome, o que é, a marca "Projeto próprio" e o link. Sem capa nem resumo, para a lista
|
||||||
|
* ocupar pouco espaço (no celular, o nome e a marca ficam na primeira linha).
|
||||||
|
*/
|
||||||
|
function LabCaseRow({ caseStudy }: { caseStudy: CaseStudyContent }) {
|
||||||
|
return (
|
||||||
|
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-sp-6 gap-y-sp-1 py-sp-4 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto_auto]">
|
||||||
|
<h3 className="text-base font-semibold leading-6">{caseStudy.title}</h3>
|
||||||
|
<p className="ds-kicker justify-self-end md:order-3">Projeto próprio</p>
|
||||||
|
<p className="col-span-2 text-[15px] leading-6 text-[var(--ink-80)] md:order-2 md:col-span-1">
|
||||||
|
{caseStudy.subtitle}
|
||||||
|
</p>
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href={`/cases/${caseStudy.id}`}
|
||||||
|
className={`${textLink} col-span-2 justify-self-start md:order-4 md:col-span-1`}
|
||||||
|
>
|
||||||
|
{buttonLabels.case}
|
||||||
|
<span className="sr-only"> {caseStudy.title}</span>
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CasesPage() {
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "CollectionPage",
|
||||||
|
"@id": `${pageUrl}#page`,
|
||||||
|
name: pageTitle,
|
||||||
|
url: pageUrl,
|
||||||
|
description: pageDescription,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ItemList",
|
||||||
|
itemListElement: allCases.map((caseStudy, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: index + 1,
|
||||||
|
name: caseStudy.title,
|
||||||
|
url: `${pageUrl}/${caseStudy.id}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
buildBreadcrumbList(pageUrl, [
|
||||||
|
["Início", siteUrl],
|
||||||
|
["Cases", pageUrl],
|
||||||
|
] as const),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||||
|
|
||||||
|
{/* sem mosaico de pôsteres: os cartões logo abaixo já trazem a capa de cada cliente, e a mesma imagem não aparece duas vezes */}
|
||||||
|
<section
|
||||||
|
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
||||||
|
>
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<div className="max-w-[860px]">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Início", href: "/" },
|
||||||
|
{ label: "Cases", href: "/cases" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p className="ds-eyebrow">Trabalhos entregues</p>
|
||||||
|
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
|
||||||
|
Veja o que já fiz para empresas.
|
||||||
|
</h1>
|
||||||
|
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
||||||
|
{pageLead}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ds-section" aria-labelledby="cases-clientes">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<h2 id="cases-clientes" className="ds-card-title !mt-0">
|
||||||
|
Projetos de clientes
|
||||||
|
</h2>
|
||||||
|
<div className="ds-grid-2 mt-sp-8 items-start">
|
||||||
|
{clientCasesForListing.map((caseStudy, index) => (
|
||||||
|
<ClientCaseCard
|
||||||
|
key={caseStudy.id}
|
||||||
|
caseStudy={caseStudy}
|
||||||
|
priority={index === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ds-section" aria-labelledby="cases-laboratorio">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<h2 id="cases-laboratorio" className="ds-card-title !mt-0">
|
||||||
|
Laboratório
|
||||||
|
</h2>
|
||||||
|
<p className="ds-copy max-w-[64ch]">
|
||||||
|
Projetos pessoais e de estudo, feitos para praticar e testar ideias.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-sp-6 divide-y divide-[var(--border)] border-y border-[var(--border)]">
|
||||||
|
{labCases.map((caseStudy) => (
|
||||||
|
<LabCaseRow key={caseStudy.id} caseStudy={caseStudy} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ds-section ds-section-dark">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<h2 className="ds-card-title !mt-0 text-white">
|
||||||
|
Quer um projeto parecido com algum destes?
|
||||||
|
</h2>
|
||||||
|
<p className="ds-copy max-w-[60ch]">
|
||||||
|
Conte o que você precisa. A conversa começa pelo WhatsApp, sem
|
||||||
|
formulário.
|
||||||
|
</p>
|
||||||
|
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||||
|
<WhatsAppLink
|
||||||
|
service={CASES_SUBJECT}
|
||||||
|
placement="cases_hub_footer"
|
||||||
|
className={primaryButton}
|
||||||
|
>
|
||||||
|
{buttonLabels.contact}
|
||||||
|
</WhatsAppLink>
|
||||||
|
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
|
||||||
|
{buttonLabels.allServices}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import About from "./About";
|
||||||
|
|
||||||
|
// o Link do Next, trocado por um <a> que mostra o prefetch recebido: HoverPrefetchLink começa em false
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de About: a entrada dos quatro cartões (Reveal, CSS) com atrasos crescentes, o
|
||||||
|
* feedback de toque e de mouse por classes (cv-press, cv-lift; cartões sem foco), o conteúdo e a coluna de
|
||||||
|
* apoio. Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("@/public/edev.jpg", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: { src: "/edev.jpg", width: 10, height: 10 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./Section", () => ({
|
||||||
|
Section: ({
|
||||||
|
children,
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
aside,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
aside?: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<section id={id}>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p data-testid="about-description">{description}</p>
|
||||||
|
{aside}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
|
||||||
|
jest.mock("./animations/LazyRevealImage", () => ({
|
||||||
|
LazyRevealImage: ({ alt }: { alt: string }) => (
|
||||||
|
<span role="img" aria-label={alt} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("About", () => {
|
||||||
|
it("path: the four cards enter through Reveal -> same rise and duration, delays growing in steps", () => {
|
||||||
|
const { container } = render(<About />);
|
||||||
|
|
||||||
|
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
|
||||||
|
// 3 etapas da história + o card do perfil (o cabeçalho da seção é um dublê, sem Reveal)
|
||||||
|
expect(reveals).toHaveLength(4);
|
||||||
|
expect(
|
||||||
|
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
|
||||||
|
).toEqual(["20px", "20px", "20px", "20px"]);
|
||||||
|
expect(
|
||||||
|
reveals.map((element) =>
|
||||||
|
element.style.getPropertyValue("--reveal-duration"),
|
||||||
|
),
|
||||||
|
).toEqual(["0.8s", "0.8s", "0.8s", "0.8s"]);
|
||||||
|
const delays = reveals.map((element) =>
|
||||||
|
Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
|
||||||
|
);
|
||||||
|
expect(delays).toEqual([0.08, 0.16, 0.24, 0.32]);
|
||||||
|
for (const element of reveals) {
|
||||||
|
expect(element.style.opacity).toBe("");
|
||||||
|
expect(element.style.transform).toBe("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the four cards are not focus stops and use the CSS press feedback", () => {
|
||||||
|
const { container } = render(<About />);
|
||||||
|
|
||||||
|
const cards = container.querySelectorAll("article");
|
||||||
|
expect(cards).toHaveLength(4);
|
||||||
|
for (const card of cards) {
|
||||||
|
expect(card).not.toHaveAttribute("tabindex");
|
||||||
|
expect(card).toHaveClass("cv-press");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the three story cards lift on hover and press with CSS; the profile card presses only", () => {
|
||||||
|
const { container } = render(<About />);
|
||||||
|
|
||||||
|
const cards = [...container.querySelectorAll("article")];
|
||||||
|
expect(cards).toHaveLength(4);
|
||||||
|
for (const card of cards.slice(0, 3)) {
|
||||||
|
expect(card).toHaveClass("cv-press", "cv-lift");
|
||||||
|
}
|
||||||
|
expect(cards[3]).toHaveClass("cv-press");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the section has three points, each with the proof that backs it, and the résumé link", () => {
|
||||||
|
const { container } = render(<About />);
|
||||||
|
|
||||||
|
const titles = [
|
||||||
|
"Quem conversa com você escreve o código",
|
||||||
|
"Aproveito o que você já tem",
|
||||||
|
"Do código ao sistema no ar",
|
||||||
|
];
|
||||||
|
for (const name of titles) {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 3, name }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
// no máximo três pontos: os três cartões da história; o cartão do perfil não é um quarto ponto, só diz do que cuido
|
||||||
|
const story = [...container.querySelectorAll("article")].slice(0, 3);
|
||||||
|
expect(story).toHaveLength(3);
|
||||||
|
for (const card of story) {
|
||||||
|
expect(within(card).getAllByText("Na prática:")).toHaveLength(1);
|
||||||
|
}
|
||||||
|
// a prova de cada ponto é concreta: a Apolo desde 2022, a revisão e os cases entregues
|
||||||
|
expect(story[0]).toHaveTextContent(
|
||||||
|
/Na prática: Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT/,
|
||||||
|
);
|
||||||
|
expect(story[1]).toHaveTextContent(
|
||||||
|
/Na prática: Na revisão, você recebe o que dá para aproveitar e o que é melhor refazer/,
|
||||||
|
);
|
||||||
|
expect(story[2]).toHaveTextContent(
|
||||||
|
/Na prática: Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços\./,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(story[1]).getByRole("link", {
|
||||||
|
name: "Ver como funciona a revisão",
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("href", "/ia");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Ver trajetória completa" }),
|
||||||
|
).toHaveAttribute("href", "/curriculo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the idea of talking to whoever develops shows up once, not in five texts", () => {
|
||||||
|
const { container } = render(<About />);
|
||||||
|
|
||||||
|
expect(container.textContent).not.toMatch(
|
||||||
|
/Sem intermediários|Uma pessoa cuida|Quem responde pelo projeto|acompanha as decisões|fala com quem desenvolve|Solução do tamanho|Tudo funcionando junto/,
|
||||||
|
);
|
||||||
|
expect(container.textContent?.match(/mesma pessoa/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the résumé and the review links pre-fetch only when the visitor reaches them (HoverPrefetchLink), as in the rest of the home", () => {
|
||||||
|
render(<About />);
|
||||||
|
|
||||||
|
for (const name of [
|
||||||
|
"Ver trajetória completa",
|
||||||
|
"Ver como funciona a revisão",
|
||||||
|
]) {
|
||||||
|
const link = screen.getByRole("link", { name });
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "false");
|
||||||
|
fireEvent.mouseEnter(link);
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "null");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: speaks in client benefits, with no technical jargon, and cites the Apolo Energia work once, as the proof of the first point", () => {
|
||||||
|
const { container } = render(<About />);
|
||||||
|
|
||||||
|
expect(container.textContent).not.toMatch(
|
||||||
|
/protocolos de baixo nível|operabilidade|camada física|sinal físico/i,
|
||||||
|
);
|
||||||
|
// a seção abre pela credencial, logo depois do nome
|
||||||
|
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
|
||||||
|
"Evandro Fonseca Junior",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("about-description").textContent).toBe(
|
||||||
|
"Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas.",
|
||||||
|
);
|
||||||
|
// só o que o currículo sustenta: nada de "os sistemas internos e a IoT" da empresa inteira
|
||||||
|
expect(container.textContent).not.toMatch(
|
||||||
|
/os sistemas internos e a IoT da Apolo/,
|
||||||
|
);
|
||||||
|
// a frase da Apolo aparece uma só vez (na prova do primeiro ponto)
|
||||||
|
expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the first story card does not reuse the name 'Como eu trabalho' of the process list in the offers section right before", () => {
|
||||||
|
const { container } = render(<About />);
|
||||||
|
|
||||||
|
expect(container.textContent).not.toMatch(/Como eu trabalho/);
|
||||||
|
expect(screen.getByText("Quem desenvolve")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => {
|
||||||
|
const { container } = render(<About />);
|
||||||
|
|
||||||
|
expect(container.textContent).not.toMatch(
|
||||||
|
/contato direto|fala direto|falar direto|conversa comigo/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the location label stays on one line and the portrait card keeps its own height next to the text cards", () => {
|
||||||
|
const { container } = render(<About />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Garuva, SC · Atendimento remoto/)).toHaveClass(
|
||||||
|
"whitespace-nowrap",
|
||||||
|
);
|
||||||
|
// items-start: o retrato tem a altura do próprio conteúdo e acompanha a rolagem; só os cartões de texto se esticam entre si
|
||||||
|
expect(container.querySelector(".ds-grid-2")).toHaveClass("items-start");
|
||||||
|
const story = container.querySelectorAll("article");
|
||||||
|
for (const card of Array.from(story).slice(0, 3)) {
|
||||||
|
expect(card).toHaveClass("flex-1");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+119
-176
@@ -1,194 +1,137 @@
|
|||||||
"use client";
|
import profilePhoto from "@/public/edev.jpg";
|
||||||
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
|
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
||||||
|
import { textLink } from "./button-styles";
|
||||||
|
import "./conversion.css";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
import { Reveal } from "./Reveal";
|
||||||
|
import { Section } from "./Section";
|
||||||
|
|
||||||
import React from "react";
|
/**
|
||||||
import { motion } from "framer-motion";
|
* Os três pontos da seção, cada um com a prova que o sustenta. A ideia de que quem conversa com você é quem desenvolve
|
||||||
import { LucideIcon, Brain, Zap, Target, Rocket, Users, Lightbulb, Code, Shield, Terminal, Cpu } from "lucide-react";
|
* aparece só no primeiro cartão: antes ela se repetia em cinco textos ("Sem intermediários", "Uma pessoa cuida",
|
||||||
import Squares from "@/components/Squares";
|
* "Quem responde pelo projeto", "Você acompanha as decisões" e "Você fala com quem desenvolve", que segue só no hero).
|
||||||
|
*/
|
||||||
// --- Types & Interfaces (Tipagem Forte para Previsibilidade) ---
|
const STORY_DATA = [
|
||||||
interface StoryStep {
|
|
||||||
icon: LucideIcon;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
gradient: string;
|
|
||||||
accent: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CoreValue {
|
|
||||||
icon: LucideIcon;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Data (Desacoplado para facilitar manutenção/CMS) ---
|
|
||||||
const STORY_DATA: StoryStep[] = [
|
|
||||||
{
|
{
|
||||||
id: "KERNEL_INIT",
|
label: "Quem desenvolve",
|
||||||
icon: Brain,
|
title: "Quem conversa com você escreve o código",
|
||||||
title: "Engenharia de Fundamentos",
|
content:
|
||||||
content: "Minha mentalidade é pautada pela redução de entropia. Não apenas escrevo código; projeto sistemas onde a escalabilidade é uma propriedade intrínseca da arquitetura, não um ajuste posterior.",
|
"A mesma pessoa entende o problema, decide a estrutura e desenvolve o sistema, do começo ao fim.",
|
||||||
gradient: "from-blue-600 to-cyan-500",
|
proof:
|
||||||
accent: "text-blue-400",
|
"Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "RUNTIME_EXEC",
|
label: "Sem começar do zero",
|
||||||
icon: Zap,
|
title: "Aproveito o que você já tem",
|
||||||
title: "Entrega sob Restrições",
|
content:
|
||||||
content: "Em 3 anos de atuação profissional, especializei-me em cenários de alta criticidade. Atuei na ponte entre sistemas legados e microserviços, garantindo a resolução do problema.",
|
"Antes de mexer em um sistema que já está em uso, vejo o que existe e combino com você o que vem primeiro. Nem todo problema exige começar de novo.",
|
||||||
gradient: "from-purple-600 to-pink-600",
|
proof:
|
||||||
accent: "text-purple-400",
|
"Na revisão, você recebe o que dá para aproveitar e o que é melhor refazer, com o motivo de cada parte.",
|
||||||
|
link: { href: "/ia", label: "Ver como funciona a revisão" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Depois da entrega",
|
||||||
|
title: "Do código ao sistema no ar",
|
||||||
|
content:
|
||||||
|
"Colocar no ar, corrigir falhas, documentar e manter o sistema entram no planejamento desde o começo, junto com as funcionalidades.",
|
||||||
|
proof:
|
||||||
|
"Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const VALUES_DATA: CoreValue[] = [
|
/** Atraso (s) de cada cartão: entram em escada, como na vitrine de serviços. */
|
||||||
{
|
const cardDelay = (index: number) => 0.08 * (index + 1);
|
||||||
icon: Code,
|
|
||||||
title: "Pragmatismo Arquitetural",
|
|
||||||
description: "Priorizo a manutenibilidade e a resiliência. Código limpo é o meio, sistemas que geram ROI são o fim."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Brain,
|
|
||||||
title: "Fundacionalismo Técnico",
|
|
||||||
description: "Domínio profundo de estruturas de dados e protocolos. Tecnologias mudam, fundamentos são perenes."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Users,
|
|
||||||
title: "Propriedade e Ownership",
|
|
||||||
description: "Comunicação de baixa latência e responsabilidade radical sobre cada entrega e decisão técnica."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Shield,
|
|
||||||
title: "Security by Design",
|
|
||||||
description: "Integridade e conformidade técnica (GDPR/LGPD) tratadas como requisitos funcionais, não opcionais."
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// --- Micro-componentes (Feynman Technique: Simplicidade e Reuso) ---
|
|
||||||
|
|
||||||
const SectionHeader = () => (
|
|
||||||
<div className="text-center md:text-left mb-24 relative z-10">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-950/30 border border-blue-500/20 text-blue-400 font-mono text-xs tracking-wider mb-6"
|
|
||||||
>
|
|
||||||
<Terminal className="w-3 h-3" />
|
|
||||||
<span>WHOAMI.sys</span>
|
|
||||||
</motion.div>
|
|
||||||
<motion.h2
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="text-4xl md:text-6xl font-black text-white tracking-tighter"
|
|
||||||
>
|
|
||||||
Engenharia <br />
|
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-cyan-400">Nuclear</span>
|
|
||||||
</motion.h2>
|
|
||||||
<p className="mt-6 text-lg text-slate-400 max-w-2xl leading-relaxed font-light border-l-2 border-slate-800 pl-6">
|
|
||||||
"Transformando complexidade técnica em valor de negócio através da engenharia."
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const StoryCard = ({ step, index }: { step: StoryStep; index: number }) => {
|
|
||||||
const isEven = index % 2 === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: isEven ? -30 : 30 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
className={`flex flex-col ${isEven ? "md:flex-row" : "md:flex-row-reverse"} items-stretch gap-8 mb-20 relative`}
|
|
||||||
>
|
|
||||||
<div className="flex-1 w-full">
|
|
||||||
<div className="group relative h-full p-8 rounded-3xl bg-zinc-900/40 border border-white/5 hover:border-blue-500/30 transition-all duration-500 shadow-xl backdrop-blur-xl overflow-hidden">
|
|
||||||
{/* Background Glow */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<div className="p-3 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 group-hover:scale-110 transition-transform duration-300">
|
|
||||||
<step.icon className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-mono text-slate-500 uppercase tracking-widest">
|
|
||||||
{step.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-2xl font-bold text-white mb-4 group-hover:text-blue-400 transition-colors">{step.title}</h3>
|
|
||||||
<p className="text-slate-400 leading-relaxed text-sm">{step.content}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Line & Node */}
|
|
||||||
<div className="hidden md:flex flex-col items-center justify-center w-12 relative">
|
|
||||||
<div className="absolute top-0 bottom-0 w-[1px] bg-slate-800 group-hover:bg-blue-500/50 transition-colors" />
|
|
||||||
<div className="w-4 h-4 rounded-full bg-slate-900 border-2 border-slate-700 z-10 group-hover:border-blue-500 transition-colors" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 hidden md:block" />
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
return (
|
return (
|
||||||
<section id="about" className="relative py-32">
|
<Section
|
||||||
{/* Background Gradients */}
|
id="about"
|
||||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
|
eyebrow="Quem conduz o projeto"
|
||||||
<div className="absolute top-[-10%] right-[-10%] w-[600px] h-[600px] bg-blue-900/10 rounded-full blur-[120px]" />
|
title="Evandro Fonseca Junior"
|
||||||
<div className="absolute bottom-[-10%] left-[-10%] w-[600px] h-[600px] bg-purple-900/10 rounded-full blur-[120px]" />
|
description="Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas."
|
||||||
</div>
|
disableChildrenAnimation
|
||||||
|
aside={
|
||||||
<div className="container max-w-6xl mx-auto px-6 relative z-10">
|
<div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
|
||||||
<SectionHeader />
|
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-slash" />
|
||||||
|
Garuva, SC · Atendimento remoto
|
||||||
<div className="relative mt-20">
|
|
||||||
{/* Center Line for Mobile/Desktop */}
|
|
||||||
<div className="absolute left-6 md:left-1/2 top-0 bottom-0 w-[1px] bg-slate-800 md:-translate-x-1/2 hidden md:block" />
|
|
||||||
|
|
||||||
{STORY_DATA.map((step, idx) => (
|
|
||||||
<StoryCard key={idx} step={step} index={idx} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
{/* Grid de Valores com Pareto 80/20 */}
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-32">
|
{/* items-start: o cartão do retrato tem a altura do próprio conteúdo, e não a das três colunas de texto; no desktop ele acompanha a rolagem */}
|
||||||
{VALUES_DATA.map((val, i) => (
|
<div className="ds-grid-2 items-start">
|
||||||
<motion.div
|
<div className="flex flex-col gap-sp-6">
|
||||||
key={i}
|
{STORY_DATA.map((step, index) => (
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<Reveal
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
key={step.title}
|
||||||
viewport={{ once: true }}
|
y={20}
|
||||||
transition={{ delay: i * 0.1 }}
|
duration={0.8}
|
||||||
className="p-6 rounded-2xl bg-zinc-900/30 border border-white/5 hover:border-blue-500/20 hover:bg-zinc-900/50 transition-all duration-300 group"
|
delay={cardDelay(index)}
|
||||||
|
className="flex flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<div className="mb-4 p-2 w-fit rounded-lg bg-slate-800/50 text-slate-400 group-hover:text-blue-400 group-hover:bg-blue-500/10 transition-colors">
|
<article className="ds-card cv-press cv-lift flex-1">
|
||||||
<val.icon className="w-5 h-5" />
|
<div className="ds-kicker">{step.label}</div>
|
||||||
</div>
|
<DrawLine delay={0.03} />
|
||||||
<h4 className="text-white font-bold mb-2 text-sm uppercase tracking-wide group-hover:text-blue-200 transition-colors">{val.title}</h4>
|
<h3 className="ds-card-title">{step.title}</h3>
|
||||||
<p className="text-slate-500 text-xs leading-relaxed group-hover:text-slate-400 transition-colors">{val.description}</p>
|
<p className="ds-copy" data-reel="right">
|
||||||
</motion.div>
|
{step.content}
|
||||||
|
</p>
|
||||||
|
<p className="mt-sp-4 border-t border-[var(--border)] pt-sp-3 font-display text-[15px] leading-6 text-[var(--ink)]">
|
||||||
|
<span className="font-semibold">Na prática: </span>
|
||||||
|
{step.proof}
|
||||||
|
</p>
|
||||||
|
{step.link && (
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href={step.link.href}
|
||||||
|
className={`${textLink} self-start text-[var(--ink-80)]`}
|
||||||
|
>
|
||||||
|
{step.link.label}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SEO Semantic Content - Visible but subtle */}
|
<Reveal
|
||||||
<div className="mt-24 pt-8 border-t border-slate-900">
|
y={20}
|
||||||
<div className="flex items-center justify-center gap-2 text-slate-700 text-xs font-mono uppercase tracking-widest mb-4">
|
duration={0.8}
|
||||||
<Cpu className="w-3 h-3" />
|
delay={cardDelay(STORY_DATA.length)}
|
||||||
<span>System_Signature</span>
|
className="lg:sticky lg:top-28"
|
||||||
</div>
|
>
|
||||||
<p className="text-[10px] text-slate-700 text-center max-w-2xl mx-auto leading-relaxed font-mono">
|
<article className="ds-card cv-press cv-lift">
|
||||||
Evandro Fonseca Junior :: Engenheiro Full-Stack <br />
|
<figure>
|
||||||
Next.js / React / Angular / Node.js / Tauri / PostgreSQL / IoT <br />
|
<LazyRevealImage
|
||||||
Garuva, Santa Catarina, Brasil :: Lat: -26.0264, Long: -48.8544
|
src={profilePhoto}
|
||||||
</p>
|
alt="Retrato de Evandro Fonseca Junior"
|
||||||
</div>
|
className="aspect-[4/3] w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
|
||||||
|
imageClassName="object-cover object-[50%_25%]"
|
||||||
|
sizes="(min-width: 1024px) 50vw, 100vw"
|
||||||
|
/>
|
||||||
|
<figcaption className="mt-sp-3 font-display text-[15px] leading-6 text-[var(--ink-80)]">
|
||||||
|
Evandro Fonseca Junior · Engenheiro da computação · Garuva, SC
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
<div className="ds-kicker mt-sp-6">Do que eu cuido</div>
|
||||||
|
<h3 className="ds-card-title !mt-sp-2">
|
||||||
|
Tela, dados e equipamentos
|
||||||
|
</h3>
|
||||||
|
<DrawLine delay={0.05} />
|
||||||
|
<p className="ds-copy" data-reel="top">
|
||||||
|
Cuido da tela que sua equipe usa, dos dados por trás dela e,
|
||||||
|
quando o negócio tem máquinas ou sensores, da ligação deles com o
|
||||||
|
sistema. Ver tudo junto ajuda a decidir o que construir, o que
|
||||||
|
conectar e o que dá para aproveitar.
|
||||||
|
</p>
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href="/curriculo"
|
||||||
|
className={`${textLink} mt-sp-4 text-[var(--ink-80)]`}
|
||||||
|
>
|
||||||
|
Ver trajetória completa
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Breadcrumb } from "./Breadcrumb";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de Breadcrumb: a barra de separação vem depois de cada item menos do último (a quebra de linha
|
||||||
|
* no celular nunca deixa uma linha começando por "/"), e só o último é a página atual (inclusive quando é o único).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("Breadcrumb", () => {
|
||||||
|
it("path: several items -> a separator after every item but the last, so a wrapped line starts with a name", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Início", href: "/" },
|
||||||
|
{ label: "Cases", href: "/cases" },
|
||||||
|
{ label: "SEV Moda Íntima", href: "/cases/sev-modaintima" },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = Array.from(container.querySelectorAll("li"));
|
||||||
|
expect(entries).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
entries[0].querySelector("span[aria-hidden='true']"),
|
||||||
|
).toHaveTextContent("/");
|
||||||
|
expect(
|
||||||
|
entries[1].querySelector("span[aria-hidden='true']"),
|
||||||
|
).toHaveTextContent("/");
|
||||||
|
expect(entries[2].querySelector("span[aria-hidden='true']")).toBeNull();
|
||||||
|
// o nome vem antes da barra em todos os itens, e nenhum item abre por "/"
|
||||||
|
for (const entry of entries) {
|
||||||
|
expect(entry.firstElementChild?.tagName).toBe("A");
|
||||||
|
expect(entry.textContent?.trimStart().startsWith("/")).toBe(false);
|
||||||
|
}
|
||||||
|
expect(entries[0].className).toContain("flex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a single item -> no separator, and it is the current page", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Breadcrumb items={[{ label: "Início", href: "/" }]} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("span[aria-hidden='true']")).toBeNull();
|
||||||
|
expect(screen.getByRole("link", { name: "Início" })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"page",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no items -> an empty list inside the navigation", () => {
|
||||||
|
render(<Breadcrumb items={[]} />);
|
||||||
|
|
||||||
|
const nav = screen.getByRole("navigation");
|
||||||
|
expect(nav.querySelectorAll("li")).toHaveLength(0);
|
||||||
|
expect(screen.queryByRole("link")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { Breadcrumb } from "./Breadcrumb";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A trilha de navegação só pré-carrega a rota de um item quando a pessoa chega nele (mouse, teclado ou toque):
|
||||||
|
* o `prefetch` não aparece no HTML, então o Link é trocado por um que o expõe em `data-prefetch`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: "Início", href: "/" },
|
||||||
|
{ label: "Cases", href: "/cases" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("Breadcrumb: pré-carregamento", () => {
|
||||||
|
it("path: opening the page -> no link pre-fetches", () => {
|
||||||
|
render(<Breadcrumb items={items} />);
|
||||||
|
|
||||||
|
for (const link of screen.getAllByRole("link")) {
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "false");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["mouse", fireEvent.mouseEnter],
|
||||||
|
["keyboard focus", fireEvent.focus],
|
||||||
|
["touch", fireEvent.touchStart],
|
||||||
|
])("path: %s reaching one link -> only that link pre-fetches", (_, event) => {
|
||||||
|
render(<Breadcrumb items={items} />);
|
||||||
|
|
||||||
|
const [home, cases] = screen.getAllByRole("link");
|
||||||
|
event(cases);
|
||||||
|
|
||||||
|
expect(cases).toHaveAttribute("data-prefetch", "null");
|
||||||
|
expect(home).toHaveAttribute("data-prefetch", "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { Breadcrumb } from "./Breadcrumb";
|
||||||
|
|
||||||
|
describe("Breadcrumb", () => {
|
||||||
|
const items = [
|
||||||
|
{ label: "Início", href: "/" },
|
||||||
|
{ label: "Cases", href: "/cases" },
|
||||||
|
{ label: "SEV Moda Íntima", href: "/cases/sev-modaintima" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("marks only the last item as the current page", () => {
|
||||||
|
render(<Breadcrumb items={items} />);
|
||||||
|
|
||||||
|
const links = within(
|
||||||
|
screen.getByRole("navigation", { name: "Trilha de navegação" }),
|
||||||
|
).getAllByRole("link");
|
||||||
|
expect(links.map((link) => link.getAttribute("aria-current"))).toEqual([
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"page",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the text at 15 px or more, as the body text of the inner pages", () => {
|
||||||
|
const { container } = render(<Breadcrumb items={items} />);
|
||||||
|
|
||||||
|
const list = container.querySelector("ol");
|
||||||
|
expect(list).toHaveClass("text-[15px]");
|
||||||
|
expect(list?.className).not.toMatch(/text-(xs|sm)\b|text-\[1[0-4]px\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual.
|
||||||
|
* Os links só pré-carregam a rota quando a pessoa chega neles (HoverPrefetchLink).
|
||||||
|
* A barra vem depois de cada item, menos do último: quando a trilha não cabe numa linha (celular), a quebra cai
|
||||||
|
* depois da barra e a linha de baixo começa pelo nome, nunca por "/".
|
||||||
|
*/
|
||||||
|
export function Breadcrumb({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: readonly { label: string; href: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Trilha de navegação" className="mb-sp-6">
|
||||||
|
<ol className="flex flex-wrap items-center gap-x-sp-2 gap-y-sp-1 text-[15px]">
|
||||||
|
{items.map(({ label, href }, index) => (
|
||||||
|
<li key={href} className="flex items-center gap-sp-2">
|
||||||
|
<HoverPrefetchLink
|
||||||
|
aria-current={index === items.length - 1 ? "page" : undefined}
|
||||||
|
className="ds-link ds-link-text"
|
||||||
|
href={href}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
{index < items.length - 1 ? (
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import ClientStrip from "./ClientStrip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de ClientStrip (complemento de ClientStrip.test.tsx): o separador entre clientes
|
||||||
|
* (um cliente, vários; só entre itens, nunca no fim de uma linha) e o className opcional.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const clients = [
|
||||||
|
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
|
||||||
|
{ id: "lavacao-047", name: "Lavação 047" },
|
||||||
|
{ id: "sc-plus", name: "SC Plus Aquicultura" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("ClientStrip", () => {
|
||||||
|
it("path: a single client -> no separator dot", () => {
|
||||||
|
const { container } = render(<ClientStrip clients={clients.slice(0, 1)} />);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("link")).toHaveLength(1);
|
||||||
|
expect(container.querySelectorAll("[aria-hidden='true']")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: several clients -> a separator before every client except the first", () => {
|
||||||
|
render(<ClientStrip clients={clients} />);
|
||||||
|
|
||||||
|
const items = screen.getAllByRole("listitem");
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
items.map((item) => item.querySelector("[aria-hidden='true']") !== null),
|
||||||
|
).toEqual([false, true, true]);
|
||||||
|
expect(items[1].querySelector("[aria-hidden='true']")).toHaveTextContent(
|
||||||
|
"·",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: className given -> appended to the wrapper without a stray space", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ClientStrip clients={clients} className="hero-in mt-sp-4" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.className.endsWith("hero-in mt-sp-4")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: className omitted -> the wrapper class has no trailing space", () => {
|
||||||
|
const { container } = render(<ClientStrip clients={clients} />);
|
||||||
|
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.className).toBe(wrapper.className.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import ClientStrip from "./ClientStrip";
|
||||||
|
|
||||||
|
const clients = [
|
||||||
|
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
|
||||||
|
{ id: "lavacao-047", name: "Lavação 047" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("lists each client as a link to its case", () => {
|
||||||
|
render(<ClientStrip clients={clients} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Já entregue para:")).toBeInTheDocument();
|
||||||
|
const list = screen.getByRole("list");
|
||||||
|
const links = within(list).getAllByRole("link");
|
||||||
|
expect(links.map((link) => link.textContent)).toEqual([
|
||||||
|
"SEV Moda Íntima",
|
||||||
|
"Lavação 047",
|
||||||
|
]);
|
||||||
|
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||||
|
"/cases/sev-modaintima",
|
||||||
|
"/cases/lavacao-047",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing without clients", () => {
|
||||||
|
const { container } = render(<ClientStrip clients={[]} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
|
||||||
|
const withoutProp = render(<ClientStrip />);
|
||||||
|
expect(withoutProp.container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the years of experience in the same line, after the clients", () => {
|
||||||
|
render(<ClientStrip clients={clients} years={5} />);
|
||||||
|
|
||||||
|
const experience = screen.getByText("anos de experiência");
|
||||||
|
expect(screen.getByText("5")).toBeInTheDocument();
|
||||||
|
const lastClient = screen.getByRole("link", { name: "Lavação 047" });
|
||||||
|
expect(
|
||||||
|
lastClient.compareDocumentPosition(experience) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(experience.closest("div")).toBe(
|
||||||
|
screen.getByText("Já entregue para:").parentElement,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the experience when there are no clients, and nothing without either", () => {
|
||||||
|
const only = render(<ClientStrip years={5} />);
|
||||||
|
expect(screen.queryByText("Já entregue para:")).toBeNull();
|
||||||
|
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
|
||||||
|
only.unmount();
|
||||||
|
|
||||||
|
const none = render(<ClientStrip years={0} />);
|
||||||
|
expect(none.container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every client link a touch target of at least 44 px (min-h-11)", () => {
|
||||||
|
render(<ClientStrip clients={clients} />);
|
||||||
|
|
||||||
|
for (const link of screen.getAllByRole("link")) {
|
||||||
|
expect(link).toHaveClass("min-h-11");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the separator only BETWEEN items: none before the first, one before each of the others (years included)", () => {
|
||||||
|
const withYears = render(<ClientStrip clients={clients} years={5} />);
|
||||||
|
const items = withYears.container.querySelectorAll("li");
|
||||||
|
expect(items).toHaveLength(clients.length + 1);
|
||||||
|
expect(
|
||||||
|
Array.from(items).map(
|
||||||
|
(item) => item.querySelector("[aria-hidden='true']") !== null,
|
||||||
|
),
|
||||||
|
).toEqual([false, true, true]);
|
||||||
|
// nenhum item termina com o ponto: ele sempre abre o item, para a margem negativa escondê-lo no começo de uma linha
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.lastElementChild).not.toHaveAttribute("aria-hidden");
|
||||||
|
}
|
||||||
|
withYears.unmount();
|
||||||
|
|
||||||
|
// zero anos não escreve um "0" solto nem um ponto a mais
|
||||||
|
const zero = render(<ClientStrip clients={clients} years={0} />);
|
||||||
|
expect(
|
||||||
|
zero.container.querySelectorAll("li [aria-hidden='true']"),
|
||||||
|
).toHaveLength(clients.length - 1);
|
||||||
|
expect(zero.container).not.toHaveTextContent(/anos de experiência/);
|
||||||
|
expect(screen.queryByText("0")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the separator of an item that wraps to the next line: negative margin on every item but the first, and the list clips the overflow on the x axis", () => {
|
||||||
|
const { container } = render(<ClientStrip clients={clients} years={5} />);
|
||||||
|
|
||||||
|
const list = container.querySelector("ul") as HTMLElement;
|
||||||
|
expect(list).toHaveClass("flex-wrap", "overflow-x-clip");
|
||||||
|
const items = Array.from(list.querySelectorAll("li"));
|
||||||
|
expect(items[0]).not.toHaveClass("-ml-4");
|
||||||
|
for (const item of items.slice(1)) {
|
||||||
|
expect(item).toHaveClass("-ml-4");
|
||||||
|
// o ponto ocupa os mesmos 16 px que a margem negativa
|
||||||
|
expect(item.querySelector("[aria-hidden='true']")).toHaveClass("w-4");
|
||||||
|
}
|
||||||
|
for (const item of items) expect(item).toHaveClass("pr-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the years number in the display font (the mono font is not downloaded above the fold)", () => {
|
||||||
|
render(<ClientStrip clients={clients} years={5} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("5").className).not.toContain("font-mono");
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
|
export type ClientLink = { id: string; name: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
|
||||||
|
* mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado.
|
||||||
|
* Os links têm 44 px de altura de toque; a rota só é pré-carregada quando a pessoa chega no link.
|
||||||
|
*
|
||||||
|
* O ponto separador vem ANTES de cada item, menos do primeiro, e só aparece ENTRE itens da mesma linha. Cada item
|
||||||
|
* (menos o primeiro) tem 16 px de margem negativa (`-ml-4`), que cobrem os 16 px de respiro (`pr-4`) do item
|
||||||
|
* anterior, e é nesses 16 px que o ponto (`w-4`) fica. Quando a lista quebra e um item abre a linha seguinte, o
|
||||||
|
* ponto dele cai na margem à esquerda da lista e o `overflow-x: clip` o esconde: nenhuma linha termina nem começa
|
||||||
|
* com "·". O recuo de 4 px da lista (`-ml-1 pl-1`) só dá espaço ao contorno de foco do primeiro link (2 px de
|
||||||
|
* contorno e 2 px de afastamento); o ponto, centrado na margem, fica 6 px além dele.
|
||||||
|
*/
|
||||||
|
export default function ClientStrip({
|
||||||
|
clients = [],
|
||||||
|
years,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
clients?: ClientLink[];
|
||||||
|
/** Anos de experiência, na mesma linha dos clientes. */
|
||||||
|
years?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (clients.length === 0 && !years) return null;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...clients.map((client) => ({
|
||||||
|
key: client.id,
|
||||||
|
node: (
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href={`/cases/${client.id}`}
|
||||||
|
className="inline-flex min-h-11 items-center font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)]"
|
||||||
|
>
|
||||||
|
{client.name}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
...(years
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "years",
|
||||||
|
node: (
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-[var(--slash)]">
|
||||||
|
{years}
|
||||||
|
</span>{" "}
|
||||||
|
anos de experiência
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] ${className}`.trim()}
|
||||||
|
>
|
||||||
|
{clients.length > 0 && <p>Já entregue para:</p>}
|
||||||
|
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.key}
|
||||||
|
className={`flex items-center pr-4${index > 0 ? " -ml-4" : ""}`}
|
||||||
|
>
|
||||||
|
{index > 0 && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 shrink-0 text-center text-[rgba(244,242,237,.4)]"
|
||||||
|
>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.node}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import Contact from "./Contact";
|
||||||
|
import { CONTACT_KIND_EVENT, DEFAULT_CONTACT_SUBJECT } from "./contact-subject";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base da leitura do tipo da oferta em Contact (`/?tipo=…#contact-form`), complemento de
|
||||||
|
* Contact.offer.test.tsx: tipo válido, inválido e ausente no endereço, e a ordem entre o endereço e o evento.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const subjectSelect = () =>
|
||||||
|
screen.getByRole("combobox", {
|
||||||
|
name: /tipo de projeto/i,
|
||||||
|
}) as HTMLSelectElement;
|
||||||
|
|
||||||
|
const choose = (kind: unknown) =>
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Contact: tipo no endereço", () => {
|
||||||
|
it.each([
|
||||||
|
["/", "sem busca nenhuma (tipo ausente)"],
|
||||||
|
["/?utm_source=instagram#contact", "só outros parâmetros (tipo ausente)"],
|
||||||
|
["/?tipo=#contact", "tipo vazio"],
|
||||||
|
["/?tipo=SITE#contact", "tipo com outra caixa (só vale em minúsculas)"],
|
||||||
|
["/?tipo=constructor#contact", "nome herdado de todo objeto"],
|
||||||
|
["/?tipo=outra-coisa#contact", "tipo desconhecido"],
|
||||||
|
])("path: %s (%s) -> keeps the default option", (address) => {
|
||||||
|
window.history.replaceState(null, "", address);
|
||||||
|
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
expect(subjectSelect()).toHaveValue(DEFAULT_CONTACT_SUBJECT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["/?tipo=site#contact-form", "Site que traz pedidos pelo WhatsApp"],
|
||||||
|
[
|
||||||
|
"/?tipo=sistema#contact-form",
|
||||||
|
"Sistema para tirar a operação da planilha",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/?utm_source=x&tipo=revisao#contact-form",
|
||||||
|
"Revisão de sistema que já existe",
|
||||||
|
],
|
||||||
|
// o endereço antigo, com #contact, continua valendo: o tipo vem da busca, não do fragmento
|
||||||
|
["/?tipo=site#contact", "Site que traz pedidos pelo WhatsApp"],
|
||||||
|
// com o parâmetro repetido vale o primeiro
|
||||||
|
[
|
||||||
|
"/?tipo=site&tipo=revisao#contact-form",
|
||||||
|
"Site que traz pedidos pelo WhatsApp",
|
||||||
|
],
|
||||||
|
])("path: %s (valid type) -> selects %s", (address, option) => {
|
||||||
|
window.history.replaceState(null, "", address);
|
||||||
|
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
expect(subjectSelect()).toHaveValue(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: valid type in the address, then an unknown one from the event -> the address choice stays", () => {
|
||||||
|
window.history.replaceState(null, "", "/?tipo=sistema#contact");
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
choose("desconhecido");
|
||||||
|
|
||||||
|
expect(subjectSelect()).toHaveValue(
|
||||||
|
"Sistema para tirar a operação da planilha",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: valid type in the address, then a valid one from the event -> the later choice wins", () => {
|
||||||
|
window.history.replaceState(null, "", "/?tipo=sistema#contact");
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
choose("revisao");
|
||||||
|
|
||||||
|
expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { act, render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import Contact from "./Contact";
|
||||||
|
import { CONTACT_KIND_EVENT } from "./contact-subject";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O tipo de projeto do formulário vindo da oferta escolhida na home: pelo evento que o botão da oferta dispara
|
||||||
|
* (na própria home) ou pelo `?tipo=` do endereço (ao chegar de outra página). As três ofertas entram no formulário
|
||||||
|
* com o nome que têm na home, o formulário avisa "Você escolheu: <oferta>" e o link direto do WhatsApp leva o tipo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const subjectSelect = () =>
|
||||||
|
screen.getByRole("combobox", {
|
||||||
|
name: /tipo de projeto/i,
|
||||||
|
}) as HTMLSelectElement;
|
||||||
|
|
||||||
|
const choose = (kind: unknown) =>
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Contact: opções do tipo de projeto", () => {
|
||||||
|
it("lists the three offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
const options = within(subjectSelect()).getAllByRole("option");
|
||||||
|
expect(options.map((option) => option.textContent)).toEqual([
|
||||||
|
"Ainda preciso definir",
|
||||||
|
"Site que traz pedidos pelo WhatsApp",
|
||||||
|
"Sistema para tirar a operação da planilha",
|
||||||
|
"Aplicativos",
|
||||||
|
"Integrações e automações",
|
||||||
|
"Evolução de um sistema existente",
|
||||||
|
"Revisão de sistema que já existe",
|
||||||
|
]);
|
||||||
|
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the review type in the message that goes to WhatsApp and e-mail", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
await user.selectOptions(
|
||||||
|
subjectSelect(),
|
||||||
|
"Revisão de sistema que já existe",
|
||||||
|
);
|
||||||
|
|
||||||
|
const mail = screen.getByRole("link", { name: /e-mail/i });
|
||||||
|
const body = new URL(mail.getAttribute("href") ?? "").searchParams.get(
|
||||||
|
"body",
|
||||||
|
);
|
||||||
|
expect(body).toContain("Tipo de projeto: Revisão de sistema que já existe");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Contact: tipo escolhido pela oferta", () => {
|
||||||
|
it.each([
|
||||||
|
["site", "Site que traz pedidos pelo WhatsApp"],
|
||||||
|
["sistema", "Sistema para tirar a operação da planilha"],
|
||||||
|
["revisao", "Revisão de sistema que já existe"],
|
||||||
|
])("the offer event with %s selects %s", (kind, option) => {
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
choose(kind);
|
||||||
|
|
||||||
|
expect(subjectSelect()).toHaveValue(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a later offer replaces the earlier choice, and a person's own pick is replaced by the offer too", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
choose("site");
|
||||||
|
await user.selectOptions(subjectSelect(), "Aplicativos");
|
||||||
|
expect(subjectSelect()).toHaveValue("Aplicativos");
|
||||||
|
choose("revisao");
|
||||||
|
|
||||||
|
expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"desconhecido",
|
||||||
|
"constructor",
|
||||||
|
"__proto__",
|
||||||
|
"",
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
42,
|
||||||
|
])("an unknown type %p changes nothing", (kind) => {
|
||||||
|
render(<Contact />);
|
||||||
|
choose("sistema");
|
||||||
|
|
||||||
|
choose(kind);
|
||||||
|
|
||||||
|
expect(subjectSelect()).toHaveValue(
|
||||||
|
"Sistema para tirar a operação da planilha",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads ?tipo= from the address when the form mounts (arrival from another page)", () => {
|
||||||
|
window.history.replaceState(null, "", "/?tipo=revisao#contact-form");
|
||||||
|
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
|
"Você escolheu: Revisão de sistema que já existe",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unknown ?tipo= in the address and keeps the default", () => {
|
||||||
|
window.history.replaceState(null, "", "/?tipo=outra-coisa");
|
||||||
|
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops listening when it unmounts", () => {
|
||||||
|
const add = jest.spyOn(window, "addEventListener");
|
||||||
|
const remove = jest.spyOn(window, "removeEventListener");
|
||||||
|
const { unmount } = render(<Contact />);
|
||||||
|
const registered = add.mock.calls.find(
|
||||||
|
([type]) => type === CONTACT_KIND_EVENT,
|
||||||
|
);
|
||||||
|
expect(registered).toBeDefined();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(remove).toHaveBeenCalledWith(CONTACT_KIND_EVENT, registered?.[1]);
|
||||||
|
add.mockRestore();
|
||||||
|
remove.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Contact: você escolheu e o WhatsApp direto", () => {
|
||||||
|
const directLink = () =>
|
||||||
|
screen.getByRole("link", { name: /Prefere conversar direto/ });
|
||||||
|
const directMessage = () =>
|
||||||
|
new URL(directLink().getAttribute("href") ?? "").searchParams.get("text");
|
||||||
|
|
||||||
|
it("renders the form card with the id the offers scroll to, and an empty status until an offer is chosen", () => {
|
||||||
|
const { container } = render(<Contact />);
|
||||||
|
|
||||||
|
expect(container.querySelector("form")).toHaveAttribute(
|
||||||
|
"id",
|
||||||
|
"contact-form",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["site", "Site que traz pedidos pelo WhatsApp"],
|
||||||
|
["sistema", "Sistema para tirar a operação da planilha"],
|
||||||
|
["revisao", "Revisão de sistema que já existe"],
|
||||||
|
])(
|
||||||
|
"after the offer %s the form says 'Você escolheu: <name>'",
|
||||||
|
(kind, name) => {
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
choose(kind);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
|
`Você escolheu: ${name}`,
|
||||||
|
);
|
||||||
|
expect(within(screen.getByRole("status")).getByText(name).tagName).toBe(
|
||||||
|
"STRONG",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("drops the message when the person picks another type in the form, and keeps it for an unknown offer", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
choose("desconhecido");
|
||||||
|
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||||
|
choose("sistema");
|
||||||
|
expect(screen.getByRole("status")).not.toBeEmptyDOMElement();
|
||||||
|
await user.selectOptions(subjectSelect(), "Aplicativos");
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the chosen type to the direct WhatsApp link of the contact column, as a phrase, and back to 'um projeto'", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
expect(directMessage()).toBe(
|
||||||
|
"Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.",
|
||||||
|
);
|
||||||
|
|
||||||
|
choose("sistema");
|
||||||
|
expect(directMessage()).toBe(
|
||||||
|
"Olá, Evandro! Quero conversar sobre um sistema para tirar a operação da planilha. Contato iniciado pelo site evandro.dev.br.",
|
||||||
|
);
|
||||||
|
choose("revisao");
|
||||||
|
expect(directMessage()).toContain(
|
||||||
|
"Quero conversar sobre a revisão de um sistema que já existe.",
|
||||||
|
);
|
||||||
|
// quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem
|
||||||
|
await user.selectOptions(
|
||||||
|
subjectSelect(),
|
||||||
|
"Site que traz pedidos pelo WhatsApp",
|
||||||
|
);
|
||||||
|
expect(directMessage()).toContain(
|
||||||
|
"Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
|
||||||
|
);
|
||||||
|
// um tipo sem oferta volta para a frase de "um projeto"
|
||||||
|
await user.selectOptions(subjectSelect(), "Aplicativos");
|
||||||
|
expect(directMessage()).toContain("Quero conversar sobre um projeto.");
|
||||||
|
// nada de nome de oferta com maiúscula na frase
|
||||||
|
expect(directMessage()).not.toMatch(/sobre [A-Z]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Contact: texto da seção", () => {
|
||||||
|
it("keeps 'conversar direto' only on the WhatsApp line, with no other 'fala direto' phrasing", () => {
|
||||||
|
const { container } = render(<Contact />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Prefere conversar direto?")).toBeInTheDocument();
|
||||||
|
expect(container.textContent).not.toMatch(/fala direto|fale direto/i);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 3,
|
||||||
|
name: "Como a conversa começa.",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import Contact from "./Contact";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de Contact (complemento de Contact.test.tsx): validate() campo a campo,
|
||||||
|
* foco no primeiro campo inválido, limpeza independente dos erros e montagem da mensagem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||||
|
localStorage.setItem("evandro.analytics-consent.v1", "granted");
|
||||||
|
delete window["ga-disable-G-QTT9QT0T3S"];
|
||||||
|
window.gtag = jest.fn();
|
||||||
|
// uma aba aberta de verdade: se devolver null, o Contact cai para location.href
|
||||||
|
jest
|
||||||
|
.spyOn(window, "open")
|
||||||
|
.mockImplementation(() => ({ opener: {} }) as unknown as Window);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
delete window.gtag;
|
||||||
|
});
|
||||||
|
|
||||||
|
const NAME_ERROR = "Informe seu nome para eu saber com quem falo.";
|
||||||
|
const EMAIL_ERROR =
|
||||||
|
"Confira o e-mail: ele precisa ter o formato [email protected].";
|
||||||
|
const MESSAGE_ERROR = "Conte em poucas palavras o que você precisa resolver.";
|
||||||
|
|
||||||
|
const nameField = () => screen.getByRole("textbox", { name: /nome/i });
|
||||||
|
const emailField = () => screen.getByRole("textbox", { name: /e-mail/i });
|
||||||
|
const messageField = () => screen.getByRole("textbox", { name: /contexto/i });
|
||||||
|
const submitButton = () => screen.getByRole("button", { name: /whatsapp/i });
|
||||||
|
|
||||||
|
async function fill(
|
||||||
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
|
values: { name?: string; email?: string; message?: string },
|
||||||
|
) {
|
||||||
|
if (values.name !== undefined) await user.type(nameField(), values.name);
|
||||||
|
if (values.email !== undefined) await user.type(emailField(), values.email);
|
||||||
|
if (values.message !== undefined)
|
||||||
|
await user.type(messageField(), values.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentMessage() {
|
||||||
|
const url = new URL(String(jest.mocked(window.open).mock.calls[0][0]));
|
||||||
|
return url.searchParams.get("text") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Contact: validate()", () => {
|
||||||
|
it("path: only the name is missing -> only the name is flagged and gets the focus", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await fill(user, {
|
||||||
|
email: "[email protected]",
|
||||||
|
message: "Organizar pedidos",
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton());
|
||||||
|
|
||||||
|
expect(nameField()).toHaveAttribute("aria-invalid", "true");
|
||||||
|
expect(emailField()).not.toHaveAttribute("aria-invalid");
|
||||||
|
expect(messageField()).not.toHaveAttribute("aria-invalid");
|
||||||
|
expect(screen.getByText(NAME_ERROR)).toBeVisible();
|
||||||
|
expect(screen.queryByText(EMAIL_ERROR)).toBeNull();
|
||||||
|
expect(screen.queryByText(MESSAGE_ERROR)).toBeNull();
|
||||||
|
expect(nameField()).toHaveFocus();
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
expect(window.gtag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: only the e-mail is malformed -> only the e-mail is flagged and gets the focus (not the name)", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await fill(user, {
|
||||||
|
name: "Ana",
|
||||||
|
email: "ana@semponto",
|
||||||
|
message: "Organizar pedidos",
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton());
|
||||||
|
|
||||||
|
expect(emailField()).toHaveAttribute("aria-invalid", "true");
|
||||||
|
expect(emailField()).toHaveAccessibleDescription(EMAIL_ERROR);
|
||||||
|
expect(nameField()).not.toHaveAttribute("aria-invalid");
|
||||||
|
expect(messageField()).not.toHaveAttribute("aria-invalid");
|
||||||
|
expect(emailField()).toHaveFocus();
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: only the message is missing -> only the message is flagged and gets the focus", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await fill(user, { name: "Ana" });
|
||||||
|
|
||||||
|
await user.click(submitButton());
|
||||||
|
|
||||||
|
expect(messageField()).toHaveAttribute("aria-invalid", "true");
|
||||||
|
expect(messageField()).toHaveAccessibleDescription(
|
||||||
|
new RegExp(MESSAGE_ERROR),
|
||||||
|
);
|
||||||
|
expect(nameField()).not.toHaveAttribute("aria-invalid");
|
||||||
|
expect(emailField()).not.toHaveAttribute("aria-invalid");
|
||||||
|
expect(messageField()).toHaveFocus();
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: name and message made only of spaces -> both rejected", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await fill(user, { name: " ", message: " " });
|
||||||
|
|
||||||
|
await user.click(submitButton());
|
||||||
|
|
||||||
|
expect(screen.getByText(NAME_ERROR)).toBeVisible();
|
||||||
|
expect(screen.getByText(MESSAGE_ERROR)).toBeVisible();
|
||||||
|
expect(nameField()).toHaveFocus();
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: e-mail made only of spaces -> counts as omitted: no error and no e-mail line", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await fill(user, {
|
||||||
|
name: "Ana",
|
||||||
|
email: " ",
|
||||||
|
message: "Organizar pedidos",
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton());
|
||||||
|
|
||||||
|
expect(screen.queryByText(EMAIL_ERROR)).toBeNull();
|
||||||
|
expect(window.open).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sentMessage()).not.toContain("E-mail:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["a@b", "a@c.", "@c.com", "a@@c.com", "a [email protected]", "sem-arroba.com"])(
|
||||||
|
"path: e-mail %j -> invalid format is blocked",
|
||||||
|
async (email) => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await fill(user, { name: "Ana", email, message: "Organizar pedidos" });
|
||||||
|
|
||||||
|
await user.click(submitButton());
|
||||||
|
|
||||||
|
expect(screen.getByText(EMAIL_ERROR)).toBeVisible();
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["[email protected]", "[email protected]"])(
|
||||||
|
"path: e-mail %j -> valid format is accepted and sent",
|
||||||
|
async (email) => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await fill(user, { name: "Ana", email, message: "Organizar pedidos" });
|
||||||
|
|
||||||
|
await user.click(submitButton());
|
||||||
|
|
||||||
|
expect(screen.queryByText(EMAIL_ERROR)).toBeNull();
|
||||||
|
expect(window.open).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sentMessage()).toContain(`E-mail: ${email}\n`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Contact: updateField() and the error state", () => {
|
||||||
|
it("path: typing in one field clears only that field's error", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await user.click(submitButton());
|
||||||
|
expect(screen.getByText(NAME_ERROR)).toBeVisible();
|
||||||
|
expect(screen.getByText(MESSAGE_ERROR)).toBeVisible();
|
||||||
|
|
||||||
|
await user.type(messageField(), "Organizar pedidos");
|
||||||
|
|
||||||
|
expect(screen.queryByText(MESSAGE_ERROR)).toBeNull();
|
||||||
|
expect(messageField()).not.toHaveAttribute("aria-invalid");
|
||||||
|
expect(screen.getByText(NAME_ERROR)).toBeVisible();
|
||||||
|
expect(nameField()).toHaveAttribute("aria-invalid", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: changing the project type keeps the other fields' errors untouched", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await user.click(submitButton());
|
||||||
|
|
||||||
|
await user.selectOptions(
|
||||||
|
screen.getByRole("combobox", { name: /tipo de projeto/i }),
|
||||||
|
"Aplicativos",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(NAME_ERROR)).toBeVisible();
|
||||||
|
expect(screen.getByText(MESSAGE_ERROR)).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("combobox", { name: /tipo de projeto/i }),
|
||||||
|
).toHaveValue("Aplicativos");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: fixing every error and submitting again -> errors gone, WhatsApp opens once", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await user.click(submitButton());
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await fill(user, { name: "Ana", message: "Organizar pedidos" });
|
||||||
|
await user.click(submitButton());
|
||||||
|
|
||||||
|
expect(screen.queryByText(NAME_ERROR)).toBeNull();
|
||||||
|
expect(screen.queryByText(MESSAGE_ERROR)).toBeNull();
|
||||||
|
expect(window.open).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.gtag).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Contact: mensagem montada", () => {
|
||||||
|
it("path: surrounding spaces are trimmed in name, e-mail and message", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await fill(user, {
|
||||||
|
name: " Ana ",
|
||||||
|
email: " [email protected] ",
|
||||||
|
message: " Organizar pedidos ",
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton());
|
||||||
|
|
||||||
|
const message = sentMessage();
|
||||||
|
expect(message).toContain("Nome: Ana\n");
|
||||||
|
expect(message).toContain("E-mail: [email protected]\n");
|
||||||
|
expect(message).toContain(
|
||||||
|
"Contexto, objetivo e prazo:\nOrganizar pedidos\n\n",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the chosen project type and the e-mail line reach the mailto draft, and the e-mail line is omitted when blank", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await user.selectOptions(
|
||||||
|
screen.getByRole("combobox", { name: /tipo de projeto/i }),
|
||||||
|
"Aplicativos",
|
||||||
|
);
|
||||||
|
await fill(user, { name: "Ana", message: "Organizar pedidos" });
|
||||||
|
|
||||||
|
const mail = screen.getByRole("link", {
|
||||||
|
name: "Prefere e-mail? Enviar por e-mail",
|
||||||
|
});
|
||||||
|
const body = new URL(mail.getAttribute("href") ?? "").searchParams.get(
|
||||||
|
"body",
|
||||||
|
);
|
||||||
|
expect(body).toContain("Tipo de projeto: Aplicativos");
|
||||||
|
expect(body).not.toContain("E-mail:");
|
||||||
|
|
||||||
|
await user.type(emailField(), "[email protected]");
|
||||||
|
const withEmail = new URL(
|
||||||
|
screen
|
||||||
|
.getByRole("link", { name: "Prefere e-mail? Enviar por e-mail" })
|
||||||
|
.getAttribute("href") ?? "",
|
||||||
|
).searchParams.get("body");
|
||||||
|
expect(withEmail).toContain("E-mail: [email protected]\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the direct e-mail row reports its own placement", async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
const direct = screen.getByRole("link", {
|
||||||
|
name: /evandrofonsecajunior@gmail\.com/,
|
||||||
|
});
|
||||||
|
// o jsdom não navega para mailto:; o clique só precisa chegar ao rastreio
|
||||||
|
direct.addEventListener("click", (event) => event.preventDefault());
|
||||||
|
await user.click(direct);
|
||||||
|
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"email_click",
|
||||||
|
expect.objectContaining({ placement: "contact_direct" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import Contact from "./Contact";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sem atraso entre as teclas: com o atraso padrão, digitar o contexto longo (cerca de 130 caracteres) levava
|
||||||
|
* mais de 5 s em máquina lenta e o teste estourava o tempo. O texto longo ainda entra por colagem.
|
||||||
|
*/
|
||||||
|
const setup = () => userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
/** Aba aberta de verdade; o Contact corta o vínculo com a página (opener = null) logo depois. */
|
||||||
|
let openedWindow: { opener: unknown };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||||
|
localStorage.setItem("evandro.analytics-consent.v1", "granted");
|
||||||
|
delete window["ga-disable-G-QTT9QT0T3S"];
|
||||||
|
window.gtag = jest.fn();
|
||||||
|
openedWindow = { opener: {} };
|
||||||
|
jest
|
||||||
|
.spyOn(window, "open")
|
||||||
|
.mockImplementation(() => openedWindow as unknown as Window);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
delete window.gtag;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepares an isolated WhatsApp draft with the entered project details only after submission", async () => {
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
const name = "Ana & João";
|
||||||
|
const email = "[email protected]";
|
||||||
|
const context =
|
||||||
|
"Integrar pedidos & estoque? Objetivo: evitar retrabalho.\nPrazo: outubro #2026";
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), name);
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /e-mail/i }), email);
|
||||||
|
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
|
||||||
|
await user.selectOptions(subject, "Integrações e automações");
|
||||||
|
await user.click(screen.getByRole("textbox", { name: /contexto/i }));
|
||||||
|
await user.paste(context);
|
||||||
|
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
expect(window.gtag).not.toHaveBeenCalled();
|
||||||
|
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||||
|
|
||||||
|
expect(window.open).toHaveBeenCalledTimes(1);
|
||||||
|
const [destination, target, ...rest] = jest.mocked(window.open).mock.calls[0];
|
||||||
|
const url = new URL(String(destination));
|
||||||
|
expect(url.origin).toBe("https://wa.me");
|
||||||
|
expect(url.pathname).toMatch(/^\/\d+$/);
|
||||||
|
expect([...url.searchParams.keys()]).toEqual(["text"]);
|
||||||
|
const message = url.searchParams.get("text");
|
||||||
|
expect(message).toContain(name);
|
||||||
|
expect(message).toContain(email);
|
||||||
|
expect(message).toContain("Integrações e automações");
|
||||||
|
expect(message).toContain(context);
|
||||||
|
expect(target).toBe("_blank");
|
||||||
|
// sem "noopener" na chamada (ele faria o window.open devolver sempre null); o vínculo é cortado depois
|
||||||
|
expect(rest).toEqual([]);
|
||||||
|
expect(openedWindow.opener).toBeNull();
|
||||||
|
expect(window.gtag).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", {
|
||||||
|
send_to: "G-QTT9QT0T3S",
|
||||||
|
service: "Contato",
|
||||||
|
placement: "contact_form",
|
||||||
|
page_path: "/",
|
||||||
|
page_location: "https://evandro.dev.br/",
|
||||||
|
page_referrer: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open WhatsApp while required fields are empty or the email is invalid", async () => {
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
const submit = screen.getByRole("button", { name: /whatsapp/i });
|
||||||
|
await user.click(submit);
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /e-mail/i }),
|
||||||
|
"email-invalido",
|
||||||
|
);
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /contexto/i }),
|
||||||
|
"Organizar pedidos",
|
||||||
|
);
|
||||||
|
await user.click(submit);
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
expect(window.gtag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the project draft with analytics unavailable and consent denied", async () => {
|
||||||
|
localStorage.setItem("evandro.analytics-consent.v1", "denied");
|
||||||
|
delete window.gtag;
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /e-mail/i }),
|
||||||
|
"[email protected]",
|
||||||
|
);
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /contexto/i }),
|
||||||
|
"Organizar pedidos",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||||
|
expect(window.open).toHaveBeenCalledTimes(1);
|
||||||
|
const destination = new URL(
|
||||||
|
String(jest.mocked(window.open).mock.calls[0][0]),
|
||||||
|
);
|
||||||
|
expect(destination.origin).toBe("https://wa.me");
|
||||||
|
expect(destination.searchParams.get("text")).toContain("Organizar pedidos");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the visitor skip the e-mail and leaves that line out of the message", async () => {
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("textbox", { name: /e-mail/i })).not.toBeRequired();
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /contexto/i }),
|
||||||
|
"Organizar pedidos",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||||
|
|
||||||
|
expect(window.open).toHaveBeenCalledTimes(1);
|
||||||
|
const url = new URL(String(jest.mocked(window.open).mock.calls[0][0]));
|
||||||
|
const message = url.searchParams.get("text") ?? "";
|
||||||
|
expect(message).toContain("Nome: Ana");
|
||||||
|
expect(message).toContain("Tipo de projeto: Ainda preciso definir");
|
||||||
|
expect(message).not.toContain("E-mail:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers 'Ainda preciso definir' as the first and default project type", () => {
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
|
||||||
|
const options = Array.from(subject.querySelectorAll("option")).map(
|
||||||
|
(option) => option.value,
|
||||||
|
);
|
||||||
|
expect(options[0]).toBe("Ainda preciso definir");
|
||||||
|
expect(options).toContain("Site que traz pedidos pelo WhatsApp");
|
||||||
|
expect(subject).toHaveValue("Ainda preciso definir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the first field 'Seu nome'", () => {
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Seu nome")).toHaveAttribute("name", "name");
|
||||||
|
expect(screen.queryByLabelText("Nome completo")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains each invalid field in Portuguese and marks it for assistive technology", async () => {
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /e-mail/i }),
|
||||||
|
"email-invalido",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||||
|
|
||||||
|
const name = screen.getByRole("textbox", { name: /nome/i });
|
||||||
|
const email = screen.getByRole("textbox", { name: /e-mail/i });
|
||||||
|
const message = screen.getByRole("textbox", { name: /contexto/i });
|
||||||
|
for (const field of [name, email, message]) {
|
||||||
|
expect(field).toHaveAttribute("aria-invalid", "true");
|
||||||
|
}
|
||||||
|
expect(name).toHaveAccessibleDescription(
|
||||||
|
"Informe seu nome para eu saber com quem falo.",
|
||||||
|
);
|
||||||
|
expect(email).toHaveAccessibleDescription(
|
||||||
|
"Confira o e-mail: ele precisa ter o formato [email protected].",
|
||||||
|
);
|
||||||
|
expect(message).toHaveAccessibleDescription(
|
||||||
|
/Conte em poucas palavras o que você precisa resolver\./,
|
||||||
|
);
|
||||||
|
expect(name).toHaveFocus();
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.type(name, "Ana");
|
||||||
|
expect(name).not.toHaveAttribute("aria-invalid");
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Informe seu nome para eu saber com quem falo."),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the same text by e-mail, below the WhatsApp button", async () => {
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /contexto/i }),
|
||||||
|
"Organizar pedidos & estoque",
|
||||||
|
);
|
||||||
|
const submit = screen.getByRole("button", { name: /whatsapp/i });
|
||||||
|
const mail = screen.getByRole("link", {
|
||||||
|
name: "Prefere e-mail? Enviar por e-mail",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
submit.compareDocumentPosition(mail) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
const href = mail.getAttribute("href") ?? "";
|
||||||
|
expect(href.startsWith("mailto:[email protected]?")).toBe(true);
|
||||||
|
const url = new URL(href);
|
||||||
|
expect(url.searchParams.get("subject")).toBe("Projeto pelo site");
|
||||||
|
const body = url.searchParams.get("body") ?? "";
|
||||||
|
expect(body).toContain("Nome: Ana");
|
||||||
|
expect(body).toContain("Organizar pedidos & estoque");
|
||||||
|
expect(body).toContain("Tipo de projeto: Ainda preciso definir");
|
||||||
|
|
||||||
|
await user.click(mail);
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"email_click",
|
||||||
|
expect.objectContaining({ placement: "contact_form" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call the direct WhatsApp row 'Contato'", () => {
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
const direct = screen.getByRole("link", { name: /Prefere conversar direto/ });
|
||||||
|
const text = new URL(direct.getAttribute("href") ?? "").searchParams.get(
|
||||||
|
"text",
|
||||||
|
);
|
||||||
|
expect(text).toContain("Quero conversar sobre um projeto.");
|
||||||
|
expect(text).not.toMatch(/sobre Contato/);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("e-mail draft validation", () => {
|
||||||
|
it("does not open the e-mail client while the form is empty, and explains what is missing", () => {
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
const mail = screen.getByRole("link", {
|
||||||
|
name: "Prefere e-mail? Enviar por e-mail",
|
||||||
|
});
|
||||||
|
// fireEvent.click devolve false quando o clique foi cancelado (preventDefault)
|
||||||
|
expect(fireEvent.click(mail)).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("Informe seu nome para eu saber com quem falo."),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Conte em poucas palavras o que você precisa resolver."),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.getByRole("textbox", { name: /nome/i })).toHaveFocus();
|
||||||
|
expect(window.gtag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a malformed e-mail address the same way", async () => {
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /e-mail/i }),
|
||||||
|
"email-invalido",
|
||||||
|
);
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /contexto/i }),
|
||||||
|
"Organizar pedidos",
|
||||||
|
);
|
||||||
|
|
||||||
|
const mail = screen.getByRole("link", {
|
||||||
|
name: "Prefere e-mail? Enviar por e-mail",
|
||||||
|
});
|
||||||
|
expect(fireEvent.click(mail)).toBe(false);
|
||||||
|
expect(screen.getByRole("textbox", { name: /e-mail/i })).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the click through, and measures it, once the form is valid", async () => {
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /contexto/i }),
|
||||||
|
"Organizar pedidos",
|
||||||
|
);
|
||||||
|
|
||||||
|
const mail = screen.getByRole("link", {
|
||||||
|
name: "Prefere e-mail? Enviar por e-mail",
|
||||||
|
});
|
||||||
|
// o clique não é cancelado pela validação: o navegador segue para o mailto:
|
||||||
|
expect(fireEvent.click(mail)).toBe(true);
|
||||||
|
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"email_click",
|
||||||
|
expect.objectContaining({ placement: "contact_form" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WhatsApp fallback", () => {
|
||||||
|
it("goes to WhatsApp in the same page when the browser blocks the new tab", async () => {
|
||||||
|
// o jsdom não navega: ele avisa "Not implemented: navigation" ao atribuir location.href
|
||||||
|
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
jest.mocked(window.open).mockImplementation(() => null);
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /contexto/i }),
|
||||||
|
"Organizar pedidos",
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||||
|
|
||||||
|
expect(window.open).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
errors.mock.calls.some(([error]) =>
|
||||||
|
String(error).includes("Not implemented: navigation"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"whatsapp_open",
|
||||||
|
expect.objectContaining({ placement: "contact_form" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not navigate the page when the new tab opened", async () => {
|
||||||
|
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /contexto/i }),
|
||||||
|
"Organizar pedidos",
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||||
|
|
||||||
|
expect(errors).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Preenche o mínimo e envia; devolve os erros que o jsdom registrou ao "navegar" para location.href. */
|
||||||
|
async function submitWithMinimumFields() {
|
||||||
|
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const user = setup();
|
||||||
|
render(<Contact />);
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /contexto/i }),
|
||||||
|
"Organizar pedidos",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigated = (errors: jest.SpyInstance) =>
|
||||||
|
errors.mock.calls.some(([error]) =>
|
||||||
|
String(error).includes("Not implemented: navigation"),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("goes to WhatsApp in the same page when there is no window.open", async () => {
|
||||||
|
const original = window.open;
|
||||||
|
Object.defineProperty(window, "open", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const errors = await submitWithMinimumFields();
|
||||||
|
|
||||||
|
expect(navigated(errors)).toBe(true);
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"whatsapp_open",
|
||||||
|
expect.objectContaining({ placement: "contact_form" }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, "open", {
|
||||||
|
configurable: true,
|
||||||
|
value: original,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes to WhatsApp in the same page when window.open throws", async () => {
|
||||||
|
jest.mocked(window.open).mockImplementation(() => {
|
||||||
|
throw new DOMException("bloqueado", "SecurityError");
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = await submitWithMinimumFields();
|
||||||
|
|
||||||
|
expect(navigated(errors)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the new tab and stays on the same page when its link to this page cannot be cut", async () => {
|
||||||
|
const close = jest.fn();
|
||||||
|
const tab = {
|
||||||
|
close,
|
||||||
|
get opener() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
set opener(_value: unknown) {
|
||||||
|
throw new DOMException("sem acesso", "SecurityError");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
jest.mocked(window.open).mockImplementation(() => tab as unknown as Window);
|
||||||
|
|
||||||
|
const errors = await submitWithMinimumFields();
|
||||||
|
|
||||||
|
expect(close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigated(errors)).toBe(true);
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"whatsapp_open",
|
||||||
|
expect.objectContaining({ placement: "contact_form" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("without JavaScript", () => {
|
||||||
|
it("keeps the form body out of the way and offers direct WhatsApp and e-mail links in a <noscript>", () => {
|
||||||
|
const html = renderToStaticMarkup(<Contact />);
|
||||||
|
|
||||||
|
const noscript = html.match(/<noscript>([\s\S]*?)<\/noscript>/)?.[1] ?? "";
|
||||||
|
expect(noscript).toMatch(/href="https:\/\/wa\.me\/\d+\?text=/);
|
||||||
|
expect(noscript).toContain('href="mailto:[email protected]"');
|
||||||
|
// o botão de envio e os campos ficam na área "js-only", que app/layout.tsx esconde sem JavaScript
|
||||||
|
const jsOnly = html.match(/class="js-only[^"]*"/);
|
||||||
|
expect(jsOnly).not.toBeNull();
|
||||||
|
expect(html.indexOf("js-only")).toBeLessThan(
|
||||||
|
html.indexOf("Continuar no WhatsApp"),
|
||||||
|
);
|
||||||
|
expect(html.indexOf("Continuar no WhatsApp")).toBeLessThan(
|
||||||
|
html.indexOf("<noscript>"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+423
-183
@@ -1,222 +1,462 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import type { FormEvent, MouseEvent, ReactNode } from "react";
|
||||||
import { Send, User, Mail, MessageSquare, Sparkles } from "lucide-react";
|
import { useEffect, useState } from "react";
|
||||||
import { ShimmerButton as MagicShimmerButton } from "@/components/magicui/shimmer-button";
|
import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics";
|
||||||
import Squares from "@/components/Squares";
|
import { CONTACT_EMAIL } from "@/lib/site-navigation";
|
||||||
|
import {
|
||||||
|
buildWhatsAppUrl,
|
||||||
|
getWhatsAppDisplayNumber,
|
||||||
|
OFFER_SUBJECTS,
|
||||||
|
} from "@/lib/whatsapp";
|
||||||
|
import {
|
||||||
|
CONTACT_FORM_ID,
|
||||||
|
CONTACT_KIND_EVENT,
|
||||||
|
CONTACT_KIND_PARAM,
|
||||||
|
CONTACT_KINDS,
|
||||||
|
CONTACT_SUBJECT_OPTIONS,
|
||||||
|
type ContactKind,
|
||||||
|
contactKindForSubject,
|
||||||
|
contactSubjectForKind,
|
||||||
|
DEFAULT_CONTACT_SUBJECT,
|
||||||
|
} from "./contact-subject";
|
||||||
|
import "./conversion.css";
|
||||||
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
import { WhatsAppMark } from "./WhatsAppMark";
|
||||||
|
|
||||||
// --- CONFIGURAÇÃO DE ENGENHARIA ---
|
type ContactField = "name" | "email" | "message";
|
||||||
// Substitua pelo seu número real: CódigoPaís + DDD + Número (sem espaços ou símbolos)
|
type FieldErrors = Partial<Record<ContactField, string>>;
|
||||||
const WHATSAPP_NUMBER = "5547992742365";
|
|
||||||
|
|
||||||
interface FormData {
|
const FIELD_ERRORS: Record<ContactField, string> = {
|
||||||
|
name: "Informe seu nome para eu saber com quem falo.",
|
||||||
|
email: "Confira o e-mail: ele precisa ter o formato [email protected].",
|
||||||
|
message: "Conte em poucas palavras o que você precisa resolver.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
const FIELD_ORDER: ContactField[] = ["name", "email", "message"];
|
||||||
|
|
||||||
|
/** E-mail é opcional: só é erro se vier preenchido e fora do formato. */
|
||||||
|
function validate(data: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
}): FieldErrors {
|
||||||
|
const errors: FieldErrors = {};
|
||||||
|
if (!data.name.trim()) errors.name = FIELD_ERRORS.name;
|
||||||
|
const email = data.email.trim();
|
||||||
|
if (email && !EMAIL_PATTERN.test(email)) errors.email = FIELD_ERRORS.email;
|
||||||
|
if (!data.message.trim()) errors.message = FIELD_ERRORS.message;
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mesmo texto para o WhatsApp e para o e-mail; a linha do e-mail só entra se a pessoa informou um. */
|
||||||
|
function buildProjectMessage(data: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
}) {
|
||||||
|
const email = data.email.trim();
|
||||||
|
return `Olá, Evandro! Quero conversar sobre um projeto.\n\nNome: ${data.name.trim()}\n${email ? `E-mail: ${email}\n` : ""}Tipo de projeto: ${data.subject}\n\nContexto, objetivo e prazo:\n${data.message.trim()}\n\nContato iniciado pelo site evandro.dev.br.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMailtoUrl(message: string) {
|
||||||
|
return `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent("Projeto pelo site")}&body=${encodeURIComponent(message)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldClassName =
|
||||||
|
"mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 !font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] aria-[invalid=true]:border-[#ff8a80]";
|
||||||
|
const labelClassName =
|
||||||
|
"block font-display text-[15px] font-medium text-white/85";
|
||||||
|
const errorClassName =
|
||||||
|
"mt-sp-2 font-display text-[15px] leading-6 text-[#ff9d94]";
|
||||||
|
|
||||||
|
/** Mensagem dos links diretos de quem está sem JavaScript (o formulário não funciona sem ele). */
|
||||||
|
const DIRECT_MESSAGE =
|
||||||
|
"Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.";
|
||||||
|
|
||||||
|
/** Abre a URL em outra aba, ou devolve null se não deu (sem `window.open`, bloqueado ou com erro). */
|
||||||
|
function tryOpenTab(url: string): Window | null {
|
||||||
|
try {
|
||||||
|
return window.open(url, "_blank");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corta o vínculo da aba aberta com esta página; devolve false se o navegador não deixou. */
|
||||||
|
function detachOpener(tab: Window): boolean {
|
||||||
|
try {
|
||||||
|
tab.opener = null;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper Component: Resolve o erro de tipagem TS2322 (IntrinsicAttributes)
|
* Abre o WhatsApp em outra aba. Sem "noopener" na chamada, porque com ele o window.open sempre devolve null
|
||||||
* Estendemos a interface para aceitar propriedades de estilo customizadas.
|
* e não dá para saber se abriu; o vínculo com a página (`opener`) é cortado logo em seguida. A página vai para o
|
||||||
|
* WhatsApp na própria aba quando não há aba nova (pop-up bloqueado, navegador interno de aplicativo, sem
|
||||||
|
* `window.open`) e também quando o vínculo não pôde ser cortado: nesse caso a aba aberta é fechada, para
|
||||||
|
* nenhuma aba do WhatsApp ficar com acesso a esta página.
|
||||||
*/
|
*/
|
||||||
interface ExtendedShimmerProps extends React.ComponentProps<typeof MagicShimmerButton> {
|
function openWhatsApp(url: string) {
|
||||||
shimmerColor?: string;
|
const tab = tryOpenTab(url);
|
||||||
shimmerSize?: string;
|
if (tab) {
|
||||||
type?: "button" | "submit" | "reset";
|
if (detachOpener(tab)) return;
|
||||||
disabled?: boolean;
|
tab.close();
|
||||||
|
}
|
||||||
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomShimmerButton = ({
|
/** Atributos que o campo entrega ao controle (input, textarea ou select) para o erro ser anunciado. */
|
||||||
|
interface ControlProps {
|
||||||
|
id: string;
|
||||||
|
"aria-invalid"?: true;
|
||||||
|
"aria-describedby"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rótulo, controle e mensagem de erro de um campo; o controle recebe o id e os atributos de acessibilidade. */
|
||||||
|
function Field({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
describedBy,
|
||||||
children,
|
children,
|
||||||
className,
|
}: {
|
||||||
shimmerColor = "#3b82f6",
|
id: string;
|
||||||
shimmerSize = "0.1em",
|
label: string;
|
||||||
...props
|
error?: string;
|
||||||
}: ExtendedShimmerProps) => {
|
/** Outros textos que descrevem o campo, depois do erro. */
|
||||||
|
describedBy?: string;
|
||||||
|
children: (control: ControlProps) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const errorId = `${id}-error`;
|
||||||
|
const description = [error ? errorId : "", describedBy ?? ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
return (
|
return (
|
||||||
<MagicShimmerButton
|
<div>
|
||||||
{...props}
|
<label htmlFor={id} className={labelClassName}>
|
||||||
className={className}
|
{label}
|
||||||
// @ts-ignore - Ignora restrição de tipos da lib original se necessário
|
</label>
|
||||||
shimmerColor={shimmerColor}
|
{children({
|
||||||
shimmerSize={shimmerSize}
|
id,
|
||||||
>
|
"aria-invalid": error ? true : undefined,
|
||||||
{children}
|
"aria-describedby": description || undefined,
|
||||||
</MagicShimmerButton>
|
})}
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} className={errorClassName}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function Contact() {
|
export default function Contact() {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||||
const [formData, setFormData] = useState<FormData>({
|
useEffect(() => {
|
||||||
|
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
subject: "full-stack",
|
subject: DEFAULT_CONTACT_SUBJECT as string,
|
||||||
message: "",
|
message: "",
|
||||||
});
|
});
|
||||||
|
const [errors, setErrors] = useState<FieldErrors>({});
|
||||||
|
// A oferta que a pessoa escolheu na home (botão ou `?tipo=`); some se ela trocar o tipo no próprio formulário.
|
||||||
|
const [chosenOffer, setChosenOffer] = useState<ContactKind | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
// O tipo de projeto pode vir da oferta escolhida na home: pelo endereço (`/?tipo=site#contact`, ao chegar de outra
|
||||||
e.preventDefault();
|
// página) ou pelo evento que o botão da oferta dispara na própria página. Tipo desconhecido não muda nada.
|
||||||
setIsSubmitting(true);
|
useEffect(() => {
|
||||||
|
const choose = (kind: string | null | undefined) => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
const subject = contactSubjectForKind(kind);
|
||||||
|
if (!subject) return;
|
||||||
// Definimos os emojis via Unicode Escape para máxima compatibilidade
|
setFormData((current) => ({ ...current, subject }));
|
||||||
const icons = {
|
setChosenOffer(contactKindForSubject(subject) ?? null);
|
||||||
rocket: "\u{1F680}",
|
|
||||||
person: "\u{1F464}",
|
|
||||||
email: "\u{1F4E7}",
|
|
||||||
tools: "\u{1F6E0}",
|
|
||||||
chat: "\u{1F4AC}"
|
|
||||||
};
|
};
|
||||||
|
choose(new URLSearchParams(window.location.search).get(CONTACT_KIND_PARAM));
|
||||||
|
const onChoose = (event: Event) =>
|
||||||
|
choose((event as CustomEvent<string | undefined>).detail);
|
||||||
|
window.addEventListener(CONTACT_KIND_EVENT, onChoose);
|
||||||
|
return () => window.removeEventListener(CONTACT_KIND_EVENT, onChoose);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const messageTemplate = `*NOVA SOLICITAÇÃO DE PROJETO*
|
const updateField = (field: ContactField | "subject", value: string) => {
|
||||||
|
setFormData((current) => ({ ...current, [field]: value }));
|
||||||
*Origem:* Portfólio Profissional
|
if (field === "subject") setChosenOffer(null);
|
||||||
*Prioridade:* Alta (Lead Qualificado)
|
if (field !== "subject" && errors[field]) {
|
||||||
|
setErrors((current) => ({ ...current, [field]: undefined }));
|
||||||
---
|
}
|
||||||
*CLIENTE:* ${formData.name}
|
|
||||||
*EMAIL:* ${formData.email}
|
|
||||||
*ÁREA:* ${formData.subject.toUpperCase()}
|
|
||||||
|
|
||||||
*DESCRIÇÃO:*
|
|
||||||
"${formData.message}"
|
|
||||||
---
|
|
||||||
_Mensagem gerada via Evandro.dev | Garuva, SC_`;
|
|
||||||
|
|
||||||
const encodedMessage = encodeURIComponent(messageTemplate);
|
|
||||||
const whatsappUrl = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodedMessage}`;
|
|
||||||
|
|
||||||
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
|
|
||||||
setIsSubmitting(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyles =
|
/** Mostra os erros, leva o foco ao primeiro campo inválido e diz se há algum. */
|
||||||
"w-full bg-black/40 border border-white/5 rounded-xl px-4 py-3 text-slate-200 placeholder:text-slate-600 focus:border-blue-500/50 focus:ring-2 focus:ring-blue-500/10 focus:bg-black/60 outline-none transition-all duration-300 font-mono text-sm backdrop-blur-sm group-hover:border-white/10";
|
const hasInvalidField = (form: HTMLFormElement | null) => {
|
||||||
const labelStyles =
|
const found = validate(formData);
|
||||||
"text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 flex items-center gap-2 transition-colors group-hover:text-slate-400";
|
setErrors(found);
|
||||||
|
const firstInvalid = FIELD_ORDER.find((field) => found[field]);
|
||||||
|
if (firstInvalid) {
|
||||||
|
form?.querySelector<HTMLElement>(`[name="${firstInvalid}"]`)?.focus();
|
||||||
|
}
|
||||||
|
return firstInvalid !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// O link direto de WhatsApp leva o tipo escolhido (o nome da oferta, em frase) na mensagem; sem oferta, fala de "um projeto".
|
||||||
|
const selectedOffer = contactKindForSubject(formData.subject);
|
||||||
|
const directSubject = selectedOffer
|
||||||
|
? OFFER_SUBJECTS[selectedOffer]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (hasInvalidField(e.currentTarget)) return;
|
||||||
|
openWhatsApp(buildWhatsAppUrl(buildProjectMessage(formData)));
|
||||||
|
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** O rascunho por e-mail passa pela mesma validação do WhatsApp: sem ela o e-mail sairia vazio. */
|
||||||
|
const handleEmailClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (hasInvalidField(e.currentTarget.closest("form"))) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackEmailClick({ placement: "contact_form" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="contact" className="w-full py-24 relative">
|
<section
|
||||||
|
id="contact"
|
||||||
|
aria-labelledby="contact-title"
|
||||||
<div className="w-full max-w-4xl mx-auto px-6 relative z-10">
|
className="ds-section ds-section-dark"
|
||||||
<motion.div
|
>
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="ds-section-shell grid items-start gap-sp-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-16">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
<div className="min-w-0">
|
||||||
transition={{ duration: 0.8 }}
|
<p className="ds-eyebrow">Contato</p>
|
||||||
className="relative bg-zinc-900/40 backdrop-blur-xl border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl overflow-hidden"
|
<h2
|
||||||
>
|
id="contact-title"
|
||||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500/0 via-blue-500/50 to-blue-500/0" />
|
className="ds-section-title mt-sp-4 max-w-[19ch] text-balance"
|
||||||
|
>
|
||||||
<div className="mb-10 text-center space-y-2">
|
Vamos conversar sobre o que sua empresa precisa.
|
||||||
<h2 className="text-3xl md:text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-b from-white to-white/60">
|
</h2>
|
||||||
Vamos Construir o Futuro
|
<p className="mt-sp-5 max-w-[44ch] font-display text-base leading-8 text-white/70">
|
||||||
</h2>
|
Conte o que você quer resolver. Pode ser um projeto novo, uma
|
||||||
<p className="text-slate-400 text-sm md:text-base max-w-lg mx-auto">
|
melhoria ou uma integração. Não precisa chegar com a solução
|
||||||
Descreva seu desafio técnico. Retorno com uma arquitetura sólida em até 24h.
|
definida.
|
||||||
|
</p>
|
||||||
|
<div className="mt-sp-8 border-l-2 border-[var(--slash)] pl-sp-5">
|
||||||
|
<h3 className="font-display text-lg font-semibold text-white">
|
||||||
|
Como a conversa começa.
|
||||||
|
</h3>
|
||||||
|
<p className="mt-sp-2 max-w-[44ch] font-display text-[15px] leading-7 text-white/65">
|
||||||
|
Sou Evandro. A primeira conversa é para entender sua necessidade;
|
||||||
|
escopo, prazo e proposta vêm depois.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
|
||||||
<form onSubmit={handleSubmit} className="space-y-8 relative">
|
<WhatsAppLink
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
placement="contact_direct"
|
||||||
<div className="space-y-3 group">
|
service={directSubject}
|
||||||
<label className={labelStyles}>
|
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
|
||||||
<User className="w-3 h-3 text-blue-500" /> NOME COMPLETO
|
>
|
||||||
</label>
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
|
||||||
<input
|
<WhatsAppMark className="h-6 w-6" />
|
||||||
required
|
</span>
|
||||||
type="text"
|
<span className="min-w-0 flex-1 font-display">
|
||||||
placeholder="Ex: Alan Turing"
|
<span className="block text-[15px] font-semibold text-white group-hover:text-[#25d366]">
|
||||||
className={inputStyles}
|
Prefere conversar direto?
|
||||||
value={formData.name}
|
</span>
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
<span className="mt-1 block text-[15px] text-white/65">
|
||||||
/>
|
WhatsApp · {whatsAppDisplay}
|
||||||
</div>
|
</span>
|
||||||
|
</span>
|
||||||
<div className="space-y-3 group">
|
<ArrowUpRight
|
||||||
<label className={labelStyles}>
|
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[#25d366]"
|
||||||
<Mail className="w-3 h-3 text-blue-500" /> E-MAIL CORPORATIVO
|
aria-hidden="true"
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type="email"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
className={inputStyles}
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 group">
|
|
||||||
<label className={labelStyles}>
|
|
||||||
<Sparkles className="w-3 h-3 text-blue-500" /> TIPO DE PROJETO
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
className={`${inputStyles} appearance-none cursor-pointer`}
|
|
||||||
value={formData.subject}
|
|
||||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="full-stack">Desenvolvimento Full-Stack</option>
|
|
||||||
<option value="cloud">Cloud & Infrastructure</option>
|
|
||||||
<option value="iot">Internet of Things (IoT)</option>
|
|
||||||
<option value="consultancy">Consultoria Técnica Sênior</option>
|
|
||||||
</select>
|
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 group">
|
|
||||||
<label className={labelStyles}>
|
|
||||||
<MessageSquare className="w-3 h-3 text-blue-500" /> DETALHES DO PROJETO
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
required
|
|
||||||
rows={5}
|
|
||||||
placeholder="Descreva os requisitos técnicos e objetivos do sistema..."
|
|
||||||
className={`${inputStyles} resize-none`}
|
|
||||||
value={formData.message}
|
|
||||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</WhatsAppLink>
|
||||||
|
<a
|
||||||
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
|
onClick={() => trackEmailClick({ placement: "contact_direct" })}
|
||||||
|
className="group flex min-h-16 items-center gap-sp-4 rounded-sm py-sp-5 text-white/65 no-underline hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
|
||||||
|
>
|
||||||
|
<Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||||
|
<span className="min-w-0 break-all font-display text-[15px]">
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight
|
||||||
|
className="ml-auto h-4 w-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-[15px] leading-6 text-white/55">
|
||||||
|
<MapPin className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
Garuva, Santa Catarina. Atendimento remoto em todo o Brasil.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<motion.div whileHover={{ scale: 1.01 }} whileTap={{ scale: 0.99 }} className="pt-6">
|
<form
|
||||||
<CustomShimmerButton
|
id={CONTACT_FORM_ID}
|
||||||
type="submit"
|
onSubmit={handleSubmit}
|
||||||
className="w-full h-14 rounded-xl shadow-lg shadow-blue-500/20"
|
noValidate
|
||||||
shimmerColor="#60a5fa"
|
aria-labelledby="contact-form-title"
|
||||||
shimmerSize="0.1em"
|
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
|
||||||
disabled={isSubmitting}
|
>
|
||||||
|
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
|
||||||
|
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#25d366] text-[#071b10]">
|
||||||
|
<WhatsAppMark className="h-7 w-7" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
id="contact-form-title"
|
||||||
|
className="font-display text-xl font-semibold leading-7 text-white"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-center gap-3 w-full h-full">
|
Prepare sua mensagem
|
||||||
{isSubmitting ? (
|
</h3>
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<p className="mt-1 font-display text-[15px] leading-6 text-white/65">
|
||||||
) : (
|
Você revisa no WhatsApp antes de enviar.
|
||||||
<>
|
|
||||||
<span className="text-white font-bold tracking-widest uppercase text-xs md:text-sm">
|
|
||||||
Iniciar Protocolo de Contato
|
|
||||||
</span>
|
|
||||||
<Send className="w-4 h-4 text-white" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CustomShimmerButton>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<footer className="pt-2">
|
|
||||||
<p className="text-[10px] text-center text-slate-600 font-mono uppercase tracking-widest opacity-60">
|
|
||||||
Secured by End-to-End Encryption
|
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</motion.div>
|
{/* js-only: app/layout.tsx esconde o que depende de JavaScript quando ele está desligado, e o <noscript> abaixo entra no lugar */}
|
||||||
|
<div className="js-only space-y-sp-5">
|
||||||
|
<div className="grid gap-sp-5 xl:grid-cols-2">
|
||||||
|
<Field id="contact-name" label="Seu nome" error={errors.name}>
|
||||||
|
{(control) => (
|
||||||
|
<input
|
||||||
|
{...control}
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
placeholder="Como posso te chamar?"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => updateField("name", e.target.value)}
|
||||||
|
className={fieldClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
id="contact-email"
|
||||||
|
label="E-mail (opcional)"
|
||||||
|
error={errors.email}
|
||||||
|
>
|
||||||
|
{(control) => (
|
||||||
|
<input
|
||||||
|
{...control}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => updateField("email", e.target.value)}
|
||||||
|
className={fieldClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field id="contact-subject" label="Tipo de projeto">
|
||||||
|
{(control) => (
|
||||||
|
<select
|
||||||
|
{...control}
|
||||||
|
name="subject"
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={(e) => updateField("subject", e.target.value)}
|
||||||
|
className={`${fieldClassName} [color-scheme:dark]`}
|
||||||
|
>
|
||||||
|
{CONTACT_SUBJECT_OPTIONS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{/* <output> (role status): o texto muda quando uma oferta da home é escolhida, e o leitor de tela o anuncia; vazio, não ocupa espaço */}
|
||||||
|
<output className="block !mt-sp-2 font-display text-[15px] leading-6 text-white/85 empty:!mt-0">
|
||||||
|
{chosenOffer && (
|
||||||
|
<>
|
||||||
|
Você escolheu:{" "}
|
||||||
|
<strong className="font-semibold text-white">
|
||||||
|
{CONTACT_KINDS[chosenOffer]}
|
||||||
|
</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</output>
|
||||||
|
<Field
|
||||||
|
id="contact-message"
|
||||||
|
label="Contexto do projeto"
|
||||||
|
error={errors.message}
|
||||||
|
describedBy="contact-privacy-note"
|
||||||
|
>
|
||||||
|
{(control) => (
|
||||||
|
<textarea
|
||||||
|
{...control}
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
|
||||||
|
value={formData.message}
|
||||||
|
onChange={(e) => updateField("message", e.target.value)}
|
||||||
|
className={`${fieldClassName} min-h-32 resize-y`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="cv-btn flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
|
||||||
|
>
|
||||||
|
<WhatsAppMark className="h-6 w-6 shrink-0" />
|
||||||
|
Continuar no WhatsApp
|
||||||
|
<ArrowUpRight className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<p className="text-center font-display text-[15px] leading-6 text-white/70">
|
||||||
|
<a
|
||||||
|
href={buildMailtoUrl(buildProjectMessage(formData))}
|
||||||
|
onClick={handleEmailClick}
|
||||||
|
className="inline-flex min-h-11 items-center underline underline-offset-4 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
|
||||||
|
>
|
||||||
|
Prefere e-mail? Enviar por e-mail
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
id="contact-privacy-note"
|
||||||
|
className="font-display text-[15px] leading-6 text-white/55"
|
||||||
|
>
|
||||||
|
Nada é enviado automaticamente. O WhatsApp abre com a mensagem
|
||||||
|
para você revisar. Não inclua senhas nem dados pessoais de
|
||||||
|
clientes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Sem JavaScript o formulário não envia nada: no lugar do botão, links diretos que funcionam sem script. */}
|
||||||
|
<noscript>
|
||||||
|
<p className="font-display text-base leading-7 text-white/80">
|
||||||
|
Para enviar sua mensagem, use um destes contatos:
|
||||||
|
</p>
|
||||||
|
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
|
||||||
|
<a
|
||||||
|
href={buildWhatsAppUrl(DIRECT_MESSAGE)}
|
||||||
|
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[#25d366] px-sp-4 font-semibold text-[#071b10] no-underline"
|
||||||
|
>
|
||||||
|
Conversar no WhatsApp
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
|
className="inline-flex min-h-12 items-center justify-center break-all rounded-lg border border-white/25 px-sp-4 font-medium text-white no-underline"
|
||||||
|
>
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</noscript>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import Contact from "./Contact";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de openWhatsApp, dentro de Contact: a aba abriu (o vínculo com a página é cortado),
|
||||||
|
* o navegador bloqueou (devolve null ou undefined e a própria página vai para o WhatsApp) e os argumentos da chamada.
|
||||||
|
* O envio com campos inválidos não abre nada.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NAVIGATION_ERROR = "Not implemented: navigation";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||||
|
localStorage.setItem("evandro.analytics-consent.v1", "granted");
|
||||||
|
delete window["ga-disable-G-QTT9QT0T3S"];
|
||||||
|
window.gtag = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
delete window.gtag;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fillValidForm() {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /contexto/i }),
|
||||||
|
"Organizar pedidos",
|
||||||
|
);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = () => screen.getByRole("button", { name: /whatsapp/i });
|
||||||
|
|
||||||
|
function navigationAttempts(errors: jest.SpyInstance) {
|
||||||
|
return errors.mock.calls.filter(([error]) =>
|
||||||
|
String(error).includes(NAVIGATION_ERROR),
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Contact: abertura do WhatsApp", () => {
|
||||||
|
it("path: the tab opened -> the opener link is cut and this page stays where it is", async () => {
|
||||||
|
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const opened = { opener: { stillLinked: true } };
|
||||||
|
jest
|
||||||
|
.spyOn(window, "open")
|
||||||
|
.mockImplementation(() => opened as unknown as Window);
|
||||||
|
const user = await fillValidForm();
|
||||||
|
|
||||||
|
await user.click(submit());
|
||||||
|
|
||||||
|
expect(opened.opener).toBeNull();
|
||||||
|
expect(navigationAttempts(errors)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the call asks for a new tab with the wa.me link and no third argument (noopener would hide the result)", async () => {
|
||||||
|
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const user = await fillValidForm();
|
||||||
|
|
||||||
|
await user.click(submit());
|
||||||
|
|
||||||
|
expect(window.open).toHaveBeenCalledTimes(1);
|
||||||
|
const args = jest.mocked(window.open).mock.calls[0];
|
||||||
|
expect(args).toHaveLength(2);
|
||||||
|
expect(String(args[0])).toMatch(/^https:\/\/wa\.me\/\d+\?text=/);
|
||||||
|
expect(args[1]).toBe("_blank");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the browser blocks the tab with null -> the page itself goes to WhatsApp, once", async () => {
|
||||||
|
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
const user = await fillValidForm();
|
||||||
|
|
||||||
|
await user.click(submit());
|
||||||
|
|
||||||
|
expect(navigationAttempts(errors)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the browser answers undefined (some in-app browsers) -> the page itself goes to WhatsApp", async () => {
|
||||||
|
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
jest
|
||||||
|
.spyOn(window, "open")
|
||||||
|
.mockImplementation(() => undefined as unknown as Window);
|
||||||
|
const user = await fillValidForm();
|
||||||
|
|
||||||
|
await user.click(submit());
|
||||||
|
|
||||||
|
expect(navigationAttempts(errors)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the form is invalid -> no tab, no navigation and no metric", async () => {
|
||||||
|
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
await user.click(submit());
|
||||||
|
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
|
expect(navigationAttempts(errors)).toBe(0);
|
||||||
|
expect(window.gtag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,925 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
|
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
|
|
||||||
interface ContactFormProps {
|
|
||||||
formType: "simple" | "advanced";
|
|
||||||
setFormType: (type: "simple" | "advanced") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContactForm({ formType, setFormType }: ContactFormProps) {
|
|
||||||
const { data } = usePortfolioData();
|
|
||||||
const [contactMethod, setContactMethod] = useState<"whatsapp" | "email">(
|
|
||||||
"whatsapp"
|
|
||||||
);
|
|
||||||
const [userName, setUserName] = useState<string>("");
|
|
||||||
const [userEmail, setUserEmail] = useState<string>("");
|
|
||||||
const [userPhone, setUserPhone] = useState<string>("");
|
|
||||||
const [userCompany, setUserCompany] = useState<string>("");
|
|
||||||
const [howFound, setHowFound] = useState<string>("");
|
|
||||||
const [projectType, setProjectType] = useState<string>("");
|
|
||||||
const [otherProjectType, setOtherProjectType] = useState<string>(""); // Novo estado para "Outro" tipo de projeto
|
|
||||||
const [desiredDeadline, setDesiredDeadline] = useState<string>("");
|
|
||||||
const [budgetValue, setBudgetValue] = useState<string>(""); // Novo estado para o valor do orçamento
|
|
||||||
const [technicalComplexity, setTechnicalComplexity] = useState<string>("");
|
|
||||||
const [hasInternalTeam, setHasInternalTeam] = useState<string>("");
|
|
||||||
const [customMessage, setCustomMessage] = useState<string>("");
|
|
||||||
const [isDecisor, setIsDecisor] = useState<string>("");
|
|
||||||
const [projectApproved, setProjectApproved] = useState<string>("");
|
|
||||||
const [urgency, setUrgency] = useState<string>("");
|
|
||||||
const [preferredModality, setPreferredModality] = useState<string>("");
|
|
||||||
const [isExpanded, setIsExpanded] = useState<boolean>(false); // Novo estado para controlar a expansão do formulário avançado
|
|
||||||
const [nameError, setNameError] = useState<string | null>(null);
|
|
||||||
const [emailError, setEmailError] = useState<string | null>(null);
|
|
||||||
const [phoneError, setPhoneError] = useState<string | null>(null);
|
|
||||||
const [otherProjectTypeError, setOtherProjectTypeError] = useState<
|
|
||||||
string | null
|
|
||||||
>(null); // Novo estado de erro
|
|
||||||
const [honeypot, setHoneypot] = useState<string>(""); // Estado para o campo honeypot
|
|
||||||
const [startTime, setStartTime] = useState<number>(0); // Estado para o tempo de início do formulário
|
|
||||||
const [nameFocused, setNameFocused] = useState<boolean>(false);
|
|
||||||
const [emailFocused, setEmailFocused] = useState<boolean>(false);
|
|
||||||
const [phoneFocused, setPhoneFocused] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setStartTime(Date.now()); // Registra o tempo de carregamento do componente
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null; // Ou um componente de carregamento/erro
|
|
||||||
}
|
|
||||||
|
|
||||||
const getGreetingMessageWithUserName = () => {
|
|
||||||
const hour = new Date().getHours();
|
|
||||||
const dayPart =
|
|
||||||
hour >= 8 && hour < 12
|
|
||||||
? "manhã"
|
|
||||||
: hour >= 12 && hour < 18
|
|
||||||
? "tarde"
|
|
||||||
: "noite";
|
|
||||||
|
|
||||||
return `Olá Evandro, espero que esta ${dayPart} esteja sendo produtiva.
|
|
||||||
|
|
||||||
Meu nome é ${userName} e entrei em contato pois vi seu portfólio e identifiquei que suas habilidades como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.
|
|
||||||
|
|
||||||
Gostaria de agendar uma conversa para discutirmos detalhes e entender como podemos trabalhar juntos de forma eficiente e produtiva.
|
|
||||||
|
|
||||||
Fico no aguardo do seu contato para marcarmos um horário que seja conveniente para ambos.
|
|
||||||
|
|
||||||
Atenciosamente,
|
|
||||||
${userName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateAdvancedMessage = () => {
|
|
||||||
if (customMessage) return customMessage;
|
|
||||||
|
|
||||||
// Saudação profissional
|
|
||||||
let message = `Prezado Evandro,\n\n`;
|
|
||||||
|
|
||||||
// Informações do contato
|
|
||||||
message += `Meu nome é ${userName}`;
|
|
||||||
if (userCompany) {
|
|
||||||
message += `, represento a empresa ${userCompany}`;
|
|
||||||
}
|
|
||||||
message += `.\n`;
|
|
||||||
|
|
||||||
if (howFound) {
|
|
||||||
message += `Conheci seu trabalho através de ${howFound}.\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
message += `\n`;
|
|
||||||
|
|
||||||
// Detalhes do projeto
|
|
||||||
message += `=== DETALHES DO PROJETO ===\n`;
|
|
||||||
message += `Tipo de projeto: `;
|
|
||||||
if (projectType === "outro" && otherProjectType) {
|
|
||||||
message += `${otherProjectType}\n`;
|
|
||||||
} else if (projectType) {
|
|
||||||
message += `${projectType}\n`;
|
|
||||||
} else {
|
|
||||||
message += `Não especificado\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desiredDeadline) {
|
|
||||||
message += `Prazo desejado: ${desiredDeadline}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (budgetValue) {
|
|
||||||
message += `Orçamento disponível: ${budgetValue}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (technicalComplexity) {
|
|
||||||
message += `Complexidade técnica estimada: ${technicalComplexity}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasInternalTeam) {
|
|
||||||
message += `Equipe técnica interna: ${
|
|
||||||
hasInternalTeam === "sim"
|
|
||||||
? "Sim"
|
|
||||||
: hasInternalTeam === "nao"
|
|
||||||
? "Não"
|
|
||||||
: "Parcialmente"
|
|
||||||
}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
message += `\n`;
|
|
||||||
|
|
||||||
// Contatos
|
|
||||||
message += `=== INFORMAÇÕES PARA CONTATO ===\n`;
|
|
||||||
if (userEmail) {
|
|
||||||
message += `📧 Email: ${userEmail}\n`;
|
|
||||||
}
|
|
||||||
if (userPhone) {
|
|
||||||
message += `📱 Telefone/WhatsApp: ${userPhone}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
message += `\n`;
|
|
||||||
|
|
||||||
// Mensagem final
|
|
||||||
message += `Estou entrando em contato pois acredito que seus conhecimentos como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.\n\n`;
|
|
||||||
message += `Fico no aguardo do seu contato para discutirmos detalhes e definirmos os próximos passos.\n\n`;
|
|
||||||
message += `Atenciosamente,\n`;
|
|
||||||
message += `${userName}`;
|
|
||||||
|
|
||||||
return message;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUserName(e.target.value);
|
|
||||||
if (e.target.value.trim() !== "") setNameError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUserEmail(e.target.value);
|
|
||||||
if (e.target.value.trim() !== "") setEmailError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePhoneChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUserPhone(e.target.value);
|
|
||||||
if (e.target.value.trim() !== "") setPhoneError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameFocus = () => setNameFocused(true);
|
|
||||||
const handleEmailFocus = () => setEmailFocused(true);
|
|
||||||
const handlePhoneFocus = () => setPhoneFocused(true);
|
|
||||||
|
|
||||||
const handleCompanyChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUserCompany(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHowFoundChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setHowFound(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProjectTypeChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setProjectType(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDesiredDeadlineChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setDesiredDeadline(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBudgetValueChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setBudgetValue(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTechnicalComplexityChange = (
|
|
||||||
e: ChangeEvent<HTMLSelectElement>
|
|
||||||
) => {
|
|
||||||
setTechnicalComplexity(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHasInternalTeamChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setHasInternalTeam(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMessageChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
setCustomMessage(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateWhatsAppUrl = () => {
|
|
||||||
const message = encodeURIComponent(
|
|
||||||
formType === "advanced"
|
|
||||||
? generateAdvancedMessage()
|
|
||||||
: customMessage || getGreetingMessageWithUserName()
|
|
||||||
);
|
|
||||||
const phone = data.contact.whatsapp.replace(/\D/g, ""); // Remove caracteres não numéricos
|
|
||||||
return `https://wa.me/${phone}?text=${message}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateEmailUrl = () => {
|
|
||||||
const subject = encodeURIComponent("Proposta de Projeto - Portfólio");
|
|
||||||
const body = encodeURIComponent(
|
|
||||||
formType === "advanced"
|
|
||||||
? generateAdvancedMessage()
|
|
||||||
: `Nome: ${userName}\nEmail: ${userEmail}\nTelefone: ${userPhone}\nEmpresa/Posição: ${userCompany}\nComo conheceu: ${howFound}\n\nTipo de Projeto: ${projectType}\nPrazo Desejado: ${desiredDeadline}\nOrçamento Disponível: ${budgetValue}\nComplexidade Técnica: ${technicalComplexity}\nTem equipe interna: ${hasInternalTeam}\n\nMensagem: ${
|
|
||||||
customMessage || getGreetingMessageWithUserName()
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
return `mailto:${data.contact.email}?subject=${subject}&body=${body}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
let hasError = false;
|
|
||||||
if (userName.trim() === "") {
|
|
||||||
setNameError("Por favor, insira seu nome.");
|
|
||||||
hasError = true;
|
|
||||||
} else {
|
|
||||||
setNameError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(userEmail)) {
|
|
||||||
setEmailError("Por favor, insira um email válido.");
|
|
||||||
hasError = true;
|
|
||||||
} else {
|
|
||||||
setEmailError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userPhone.trim() === "") {
|
|
||||||
setPhoneError("Por favor, insira seu telefone.");
|
|
||||||
hasError = true;
|
|
||||||
} else {
|
|
||||||
setPhoneError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projectType === "outro" && otherProjectType.trim() === "") {
|
|
||||||
setOtherProjectTypeError("Por favor, especifique o tipo de projeto.");
|
|
||||||
hasError = true;
|
|
||||||
} else {
|
|
||||||
setOtherProjectTypeError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validação Honeypot
|
|
||||||
if (honeypot) {
|
|
||||||
console.log("Honeypot preenchido, provável bot.");
|
|
||||||
return; // Ignora a submissão
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validação de tempo de preenchimento (mínimo de 3 segundos)
|
|
||||||
const timeElapsed = Date.now() - startTime;
|
|
||||||
if (timeElapsed < 3000) {
|
|
||||||
console.log("Formulário preenchido muito rápido, provável bot.");
|
|
||||||
return; // Ignora a submissão
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasError) return;
|
|
||||||
|
|
||||||
// Verificação de interação mínima
|
|
||||||
if (!nameFocused || !emailFocused || !phoneFocused) {
|
|
||||||
console.log("Campos principais não foram focados, provável bot.");
|
|
||||||
// Opcional: exibir uma mensagem de erro para o usuário
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contactMethod === "whatsapp") {
|
|
||||||
window.open(generateWhatsAppUrl(), "_blank");
|
|
||||||
} else {
|
|
||||||
window.open(generateEmailUrl(), "_blank");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCtaMessage = () => {
|
|
||||||
if (projectType === "consultoria") {
|
|
||||||
return "Agendar Consultoria Gratuita";
|
|
||||||
}
|
|
||||||
if (budgetValue && parseFloat(budgetValue.replace(/\D/g, "")) < 2000) {
|
|
||||||
return "Discutir Soluções de Baixo Custo";
|
|
||||||
}
|
|
||||||
if (projectType && desiredDeadline && budgetValue) {
|
|
||||||
return "Receber Proposta Personalizada";
|
|
||||||
}
|
|
||||||
return "Enviar Formulário";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEstimatedValue = () => {
|
|
||||||
let hours = 0;
|
|
||||||
let hourlyRate = 55; // Taxa base R$ 55/h
|
|
||||||
|
|
||||||
// Estimativa de horas baseada no tipo de projeto
|
|
||||||
switch (projectType) {
|
|
||||||
case "web-app":
|
|
||||||
hours += 80; // Aplicação web completa
|
|
||||||
break;
|
|
||||||
case "landing-page":
|
|
||||||
hours += 16; // Landing page simples
|
|
||||||
break;
|
|
||||||
case "mvp":
|
|
||||||
hours += 40; // MVP básico
|
|
||||||
break;
|
|
||||||
case "iot-app":
|
|
||||||
hours += 120; // Aplicação IoT complexa
|
|
||||||
break;
|
|
||||||
case "integracao":
|
|
||||||
hours += 32; // Integração de sistemas
|
|
||||||
break;
|
|
||||||
case "manutencao":
|
|
||||||
hours += 8; // Manutenção pontual
|
|
||||||
break;
|
|
||||||
case "consultoria":
|
|
||||||
hours += 4; // Consultoria inicial
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajuste de horas baseado no prazo
|
|
||||||
switch (desiredDeadline) {
|
|
||||||
case "1-3-weeks":
|
|
||||||
hours *= 1.3; // Urgência aumenta horas
|
|
||||||
break;
|
|
||||||
case "1-2-months":
|
|
||||||
hours *= 1.1;
|
|
||||||
break;
|
|
||||||
case "6-plus-months":
|
|
||||||
hours *= 0.9; // Prazo longo pode reduzir horas
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajuste de horas baseado na complexidade
|
|
||||||
switch (technicalComplexity) {
|
|
||||||
case "alta":
|
|
||||||
hours *= 1.4;
|
|
||||||
hourlyRate = 65; // Taxa maior para alta complexidade
|
|
||||||
break;
|
|
||||||
case "muito-alta":
|
|
||||||
hours *= 1.7;
|
|
||||||
hourlyRate = 70; // Taxa máxima para muito alta complexidade
|
|
||||||
break;
|
|
||||||
case "media":
|
|
||||||
hourlyRate = 50; // Taxa média
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasInternalTeam === "nao") {
|
|
||||||
hours *= 1.2; // Sem equipe interna = mais horas necessárias
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalValue = hours * hourlyRate;
|
|
||||||
|
|
||||||
if (totalValue > 0) {
|
|
||||||
return `Estimativa: ${Math.round(
|
|
||||||
hours
|
|
||||||
)}h × R$ ${hourlyRate}/h = R$ ${totalValue.toFixed(0)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Estimativa: Solicite orçamento personalizado";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 30 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<h3 className="text-2xl font-semibold mb-6 bg-gradient-to-r from-cyan-400 to-blue-500 bg-clip-text text-transparent">
|
|
||||||
Preencha o Formulário
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex justify-center gap-4 mb-8">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormType("simple")}
|
|
||||||
className={`py-2 px-4 rounded-lg font-semibold transition-all duration-300 ${
|
|
||||||
formType === "simple"
|
|
||||||
? "bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
|
|
||||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600 cursor-pointer"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Formulário Simples
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormType("advanced")}
|
|
||||||
className={`py-2 px-4 rounded-lg font-semibold transition-all duration-300 ${
|
|
||||||
formType === "advanced"
|
|
||||||
? "bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
|
|
||||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600 cursor-pointer"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Formulário Avançado
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formType === "simple" ? (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* Campo Honeypot */}
|
|
||||||
<div style={{ display: "none" }}>
|
|
||||||
<label htmlFor="honeypot">Não preencha este campo</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="honeypot"
|
|
||||||
name="honeypot"
|
|
||||||
value={honeypot}
|
|
||||||
onChange={(e) => setHoneypot(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Formulário Simples: Apenas Nome */}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="simple-user-name"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Seu Nome:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="simple-user-name"
|
|
||||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
|
||||||
nameError ? "border-red-500" : "border-white/10"
|
|
||||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
|
||||||
placeholder="Seu nome"
|
|
||||||
value={userName}
|
|
||||||
onChange={handleNameChange}
|
|
||||||
onFocus={handleNameFocus}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{nameError && (
|
|
||||||
<p className="text-red-400 text-xs mt-1">{nameError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Botão de Contato para o formulário simples */}
|
|
||||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full py-4 px-6 rounded-lg font-semibold text-lg transition-all duration-300 bg-green-600 hover:bg-green-700 text-white shadow-lg shadow-green-600/25"
|
|
||||||
onClick={() => window.open(generateWhatsAppUrl(), "_blank")}
|
|
||||||
>
|
|
||||||
Enviar Mensagem via WhatsApp
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* Campo Honeypot */}
|
|
||||||
<div style={{ display: "none" }}>
|
|
||||||
<label htmlFor="honeypot">Não preencha este campo</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="honeypot"
|
|
||||||
name="honeypot"
|
|
||||||
value={honeypot}
|
|
||||||
onChange={(e) => setHoneypot(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dados Básicos */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xl font-semibold mb-4 text-gray-200">
|
|
||||||
Dados Básicos
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="user-name"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Nome Completo:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="user-name"
|
|
||||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
|
||||||
nameError ? "border-red-500" : "border-white/10"
|
|
||||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
|
||||||
placeholder="Seu nome completo"
|
|
||||||
value={userName}
|
|
||||||
onChange={handleNameChange}
|
|
||||||
onFocus={handleNameFocus}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{nameError && (
|
|
||||||
<p className="text-red-400 text-xs mt-1">{nameError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="user-email"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Email Corporativo:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="user-email"
|
|
||||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
|
||||||
emailError ? "border-red-500" : "border-white/10"
|
|
||||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
|
||||||
placeholder="[email protected]"
|
|
||||||
value={userEmail}
|
|
||||||
onChange={handleEmailChange}
|
|
||||||
onFocus={handleEmailFocus}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{emailError && (
|
|
||||||
<p className="text-red-400 text-xs mt-1">{emailError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="user-phone"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
WhatsApp/Telefone:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
id="user-phone"
|
|
||||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
|
||||||
phoneError ? "border-red-500" : "border-white/10"
|
|
||||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
|
||||||
placeholder="(XX) XXXXX-XXXX"
|
|
||||||
value={userPhone}
|
|
||||||
onChange={handlePhoneChange}
|
|
||||||
onFocus={handlePhoneFocus}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{phoneError && (
|
|
||||||
<p className="text-red-400 text-xs mt-1">{phoneError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="user-company"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Empresa/Posição:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="user-company"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
|
||||||
placeholder="Nome da empresa, sua posição"
|
|
||||||
value={userCompany}
|
|
||||||
onChange={handleCompanyChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="how-found"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Como conheceu meu trabalho?
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="how-found"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "16px",
|
|
||||||
}}
|
|
||||||
value={howFound}
|
|
||||||
onChange={handleHowFoundChange}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Selecione uma opção
|
|
||||||
</option>
|
|
||||||
<option value="linkedin" className="bg-gray-800 text-white">
|
|
||||||
LinkedIn
|
|
||||||
</option>
|
|
||||||
<option value="github" className="bg-gray-800 text-white">
|
|
||||||
GitHub
|
|
||||||
</option>
|
|
||||||
<option value="indicacao" className="bg-gray-800 text-white">
|
|
||||||
Indicação
|
|
||||||
</option>
|
|
||||||
<option value="pesquisa" className="bg-gray-800 text-white">
|
|
||||||
Pesquisa Online
|
|
||||||
</option>
|
|
||||||
<option value="outro" className="bg-gray-800 text-white">
|
|
||||||
Outro
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tile de Expandir/Recolher */}
|
|
||||||
<div
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="p-4 rounded-lg bg-white/5 border border-white/10 text-white cursor-pointer hover:bg-white/10 transition-colors duration-300 mb-6 flex justify-between items-center"
|
|
||||||
>
|
|
||||||
<h4 className="text-xl font-semibold text-gray-200">
|
|
||||||
{isExpanded
|
|
||||||
? "Detalhes do Projeto (Recolher)"
|
|
||||||
: "Detalhes do Projeto (Expandir)"}
|
|
||||||
</h4>
|
|
||||||
<svg
|
|
||||||
className={`w-6 h-6 transition-transform duration-300 ${
|
|
||||||
isExpanded ? "rotate-180" : ""
|
|
||||||
}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden transition-all duration-500 ease-in-out ${
|
|
||||||
isExpanded ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
|
|
||||||
} space-y-6`}
|
|
||||||
>
|
|
||||||
{/* Conteúdo do Formulário Avançado */}
|
|
||||||
{/* Sobre o Projeto */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xl font-semibold mb-4 text-gray-200">
|
|
||||||
Sobre o Projeto
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="project-type"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Tipo de Projeto:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="project-type"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "16px",
|
|
||||||
}}
|
|
||||||
value={projectType}
|
|
||||||
onChange={handleProjectTypeChange}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Selecione o tipo
|
|
||||||
</option>
|
|
||||||
<option value="web-app" className="bg-gray-800 text-white">
|
|
||||||
Aplicação Web Completa
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="landing-page"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Landing Page
|
|
||||||
</option>
|
|
||||||
<option value="mvp" className="bg-gray-800 text-white">
|
|
||||||
MVP (Produto Mínimo Viável)
|
|
||||||
</option>
|
|
||||||
<option value="iot-app" className="bg-gray-800 text-white">
|
|
||||||
Aplicação IoT
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="integracao"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Integração de Sistemas
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="manutencao"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Manutenção/Ajustes
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="consultoria"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Consultoria/Mentoria
|
|
||||||
</option>
|
|
||||||
<option value="outro" className="bg-gray-800 text-white">
|
|
||||||
Outro
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{projectType === "outro" && (
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="other-project-type"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Qual outro tipo de projeto?
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="other-project-type"
|
|
||||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
|
||||||
otherProjectTypeError
|
|
||||||
? "border-red-500"
|
|
||||||
: "border-white/10"
|
|
||||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
|
||||||
placeholder="Ex: Desenvolvimento de Plugin, Automação de Processos"
|
|
||||||
value={otherProjectType}
|
|
||||||
onChange={(e) => {
|
|
||||||
setOtherProjectType(e.target.value);
|
|
||||||
if (e.target.value.trim() !== "")
|
|
||||||
setOtherProjectTypeError(null);
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{otherProjectTypeError && (
|
|
||||||
<p className="text-red-400 text-xs mt-1">
|
|
||||||
{otherProjectTypeError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="desired-deadline"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Prazo Desejado:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="desired-deadline"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "16px",
|
|
||||||
}}
|
|
||||||
value={desiredDeadline}
|
|
||||||
onChange={handleDesiredDeadlineChange}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Selecione o prazo
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="1-3-weeks"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
1-3 Semanas
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="1-2-months"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
1-2 Meses
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="2-4-months"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
2-4 Meses
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="4-6-months"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
4-6 Meses
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="6-plus-months"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Mais de 6 Meses
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="budget-value"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Orçamento Disponível (R$):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="budget-value"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
|
||||||
placeholder="Ex: 5000, 10000, A Combinar"
|
|
||||||
value={budgetValue}
|
|
||||||
onChange={handleBudgetValueChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="technical-complexity"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Complexidade Técnica Estimada:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="technical-complexity"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "16px",
|
|
||||||
}}
|
|
||||||
value={technicalComplexity}
|
|
||||||
onChange={handleTechnicalComplexityChange}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Selecione a complexidade
|
|
||||||
</option>
|
|
||||||
<option value="baixa" className="bg-gray-800 text-white">
|
|
||||||
Baixa
|
|
||||||
</option>
|
|
||||||
<option value="media" className="bg-gray-800 text-white">
|
|
||||||
Média
|
|
||||||
</option>
|
|
||||||
<option value="alta" className="bg-gray-800 text-white">
|
|
||||||
Alta
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="muito-alta"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Muito Alta
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="has-internal-team"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Já tem equipe técnica interna?
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="has-internal-team"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "16px",
|
|
||||||
}}
|
|
||||||
value={hasInternalTeam}
|
|
||||||
onChange={handleHasInternalTeamChange}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Selecione
|
|
||||||
</option>
|
|
||||||
<option value="sim" className="bg-gray-800 text-white">
|
|
||||||
Sim
|
|
||||||
</option>
|
|
||||||
<option value="nao" className="bg-gray-800 text-white">
|
|
||||||
Não
|
|
||||||
</option>
|
|
||||||
<option value="parcial" className="bg-gray-800 text-white">
|
|
||||||
Parcialmente
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom Message Input */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<label
|
|
||||||
htmlFor="custom-message"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Mensagem Adicional (Opcional):
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="custom-message"
|
|
||||||
rows={4}
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
|
||||||
placeholder="Descreva seu projeto ou suas necessidades aqui..."
|
|
||||||
value={customMessage}
|
|
||||||
onChange={handleMessageChange}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Button */}
|
|
||||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full py-4 px-6 rounded-lg font-semibold text-lg transition-all duration-300 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
|
|
||||||
>
|
|
||||||
{getCtaMessage()}
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
{projectType && desiredDeadline && budgetValue && (
|
|
||||||
<p className="text-center text-lg font-semibold text-cyan-400 mt-4">
|
|
||||||
{getEstimatedValue()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { Mail, MessageCircle } from "lucide-react";
|
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
|
||||||
|
|
||||||
interface ContactInfoProps {
|
|
||||||
onWhatsAppClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
|
|
||||||
const { data } = usePortfolioData();
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null; // Ou um componente de carregamento/erro
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -30 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold mb-6 text-blue-400">
|
|
||||||
Entre em Contato
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<a
|
|
||||||
href={`mailto:${data.contact.email}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<div className="p-3 rounded-full bg-blue-500/20">
|
|
||||||
<Mail className="w-6 h-6 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">Email</p>
|
|
||||||
<p className="text-gray-400">{data.contact.email}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onWhatsAppClick}
|
|
||||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
|
|
||||||
>
|
|
||||||
<div className="p-3 rounded-full bg-green-500/20">
|
|
||||||
<MessageCircle className="w-6 h-6 text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">WhatsApp</p>
|
|
||||||
<p className="text-gray-400">{data.contact.whatsapp}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={data.contact.linkedin}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<div className="p-3 rounded-full bg-blue-500/20">
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6 text-blue-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">LinkedIn</p>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
{data.contact.linkedin.replace("https://www.", "")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={data.contact.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<div className="p-3 rounded-full bg-gray-500/20">
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6 text-gray-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">GitHub</p>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
{data.contact.github.replace("https://github.com/", "")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold mb-4 text-blue-400">
|
|
||||||
Localização
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300">📍 {data.contact.location}</p>
|
|
||||||
<p className="text-gray-400 mt-2">
|
|
||||||
Disponível para projetos remotos e trabalho presencial na região.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline de Resposta (Placeholder) */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold mb-4 text-blue-400">
|
|
||||||
Timeline de Resposta
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300">
|
|
||||||
Em breve, uma timeline detalhada do processo de contato e resposta.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import ContactOfferLink from "./ContactOfferLink";
|
||||||
|
import { focusFragment, onFragmentClick } from "./cinematic/fragment-nav";
|
||||||
|
import { CONTACT_KIND_EVENT } from "./contact-subject";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de ContactOfferLink (complemento de ContactOfferLink.test.tsx).
|
||||||
|
*
|
||||||
|
* onClick (CC 6): fora da home | sem #contact-form | clique já tratado pelo gerenciador de âncoras | tecla modificadora ou
|
||||||
|
* botão que não é o principal | rolagem suave | rolagem em salto (movimento reduzido). Os seis caminhos já estão
|
||||||
|
* em ContactOfferLink.test.tsx; aqui entram o que faltava:
|
||||||
|
* - o gerenciador de âncoras REAL (fragment-nav.onFragmentClick) tratando o clique, em vez de um ouvinte avulso;
|
||||||
|
* - Alt como tecla modificadora;
|
||||||
|
* - showContactInAddress (CC 2): o endereço já é /#contact-form, então não há replaceState; e o `history.state` do roteador
|
||||||
|
* do Next sobrevive ao replaceState.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const scrollIntoView = jest.fn();
|
||||||
|
const scrollTo = jest.fn();
|
||||||
|
let form: HTMLElement;
|
||||||
|
let replaceState: jest.SpyInstance;
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
const originalScrollTo = window.scrollTo;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scrollIntoView.mockReset();
|
||||||
|
scrollTo.mockReset();
|
||||||
|
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||||
|
window.scrollTo = scrollTo as unknown as typeof window.scrollTo;
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
}));
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
replaceState = jest.spyOn(window.history, "replaceState");
|
||||||
|
form = document.createElement("section");
|
||||||
|
form.id = "contact-form";
|
||||||
|
document.body.append(form);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
replaceState.mockRestore();
|
||||||
|
form.remove();
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
window.scrollTo = originalScrollTo;
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
/** O que useCinematicScroll faz com um clique numa âncora: cancela, empurra o hash, rola e dá o foco. */
|
||||||
|
function installAnchorManager() {
|
||||||
|
return onFragmentClick((hit, event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (window.location.hash !== hit.hash)
|
||||||
|
window.history.pushState(null, "", hit.hash);
|
||||||
|
window.scrollTo({ top: 1234, behavior: "smooth" });
|
||||||
|
focusFragment(hit.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ContactOfferLink: gerenciador de âncoras real", () => {
|
||||||
|
it("path: the manager takes the click -> its scroll is the only one, the offer cleans the old ?tipo= and still tells the form", () => {
|
||||||
|
window.history.replaceState(null, "", "/?tipo=site");
|
||||||
|
replaceState.mockClear();
|
||||||
|
const heard: unknown[] = [];
|
||||||
|
const listener = (event: Event) =>
|
||||||
|
heard.push((event as CustomEvent).detail);
|
||||||
|
window.addEventListener(CONTACT_KIND_EVENT, listener);
|
||||||
|
const stop = installAnchorManager();
|
||||||
|
try {
|
||||||
|
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
|
||||||
|
const entries = window.history.length;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Começar" })),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(form).toHaveFocus();
|
||||||
|
expect(heard).toEqual(["sistema"]);
|
||||||
|
// o gerenciador abriu uma entrada de histórico (/?tipo=site#contact-form); a oferta só a reescreve
|
||||||
|
expect(window.history.length).toBe(entries + 1);
|
||||||
|
expect(window.location.search).toBe("");
|
||||||
|
expect(window.location.hash).toBe("#contact-form");
|
||||||
|
expect(replaceState).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
window.removeEventListener(CONTACT_KIND_EVENT, listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the manager takes the click and the address is already /#contact-form -> no replaceState at all", () => {
|
||||||
|
window.history.replaceState(null, "", "/#contact-form");
|
||||||
|
replaceState.mockClear();
|
||||||
|
const stop = installAnchorManager();
|
||||||
|
try {
|
||||||
|
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
|
||||||
|
|
||||||
|
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replaceState).not.toHaveBeenCalled();
|
||||||
|
expect(window.location.pathname + window.location.hash).toBe(
|
||||||
|
"/#contact-form",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no manager mounted -> the offer scrolls and focuses by itself, and a second click scrolls again", () => {
|
||||||
|
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
|
||||||
|
const link = screen.getByRole("link", { name: "Começar" });
|
||||||
|
|
||||||
|
expect(fireEvent.click(link)).toBe(false);
|
||||||
|
expect(scrollIntoView).toHaveBeenCalledTimes(1);
|
||||||
|
expect(form).toHaveFocus();
|
||||||
|
expect(window.location.hash).toBe("#contact-form");
|
||||||
|
|
||||||
|
// o endereço já é /#contact-form (o navegador não rolaria): a oferta rola de novo mesmo assim, sem tocar no endereço
|
||||||
|
(document.activeElement as HTMLElement).blur();
|
||||||
|
replaceState.mockClear();
|
||||||
|
expect(fireEvent.click(link)).toBe(false);
|
||||||
|
expect(scrollIntoView).toHaveBeenCalledTimes(2);
|
||||||
|
expect(form).toHaveFocus();
|
||||||
|
expect(replaceState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ContactOfferLink: teclas e histórico", () => {
|
||||||
|
it("path: Alt held down -> not taken over (the browser keeps its own behavior)", () => {
|
||||||
|
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Começar" }), {
|
||||||
|
altKey: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(replaceState).not.toHaveBeenCalled();
|
||||||
|
expect(form).not.toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a modified click still tells the form which type was chosen (the form may be in the same tab)", () => {
|
||||||
|
const heard: unknown[] = [];
|
||||||
|
const listener = (event: Event) =>
|
||||||
|
heard.push((event as CustomEvent).detail);
|
||||||
|
window.addEventListener(CONTACT_KIND_EVENT, listener);
|
||||||
|
try {
|
||||||
|
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Começar" }), {
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(heard).toEqual(["revisao"]);
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener(CONTACT_KIND_EVENT, listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: replaceState keeps the history.state of the router (the Next router stores its tree there)", () => {
|
||||||
|
const routerState = { __NA: true, tree: ["", {}] };
|
||||||
|
window.history.replaceState(routerState, "", "/?tipo=site#contact-form");
|
||||||
|
replaceState.mockClear();
|
||||||
|
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
|
||||||
|
|
||||||
|
expect(replaceState).toHaveBeenCalledWith(
|
||||||
|
routerState,
|
||||||
|
"",
|
||||||
|
"/#contact-form",
|
||||||
|
);
|
||||||
|
expect(window.history.state).toEqual(routerState);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import ContactOfferLink from "./ContactOfferLink";
|
||||||
|
import { CONTACT_KIND_EVENT } from "./contact-subject";
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ContactOfferLink", () => {
|
||||||
|
it("links to the form with the type in the address, and prefetches nothing until hover or focus", () => {
|
||||||
|
render(
|
||||||
|
<ContactOfferLink kind="sistema" className="botao">
|
||||||
|
Começar pelo sistema
|
||||||
|
</ContactOfferLink>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "Começar pelo sistema" });
|
||||||
|
expect(link).toHaveAttribute("href", "/?tipo=sistema#contact-form");
|
||||||
|
expect(link).toHaveClass("botao");
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "false");
|
||||||
|
fireEvent.mouseEnter(link);
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells the form which type was chosen, through a window event, when it is clicked", () => {
|
||||||
|
const heard: unknown[] = [];
|
||||||
|
const listener = (event: Event) =>
|
||||||
|
heard.push((event as CustomEvent).detail);
|
||||||
|
window.addEventListener(CONTACT_KIND_EVENT, listener);
|
||||||
|
try {
|
||||||
|
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
|
||||||
|
expect(heard).toEqual(["revisao"]);
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener(CONTACT_KIND_EVENT, listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still tells the form when another handler already cancelled the click (the home scroll manager does that to scroll by itself)", () => {
|
||||||
|
const heard: unknown[] = [];
|
||||||
|
const listener = (event: Event) =>
|
||||||
|
heard.push((event as CustomEvent).detail);
|
||||||
|
const cancel = (event: Event) => event.preventDefault();
|
||||||
|
window.addEventListener(CONTACT_KIND_EVENT, listener);
|
||||||
|
document.addEventListener("click", cancel, true);
|
||||||
|
try {
|
||||||
|
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
|
||||||
|
expect(heard).toEqual(["site"]);
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener(CONTACT_KIND_EVENT, listener);
|
||||||
|
document.removeEventListener("click", cancel, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ContactOfferLink: rolagem até o formulário", () => {
|
||||||
|
const scrollIntoView = jest.fn();
|
||||||
|
const original = window.matchMedia;
|
||||||
|
let form: HTMLElement;
|
||||||
|
|
||||||
|
const setReducedMotion = (reduced: boolean) => {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: reduced && query === "(prefers-reduced-motion: reduce)",
|
||||||
|
media: query,
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scrollIntoView.mockReset();
|
||||||
|
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
setReducedMotion(false);
|
||||||
|
form = document.createElement("section");
|
||||||
|
form.id = "contact-form";
|
||||||
|
document.body.append(form);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
form.remove();
|
||||||
|
window.matchMedia = original;
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels the navigation on the home, rolls to #contact-form, focuses the form and leaves the address as /#contact-form", () => {
|
||||||
|
const heard: unknown[] = [];
|
||||||
|
const listener = (event: Event) =>
|
||||||
|
heard.push((event as CustomEvent).detail);
|
||||||
|
window.addEventListener(CONTACT_KIND_EVENT, listener);
|
||||||
|
const pushState = jest.spyOn(window.history, "pushState");
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
<ContactOfferLink kind="site">Começar pelo site</ContactOfferLink>,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole("link", { name: "Começar pelo site" });
|
||||||
|
|
||||||
|
// fireEvent devolve false quando alguém chamou preventDefault: o Link do Next não navega
|
||||||
|
expect(fireEvent.click(link)).toBe(false);
|
||||||
|
expect(heard).toEqual(["site"]);
|
||||||
|
expect(scrollIntoView).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
expect(form).toHaveFocus();
|
||||||
|
expect(window.location.pathname + window.location.search).toBe("/");
|
||||||
|
expect(window.location.hash).toBe("#contact-form");
|
||||||
|
expect(pushState).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
pushState.mockRestore();
|
||||||
|
window.removeEventListener(CONTACT_KIND_EVENT, listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("jumps without animation when the visitor asks for reduced motion", () => {
|
||||||
|
setReducedMotion(true);
|
||||||
|
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
|
||||||
|
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||||
|
behavior: "instant",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the ?tipo= left by an earlier offer or by the arrival from another page, and keeps the other parameters", () => {
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
"/?utm_source=ads&tipo=site#contact-form",
|
||||||
|
);
|
||||||
|
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
|
||||||
|
expect(window.location.search).toBe("?utm_source=ads");
|
||||||
|
expect(window.location.hash).toBe("#contact-form");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the click alone when the scroll manager already took it (no second scroll), still cleaning the address", () => {
|
||||||
|
window.history.replaceState(null, "", "/?tipo=site#contact-form");
|
||||||
|
const cancel = (event: Event) => event.preventDefault();
|
||||||
|
document.addEventListener("click", cancel, true);
|
||||||
|
try {
|
||||||
|
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
|
||||||
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(window.location.search).toBe("");
|
||||||
|
expect(window.location.hash).toBe("#contact-form");
|
||||||
|
} finally {
|
||||||
|
document.removeEventListener("click", cancel, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not take over a click with a modifier key (new tab) or the middle button", () => {
|
||||||
|
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
|
||||||
|
const link = screen.getByRole("link", { name: "Começar" });
|
||||||
|
|
||||||
|
expect(fireEvent.click(link, { ctrlKey: true })).toBe(true);
|
||||||
|
expect(fireEvent.click(link, { metaKey: true })).toBe(true);
|
||||||
|
expect(fireEvent.click(link, { shiftKey: true })).toBe(true);
|
||||||
|
expect(fireEvent.click(link, { button: 1 })).toBe(true);
|
||||||
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(window.location.hash).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cancel anything outside the home: the Link navigates to /?tipo=…#contact-form", () => {
|
||||||
|
window.history.replaceState(null, "", "/privacidade");
|
||||||
|
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
|
||||||
|
const link = screen.getByRole("link", { name: "Começar" });
|
||||||
|
|
||||||
|
expect(link).toHaveAttribute("href", "/?tipo=revisao#contact-form");
|
||||||
|
expect(fireEvent.click(link)).toBe(true);
|
||||||
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(window.location.pathname).toBe("/privacidade");
|
||||||
|
expect(window.location.hash).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the Link navigation when the page has no #contact-form", () => {
|
||||||
|
form.remove();
|
||||||
|
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
|
||||||
|
|
||||||
|
expect(fireEvent.click(screen.getByRole("link", { name: "Começar" }))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
document.body.append(form);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { MouseEvent, ReactNode } from "react";
|
||||||
|
import { prefersReducedMotion } from "@/lib/device";
|
||||||
|
import {
|
||||||
|
elementForHash,
|
||||||
|
focusFragment,
|
||||||
|
isPlainPrimaryClick,
|
||||||
|
} from "./cinematic/fragment-nav";
|
||||||
|
import {
|
||||||
|
CONTACT_FORM_HASH,
|
||||||
|
CONTACT_KIND_EVENT,
|
||||||
|
CONTACT_KIND_PARAM,
|
||||||
|
type ContactKind,
|
||||||
|
contactKindHref,
|
||||||
|
} from "./contact-subject";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deixa o endereço da home com o fragmento do cartão do formulário (`/#contact-form`) e sem o `?tipo=` que uma oferta
|
||||||
|
* anterior ou a chegada de outra página deixou (o tipo já está no formulário). `replaceState`, e não uma navegação do
|
||||||
|
* Next: nada recarrega, e as âncoras seguintes (cabeçalho, rodapé) continuam a rolar a página.
|
||||||
|
*/
|
||||||
|
function showContactInAddress() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete(CONTACT_KIND_PARAM);
|
||||||
|
url.hash = CONTACT_FORM_HASH;
|
||||||
|
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
if (next !== current)
|
||||||
|
window.history.replaceState(window.history.state, "", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Botão de uma oferta da home: leva ao cartão do formulário de contato (`#contact-form`, com o campo do tipo de projeto
|
||||||
|
* à vista, também no celular) com o tipo já escolhido. Na própria home o tipo segue por evento e a página rola até o
|
||||||
|
* cartão (sem navegar pelo Next); o `href` com `?tipo=` cobre quem chega de outra página ou abre o link em outra aba.
|
||||||
|
*
|
||||||
|
* O normal é o gerenciador de âncoras (fragment-nav + useCinematicScroll) tratar o clique antes deste código, do
|
||||||
|
* mesmo jeito que trata o link do cabeçalho: rola (com movimento reduzido, em salto), dá o foco ao formulário e
|
||||||
|
* cancela o clique. Quando ele não o trata (ainda não montou, por exemplo), este botão cancela a navegação, rola e dá
|
||||||
|
* o foco por conta própria. Em outra página, nada é cancelado: o Link navega para `/?tipo=…#contact-form`.
|
||||||
|
*/
|
||||||
|
export default function ContactOfferLink({
|
||||||
|
kind,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
kind: ContactKind;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const onClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent<ContactKind>(CONTACT_KIND_EVENT, { detail: kind }),
|
||||||
|
);
|
||||||
|
if (window.location.pathname !== "/") return;
|
||||||
|
const form = elementForHash(CONTACT_FORM_HASH);
|
||||||
|
if (!form) return;
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
showContactInAddress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// aba nova, clique com tecla modificadora ou botão do meio: o navegador cuida, com o `?tipo=` do href
|
||||||
|
if (!isPlainPrimaryClick(event.nativeEvent)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
showContactInAddress();
|
||||||
|
form.scrollIntoView({
|
||||||
|
behavior: prefersReducedMotion() ? "instant" : "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
focusFragment(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href={contactKindHref(kind)}
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import {
|
|
||||||
ChevronRight,
|
|
||||||
Terminal,
|
|
||||||
Cpu,
|
|
||||||
Network,
|
|
||||||
Code2
|
|
||||||
} from "lucide-react";
|
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
|
||||||
|
|
||||||
// --- Tipagem e Configuração (Otimização Pareto 80/20) ---
|
|
||||||
const CONFIG = {
|
|
||||||
"innovo-tecnologia": { color: "text-blue-400", bg: "bg-blue-400/10", border: "border-blue-400/20", icon: Cpu },
|
|
||||||
"apolo-energia": { color: "text-emerald-400", bg: "bg-emerald-400/10", border: "border-emerald-400/20", icon: Network },
|
|
||||||
default: { color: "text-slate-400", bg: "bg-slate-400/10", border: "border-slate-400/20", icon: Code2 }
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Experience() {
|
|
||||||
const { data, loading, error } = usePortfolioData();
|
|
||||||
|
|
||||||
// Memória: Evita re-processamento de regex em cada re-render (Princípio de Eficiência)
|
|
||||||
const sortedExperiences = useMemo(() => {
|
|
||||||
if (!data?.experiences) return [];
|
|
||||||
return [...data.experiences]
|
|
||||||
.filter((exp) => exp.featured)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const yearA = parseInt(a.period.match(/\d{4}/)?.[0] || "0");
|
|
||||||
const yearB = parseInt(b.period.match(/\d{4}/)?.[0] || "0");
|
|
||||||
return yearB - yearA; // Da mais recente para a mais antiga (Padrão Senior)
|
|
||||||
});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (loading) return <ExperienceSkeleton />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="experience" className="relative py-32">
|
|
||||||
{/* Background Gradients */}
|
|
||||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
|
|
||||||
<div className="absolute top-[20%] left-[-10%] w-[500px] h-[500px] bg-blue-900/10 rounded-full blur-[100px]" />
|
|
||||||
<div className="absolute bottom-[20%] right-[-10%] w-[500px] h-[500px] bg-indigo-900/10 rounded-full blur-[100px]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6 relative z-10">
|
|
||||||
|
|
||||||
{/* Header com Identidade Visual */}
|
|
||||||
<div className="mb-24 text-center md:text-left">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-950/30 border border-blue-500/20 text-blue-400 font-mono text-xs tracking-wider mb-6"
|
|
||||||
>
|
|
||||||
<Terminal className="w-3 h-3" />
|
|
||||||
<span>SYSTEM_LOGS.history</span>
|
|
||||||
</motion.div>
|
|
||||||
<motion.h2
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="text-4xl md:text-6xl font-black text-white tracking-tighter"
|
|
||||||
>
|
|
||||||
Trajetória <span className="bg-gradient-to-r from-blue-500 to-indigo-500 bg-clip-text text-transparent">Profissional</span>
|
|
||||||
</motion.h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{/* Timeline Desktop (Linha Central com Gradiente) */}
|
|
||||||
<div className="absolute left-[50%] top-0 bottom-0 w-[2px] bg-gradient-to-b from-blue-500/50 via-indigo-500/20 to-transparent hidden lg:block translate-x-[-50%]" />
|
|
||||||
|
|
||||||
<div className="space-y-24">
|
|
||||||
{sortedExperiences.map((exp, index) => (
|
|
||||||
<ExperienceCard key={exp.id} exp={exp} index={index} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sub-componente Card (Encapsulamento de Lógica de Transição) ---
|
|
||||||
function ExperienceCard({ exp, index }: { exp: any, index: number }) {
|
|
||||||
const isEven = index % 2 === 0;
|
|
||||||
const config = (CONFIG as any)[exp.company.toLowerCase().replace(/\s+/g, "-")] || CONFIG.default;
|
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative flex flex-col ${isEven ? 'lg:flex-row' : 'lg:flex-row-reverse'} items-center gap-8 lg:gap-16`}>
|
|
||||||
|
|
||||||
{/* Node Central (O "Ponto" da Timeline) */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
|
||||||
whileInView={{ scale: 1, opacity: 1 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.2, duration: 0.5 }}
|
|
||||||
className="hidden lg:flex absolute left-[50%] top-0 translate-x-[-50%] w-4 h-4 rounded-full bg-blue-500 shadow-[0_0_20px_rgba(59,130,246,0.5)] z-20 items-center justify-center"
|
|
||||||
>
|
|
||||||
<div className="w-2 h-2 rounded-full bg-white animate-pulse" />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Indicador de Data na Timeline */}
|
|
||||||
<div className="hidden lg:flex flex-1 justify-end items-start pt-2">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: isEven ? -20 : 20 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.3 }}
|
|
||||||
className={`${isEven ? 'text-right' : 'text-left'} flex-1`}
|
|
||||||
>
|
|
||||||
<span className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-b from-slate-700 to-slate-800 tracking-tighter opacity-50 select-none">
|
|
||||||
{exp.period.split(' ')[0]}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card Principal */}
|
|
||||||
<div className="flex-1 w-full relative group perspective-1000">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: isEven ? 50 : -50 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.1 * index }}
|
|
||||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
|
||||||
className={`p-8 rounded-3xl bg-zinc-900/40 border border-white/5 backdrop-blur-md relative overflow-hidden transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.1)] hover:border-blue-500/20`}
|
|
||||||
>
|
|
||||||
{/* Background Glow Effect on Hover */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
|
|
||||||
{/* Badge de Empresa & Header */}
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="flex flex-wrap justify-between items-start gap-4 mb-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg ${config.bg} ${config.color}`}>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<span className={`text-xs font-bold uppercase tracking-widest ${config.color}`}>
|
|
||||||
{exp.company}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl font-bold text-white group-hover:text-blue-400 transition-colors">
|
|
||||||
{exp.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-3 py-1 rounded-full bg-white/5 border border-white/10 text-[10px] font-mono font-bold text-slate-400 uppercase tracking-wide">
|
|
||||||
{exp.period}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-slate-400 leading-relaxed mb-8 text-sm">
|
|
||||||
{exp.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Conquistas (Checklist Técnico) */}
|
|
||||||
<div className="grid grid-cols-1 gap-3 mb-8">
|
|
||||||
{exp.achievements?.map((ach: string, i: number) => (
|
|
||||||
<div key={i} className="flex gap-3 text-sm text-slate-300 items-start">
|
|
||||||
<div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
|
|
||||||
<span>{ach}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags de Tech */}
|
|
||||||
<div className="flex flex-wrap gap-2 pt-6 border-t border-white/5">
|
|
||||||
{exp.technologies?.map((tech: string) => (
|
|
||||||
<span key={tech} className="px-3 py-1 bg-black/20 border border-white/5 text-slate-500 text-[10px] rounded-full font-mono hover:text-blue-400 hover:border-blue-400/30 transition-all cursor-default">
|
|
||||||
{tech}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spacer para Timeline Desktop */}
|
|
||||||
<div className="hidden lg:block flex-1" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExperienceSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="h-96 flex flex-col items-center justify-center gap-4 text-slate-600 font-mono animate-pulse">
|
|
||||||
<Cpu className="w-8 h-8 animate-spin" />
|
|
||||||
<span>BOOTING_SYSTEM_HISTORY...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { Faq } from "./Faq";
|
||||||
|
import HomeFaq from "./HomeFaq";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ question: "Primeira pergunta?", answer: "Primeira resposta." },
|
||||||
|
{
|
||||||
|
question: "Segunda pergunta?",
|
||||||
|
answer: "Segunda resposta.",
|
||||||
|
link: { href: "/ia", label: "Ver a revisão" },
|
||||||
|
},
|
||||||
|
{ question: "Sem resposta ainda?", answer: " " },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("Faq", () => {
|
||||||
|
it("path: answered questions -> one closed accordion item per question, under a level 2 heading", () => {
|
||||||
|
const { container } = render(<Faq items={items} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "Perguntas frequentes" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const summaries = Array.from(container.querySelectorAll("summary")).map(
|
||||||
|
(summary) => summary.textContent?.replace("+", "").trim(),
|
||||||
|
);
|
||||||
|
expect(summaries).toEqual(["Primeira pergunta?", "Segunda pergunta?"]);
|
||||||
|
for (const details of container.querySelectorAll("details")) {
|
||||||
|
expect(details.open).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a question with no answer -> stays out of the page, like on the home", () => {
|
||||||
|
render(<Faq items={items} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Sem resposta ainda?")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a question with a link -> the link sits inside its answer", () => {
|
||||||
|
render(<Faq items={items} />);
|
||||||
|
|
||||||
|
const details = screen
|
||||||
|
.getByText("Segunda pergunta?")
|
||||||
|
.closest("details") as HTMLElement;
|
||||||
|
expect(within(details).getByText("Segunda resposta.")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(details).getByRole("link", { name: "Ver a revisão" }),
|
||||||
|
).toHaveAttribute("href", "/ia");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: custom title and anchor -> the heading and the section follow them", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Faq items={items} id="antes" title="Antes de contratar" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const heading = screen.getByRole("heading", { name: "Antes de contratar" });
|
||||||
|
expect(heading).toHaveAttribute("id", "antes-title");
|
||||||
|
expect(container.querySelector("section")).toHaveAttribute("id", "antes");
|
||||||
|
expect(container.querySelector("section")).toHaveAttribute(
|
||||||
|
"aria-labelledby",
|
||||||
|
"antes-title",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no items, or none with an answer -> renders nothing, not even the heading", () => {
|
||||||
|
const empty = render(<Faq />);
|
||||||
|
expect(empty.container).toBeEmptyDOMElement();
|
||||||
|
empty.unmount();
|
||||||
|
|
||||||
|
const unanswered = render(
|
||||||
|
<Faq items={[{ question: "Pergunta?", answer: "" }]} />,
|
||||||
|
);
|
||||||
|
expect(unanswered.container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: same pattern as the home FAQ -> same summary and details classes", () => {
|
||||||
|
const shared = render(<Faq items={items} />);
|
||||||
|
const home = render(
|
||||||
|
<HomeFaq
|
||||||
|
items={[{ question: "Primeira pergunta?", answer: "Resposta." }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const classOf = (container: HTMLElement, selector: string) =>
|
||||||
|
container.querySelector(selector)?.className;
|
||||||
|
for (const selector of ["details", "summary", "summary > span"]) {
|
||||||
|
expect(classOf(shared.container, selector)).toBe(
|
||||||
|
classOf(home.container, selector),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { textLink } from "./button-styles";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
|
export interface FaqEntry {
|
||||||
|
question: string;
|
||||||
|
/** Resposta vazia = pergunta preparada, que ainda não é exibida. */
|
||||||
|
answer: string;
|
||||||
|
link?: { href: string; label: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perguntas frequentes em acordeão, um formato só no site: pergunta na linha, resposta ao abrir, "+" que gira.
|
||||||
|
* A home (HomeFaq), as páginas de serviço e a de serviços usam este componente. Só aparecem as perguntas com resposta; sem nenhuma, a seção não existe.
|
||||||
|
* Nunca escrever aqui prazo, preço, garantia ou política de pagamento que o dono não tenha dito.
|
||||||
|
*/
|
||||||
|
export function Faq({
|
||||||
|
items,
|
||||||
|
id = "faq",
|
||||||
|
title = "Perguntas frequentes",
|
||||||
|
eyebrow = "Dúvidas comuns",
|
||||||
|
}: {
|
||||||
|
items?: readonly FaqEntry[];
|
||||||
|
/** Âncora da seção; o título leva `${id}-title`. */
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
}) {
|
||||||
|
const answered = (items ?? []).filter((item) => item.answer.trim() !== "");
|
||||||
|
if (answered.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} aria-labelledby={`${id}-title`} className="ds-section">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<p className="ds-eyebrow">{eyebrow}</p>
|
||||||
|
<h2
|
||||||
|
id={`${id}-title`}
|
||||||
|
className="ds-section-title max-w-[22ch] text-balance"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-sp-8 max-w-[860px]">
|
||||||
|
{answered.map((item) => (
|
||||||
|
<details
|
||||||
|
key={item.question}
|
||||||
|
className="group border-t border-[var(--border)] last:border-b"
|
||||||
|
>
|
||||||
|
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
|
||||||
|
{item.question}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="shrink-0 font-mono text-xl leading-none text-[var(--meta)] transition-transform group-open:rotate-45 motion-reduce:transition-none"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="pb-sp-5">
|
||||||
|
<p className="ds-copy max-w-[65ch]">{item.answer}</p>
|
||||||
|
{item.link ? (
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href={item.link.href}
|
||||||
|
className={`${textLink} mt-sp-2 text-[var(--ink-80)]`}
|
||||||
|
>
|
||||||
|
{item.link.label}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { resetNavigation, setNavigation } from "@/__mocks__/next/navigation";
|
||||||
|
import { HOME_SUBJECT } from "@/lib/whatsapp";
|
||||||
|
import FloatingWhatsApp from "./FloatingWhatsApp";
|
||||||
|
import GoogleAnalytics from "./GoogleAnalytics";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de FloatingWhatsApp (complemento de FloatingWhatsApp.test.tsx):
|
||||||
|
* cada convite (botão do hero, seção de contato) sozinho na página, relatórios parciais do observador,
|
||||||
|
* desconexão ao desmontar e a convivência com a barra de cookies.
|
||||||
|
* A regra "só abaixo de 768 px" é CSS (md:hidden); o jsdom não avalia media queries, então só a classe é conferida.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/script", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
strategy: _strategy,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CONSENT_KEY = "evandro.analytics-consent.v1";
|
||||||
|
|
||||||
|
type Entry = Pick<IntersectionObserverEntry, "target" | "isIntersecting">;
|
||||||
|
|
||||||
|
const instances: FakeObserver[] = [];
|
||||||
|
const originalObserver = global.IntersectionObserver;
|
||||||
|
|
||||||
|
class FakeObserver {
|
||||||
|
observed: Element[] = [];
|
||||||
|
disconnect = jest.fn();
|
||||||
|
constructor(private callback: IntersectionObserverCallback) {
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
observe = jest.fn((target: Element) => {
|
||||||
|
this.observed.push(target);
|
||||||
|
});
|
||||||
|
unobserve = jest.fn();
|
||||||
|
takeRecords = () => [];
|
||||||
|
report(entries: Entry[]) {
|
||||||
|
act(() =>
|
||||||
|
this.callback(entries as IntersectionObserverEntry[], this as never),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hero = () => document.getElementById("hero-contact-cta") as Element;
|
||||||
|
const contact = () => document.getElementById("contact") as Element;
|
||||||
|
const floatingLink = () =>
|
||||||
|
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetNavigation();
|
||||||
|
instances.length = 0;
|
||||||
|
global.IntersectionObserver =
|
||||||
|
FakeObserver as unknown as typeof IntersectionObserver;
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
window.gtag = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.IntersectionObserver = originalObserver;
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
delete window.gtag;
|
||||||
|
document.documentElement.style.removeProperty("--cookie-bar-height");
|
||||||
|
document.documentElement.removeAttribute("data-cookie-bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderBoth() {
|
||||||
|
return render(
|
||||||
|
<>
|
||||||
|
<a id="hero-contact-cta" href="/#contact">
|
||||||
|
Conversar
|
||||||
|
</a>
|
||||||
|
<section id="contact" aria-label="Contato" />
|
||||||
|
<FloatingWhatsApp />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FloatingWhatsApp: convites presentes na página", () => {
|
||||||
|
it("path: only the hero button exists -> observes just it and follows its visibility", () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<a id="hero-contact-cta" href="/#contact">
|
||||||
|
Conversar sobre meu projeto
|
||||||
|
</a>
|
||||||
|
<FloatingWhatsApp />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0].observed).toEqual([hero()]);
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
|
||||||
|
instances[0].report([{ target: hero(), isIntersecting: true }]);
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
|
||||||
|
instances[0].report([{ target: hero(), isIntersecting: false }]);
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: only the contact section exists -> observes just it and follows its visibility", () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<section id="contact" aria-label="Contato" />
|
||||||
|
<FloatingWhatsApp />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
expect(instances[0].observed).toEqual([contact()]);
|
||||||
|
|
||||||
|
instances[0].report([{ target: contact(), isIntersecting: true }]);
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
|
||||||
|
instances[0].report([{ target: contact(), isIntersecting: false }]);
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: neither invite exists -> no observer is created and the button shows at once", () => {
|
||||||
|
render(<FloatingWhatsApp />);
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: both invites exist -> both are observed", () => {
|
||||||
|
renderBoth();
|
||||||
|
|
||||||
|
expect(instances[0].observed).toEqual([hero(), contact()]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FloatingWhatsApp: relatórios do observador", () => {
|
||||||
|
it("path: a later report about one invite keeps what was said about the other", () => {
|
||||||
|
renderBoth();
|
||||||
|
const [observer] = instances;
|
||||||
|
observer.report([
|
||||||
|
{ target: hero(), isIntersecting: true },
|
||||||
|
{ target: contact(), isIntersecting: false },
|
||||||
|
]);
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
|
||||||
|
// só o contato é reportado de novo; o hero continua à vista
|
||||||
|
observer.report([{ target: contact(), isIntersecting: false }]);
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
|
||||||
|
observer.report([{ target: hero(), isIntersecting: false }]);
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: both invites on screen at once -> hidden until both leave", () => {
|
||||||
|
renderBoth();
|
||||||
|
const [observer] = instances;
|
||||||
|
observer.report([
|
||||||
|
{ target: hero(), isIntersecting: true },
|
||||||
|
{ target: contact(), isIntersecting: true },
|
||||||
|
]);
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
|
||||||
|
observer.report([{ target: hero(), isIntersecting: false }]);
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
|
||||||
|
observer.report([{ target: contact(), isIntersecting: false }]);
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: an empty report -> nothing is visible, so the button shows", () => {
|
||||||
|
renderBoth();
|
||||||
|
|
||||||
|
instances[0].report([]);
|
||||||
|
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmount -> the observer is disconnected", () => {
|
||||||
|
const { unmount } = renderBoth();
|
||||||
|
expect(instances[0].disconnect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(instances[0].disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FloatingWhatsApp: conteúdo e cliques", () => {
|
||||||
|
it("path: the shortcut is CSS-limited to small screens and sized as a 56px target", () => {
|
||||||
|
render(<FloatingWhatsApp />);
|
||||||
|
|
||||||
|
const link = floatingLink();
|
||||||
|
expect(link).toHaveClass("h-14", "w-14");
|
||||||
|
expect(link?.parentElement).toHaveClass("md:hidden", "fixed", "right-sp-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: click on the home reports the home subject as the service (the central navigation mock opens on '/')", () => {
|
||||||
|
render(<FloatingWhatsApp />);
|
||||||
|
|
||||||
|
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
|
||||||
|
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"whatsapp_open",
|
||||||
|
expect.objectContaining({ service: HOME_SUBJECT, placement: "floating" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: click on a page without a subject reports 'Contato' as the service", () => {
|
||||||
|
setNavigation({ pathname: "/privacidade" });
|
||||||
|
render(<FloatingWhatsApp />);
|
||||||
|
|
||||||
|
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
|
||||||
|
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"whatsapp_open",
|
||||||
|
expect.objectContaining({ service: "Contato", placement: "floating" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FloatingWhatsApp com a barra de cookies aberta", () => {
|
||||||
|
it("path: the bar publishes its height while open and removes it once a choice is made", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.clear();
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 72,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<GoogleAnalytics />
|
||||||
|
<FloatingWhatsApp />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
const refuse = await screen.findByRole("button", { name: "Recusar" });
|
||||||
|
|
||||||
|
// o botão flutuante usa a variável publicada pela barra para subir
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
||||||
|
).toBe("72px");
|
||||||
|
expect(floatingLink()?.parentElement?.className).toContain(
|
||||||
|
"var(--cookie-bar-height,0px)",
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(refuse);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
||||||
|
).toBe("");
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { WhatsAppContexts } from "@/lib/whatsapp";
|
||||||
|
import FloatingWhatsApp from "./FloatingWhatsApp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O botão vive em app/layout.tsx e vale em todo o site: a mensagem acompanha a página aberta, e a observação
|
||||||
|
* do hero e do contato (que só existem na home) recomeça a cada troca de página.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const contexts: WhatsAppContexts = {
|
||||||
|
// o whatsappSubject do serviço (lib/site-content.ts): uma frase em minúsculas, que cabe em "Quero conversar sobre {assunto}."
|
||||||
|
services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" },
|
||||||
|
cases: { "sev-modaintima": "SEV Moda Íntima" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Entry = Pick<IntersectionObserverEntry, "target" | "isIntersecting">;
|
||||||
|
|
||||||
|
const instances: FakeObserver[] = [];
|
||||||
|
const originalObserver = global.IntersectionObserver;
|
||||||
|
|
||||||
|
class FakeObserver {
|
||||||
|
disconnect = jest.fn();
|
||||||
|
constructor(private callback: IntersectionObserverCallback) {
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
observe = jest.fn();
|
||||||
|
unobserve = jest.fn();
|
||||||
|
takeRecords = () => [];
|
||||||
|
report(entries: Entry[]) {
|
||||||
|
act(() =>
|
||||||
|
this.callback(entries as IntersectionObserverEntry[], this as never),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const floatingLink = () =>
|
||||||
|
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
|
||||||
|
|
||||||
|
const messageOf = (link: HTMLElement | null) =>
|
||||||
|
new URL(link?.getAttribute("href") ?? "").searchParams.get("text");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
instances.length = 0;
|
||||||
|
global.IntersectionObserver =
|
||||||
|
FakeObserver as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.IntersectionObserver = originalObserver;
|
||||||
|
document.getElementById("hero-contact-cta")?.remove();
|
||||||
|
document.getElementById("contact")?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("message by route", () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"/servicos/criacao-de-sites",
|
||||||
|
"Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/cases/sev-modaintima",
|
||||||
|
"Quero conversar sobre um projeto parecido com o case SEV Moda Íntima.",
|
||||||
|
],
|
||||||
|
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
|
||||||
|
// a mesma frase do botão do hero e do cabeçalho de cada página
|
||||||
|
["/", "Quero conversar sobre um site ou sistema para a minha empresa."],
|
||||||
|
["/servicos", "Quero conversar sobre sites, sistemas e aplicativos."],
|
||||||
|
[
|
||||||
|
"/cases",
|
||||||
|
"Quero conversar sobre um projeto parecido com os cases do portfólio.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/curriculo",
|
||||||
|
"Quero conversar sobre um projeto, depois de ver o seu currículo.",
|
||||||
|
],
|
||||||
|
["/privacidade", "Quero conversar sobre um projeto."],
|
||||||
|
["/servicos/inexistente", "Quero conversar sobre um projeto."],
|
||||||
|
])("on %s", (pathname, sentence) => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue(pathname);
|
||||||
|
// sem hero nem contato na página, o botão aparece de imediato
|
||||||
|
render(<FloatingWhatsApp contexts={contexts} />);
|
||||||
|
|
||||||
|
expect(messageOf(floatingLink())).toContain(sentence);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a generic message without contexts", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
|
||||||
|
render(<FloatingWhatsApp />);
|
||||||
|
|
||||||
|
expect(messageOf(floatingLink())).toContain(
|
||||||
|
"Quero conversar sobre um projeto.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("page changes", () => {
|
||||||
|
it("observes the hero and the contact again on every page, hiding the button until the new report", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/cases/sev-modaintima");
|
||||||
|
const { rerender } = render(<FloatingWhatsApp contexts={contexts} />);
|
||||||
|
// páginas de case não têm hero nem contato: o botão está à vista
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
|
||||||
|
// volta à home, que tem os dois convites
|
||||||
|
document.body.insertAdjacentHTML(
|
||||||
|
"beforeend",
|
||||||
|
'<a id="hero-contact-cta" href="/#contact">Conversar</a><section id="contact"></section>',
|
||||||
|
);
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
rerender(<FloatingWhatsApp contexts={contexts} />);
|
||||||
|
|
||||||
|
// sem piscar: some até o observador dizer que o hero saiu da tela
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
const hero = document.getElementById("hero-contact-cta") as Element;
|
||||||
|
const contact = document.getElementById("contact") as Element;
|
||||||
|
instances[0].report([
|
||||||
|
{ target: hero, isIntersecting: false },
|
||||||
|
{ target: contact, isIntersecting: false },
|
||||||
|
]);
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
expect(messageOf(floatingLink())).toContain(
|
||||||
|
"Quero conversar sobre um site ou sistema para a minha empresa.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// sai da home para um serviço: o observador antigo é desligado e o botão passa a falar do serviço
|
||||||
|
document.getElementById("hero-contact-cta")?.remove();
|
||||||
|
document.getElementById("contact")?.remove();
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
|
||||||
|
rerender(<FloatingWhatsApp contexts={contexts} />);
|
||||||
|
expect(instances[0].disconnect).toHaveBeenCalled();
|
||||||
|
expect(messageOf(floatingLink())).toContain(
|
||||||
|
"Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { HOME_SUBJECT } from "@/lib/whatsapp";
|
||||||
|
import FloatingWhatsApp from "./FloatingWhatsApp";
|
||||||
|
|
||||||
|
const CONSENT_KEY = "evandro.analytics-consent.v1";
|
||||||
|
|
||||||
|
type Entry = Pick<IntersectionObserverEntry, "target" | "isIntersecting">;
|
||||||
|
|
||||||
|
let notify: (entries: Entry[]) => void = () => {};
|
||||||
|
const originalObserver = global.IntersectionObserver;
|
||||||
|
|
||||||
|
class FakeObserver {
|
||||||
|
constructor(callback: IntersectionObserverCallback) {
|
||||||
|
notify = (entries) =>
|
||||||
|
callback(entries as IntersectionObserverEntry[], this as never);
|
||||||
|
}
|
||||||
|
observe = jest.fn();
|
||||||
|
unobserve = jest.fn();
|
||||||
|
disconnect = jest.fn();
|
||||||
|
takeRecords = () => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Page({ withInvites = true }: { withInvites?: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{withInvites && (
|
||||||
|
<>
|
||||||
|
<a id="hero-contact-cta" href="/#contact">
|
||||||
|
Conversar sobre meu projeto
|
||||||
|
</a>
|
||||||
|
<section id="contact" aria-label="Contato" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<FloatingWhatsApp />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVisibility(hero: boolean, contact: boolean) {
|
||||||
|
act(() =>
|
||||||
|
notify([
|
||||||
|
{
|
||||||
|
target: document.getElementById("hero-contact-cta") as Element,
|
||||||
|
isIntersecting: hero,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: document.getElementById("contact") as Element,
|
||||||
|
isIntersecting: contact,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const floatingLink = () =>
|
||||||
|
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
global.IntersectionObserver =
|
||||||
|
FakeObserver as unknown as typeof IntersectionObserver;
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
window.gtag = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.IntersectionObserver = originalObserver;
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
delete window.gtag;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays hidden until the observer reports, then shows a 56px WhatsApp shortcut for small screens", () => {
|
||||||
|
render(<Page />);
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
|
||||||
|
setVisibility(false, false);
|
||||||
|
const link = floatingLink();
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(link).toHaveAttribute("target", "_blank");
|
||||||
|
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
const url = new URL(link?.getAttribute("href") ?? "");
|
||||||
|
expect(url.origin).toBe("https://wa.me");
|
||||||
|
// o dublê central de next/navigation abre na home ("/"): o botão fala do assunto da home
|
||||||
|
expect(url.searchParams.get("text")).toContain(
|
||||||
|
`Quero conversar sobre ${HOME_SUBJECT}.`,
|
||||||
|
);
|
||||||
|
// alvo de 56 px, canto inferior direito, só abaixo de 768 px
|
||||||
|
expect(link).toHaveClass("h-14", "w-14");
|
||||||
|
expect(link?.parentElement).toHaveClass("fixed", "right-sp-4", "md:hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides while the hero button is on screen", () => {
|
||||||
|
render(<Page />);
|
||||||
|
setVisibility(true, false);
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
|
||||||
|
setVisibility(false, false);
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides while the contact section is on screen", () => {
|
||||||
|
render(<Page />);
|
||||||
|
setVisibility(false, true);
|
||||||
|
expect(floatingLink()).toBeNull();
|
||||||
|
|
||||||
|
setVisibility(false, false);
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows right away on a page that has neither the hero button nor the contact section", () => {
|
||||||
|
render(<Page withInvites={false} />);
|
||||||
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lifts itself by the height of the open cookie bar", () => {
|
||||||
|
render(<Page />);
|
||||||
|
setVisibility(false, false);
|
||||||
|
|
||||||
|
expect(floatingLink()?.parentElement?.className).toContain(
|
||||||
|
"var(--cookie-bar-height,0px)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lifts with a transform, not by animating `bottom`, so the layout does not shift", () => {
|
||||||
|
render(<Page />);
|
||||||
|
setVisibility(false, false);
|
||||||
|
|
||||||
|
const className = floatingLink()?.parentElement?.className ?? "";
|
||||||
|
expect(className).toContain("-translate-y-[var(--cookie-bar-height,0px)]");
|
||||||
|
expect(className).toContain("transition-transform");
|
||||||
|
expect(className).toContain("motion-reduce:transition-none");
|
||||||
|
// o `bottom` fica fixo (margem e área segura) e a barra de cookies não entra nele
|
||||||
|
expect(className).not.toContain("transition-[bottom]");
|
||||||
|
expect(className).toContain(
|
||||||
|
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sits in a named complementary region (the 'region' landmark rule), which only exists while the shortcut shows", () => {
|
||||||
|
render(<Page />);
|
||||||
|
expect(screen.queryByRole("complementary")).toBeNull();
|
||||||
|
|
||||||
|
setVisibility(false, false);
|
||||||
|
const region = screen.getByRole("complementary", { name: "Contato rápido" });
|
||||||
|
expect(region.tagName).toBe("ASIDE");
|
||||||
|
expect(region).toContainElement(floatingLink());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the click as a floating WhatsApp handoff", () => {
|
||||||
|
render(<Page />);
|
||||||
|
setVisibility(false, false);
|
||||||
|
|
||||||
|
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
|
||||||
|
|
||||||
|
expect(window.gtag).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"whatsapp_open",
|
||||||
|
expect.objectContaining({ placement: "floating" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { type WhatsAppContexts, whatsAppSubjectForPath } from "@/lib/whatsapp";
|
||||||
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
import { WhatsAppMark } from "./WhatsAppMark";
|
||||||
|
|
||||||
|
/** Botão principal do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
|
||||||
|
export const HERO_CTA_ID = "hero-contact-cta";
|
||||||
|
const CONTACT_SECTION_ID = "contact";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
|
||||||
|
* cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e
|
||||||
|
* contato da home) e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta.
|
||||||
|
* Fica num <aside> com nome (região de contato rápido para leitores de tela) e sobe com `transform`, não com `bottom`:
|
||||||
|
* animar `bottom` refaz o layout e contava como deslocamento de layout (CLS) nas páginas internas.
|
||||||
|
*/
|
||||||
|
export default function FloatingWhatsApp({
|
||||||
|
contexts,
|
||||||
|
}: {
|
||||||
|
/** Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx). */
|
||||||
|
contexts?: WhatsAppContexts;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const subject = whatsAppSubjectForPath(pathname, contexts);
|
||||||
|
// null até o observador responder pela primeira vez: assim o botão não pisca na chegada, quando o hero está à vista
|
||||||
|
const [inviteVisible, setInviteVisible] = useState<Record<
|
||||||
|
string,
|
||||||
|
boolean
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
|
// o botão vive no layout: a cada página o hero e o contato (que só existem na home) entram ou saem de cena
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname só reinicia a observação a cada página
|
||||||
|
useEffect(() => {
|
||||||
|
setInviteVisible(null);
|
||||||
|
const targets = [HERO_CTA_ID, CONTACT_SECTION_ID]
|
||||||
|
.map((id) => document.getElementById(id))
|
||||||
|
.filter((element): element is HTMLElement => element !== null);
|
||||||
|
if (targets.length === 0) {
|
||||||
|
setInviteVisible({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
setInviteVisible((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
for (const entry of entries) {
|
||||||
|
next[entry.target.id] = entry.isIntersecting;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (const target of targets) observer.observe(target);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
if (!inviteVisible || Object.values(inviteVisible).some(Boolean)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label="Contato rápido"
|
||||||
|
className="fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))] z-[8900] -translate-y-[var(--cookie-bar-height,0px)] transition-transform duration-200 motion-reduce:transition-none md:hidden"
|
||||||
|
>
|
||||||
|
<WhatsAppLink
|
||||||
|
placement="floating"
|
||||||
|
service={subject}
|
||||||
|
className="flex h-14 w-14 items-center justify-center rounded-full bg-[#25d366] text-[#071b10] shadow-[0_8px_24px_rgba(0,0,0,0.45)] transition-colors hover:bg-[#54e58b] motion-reduce:transition-none"
|
||||||
|
>
|
||||||
|
<WhatsAppMark className="h-7 w-7" />
|
||||||
|
<span className="sr-only">Conversar no WhatsApp</span>
|
||||||
|
</WhatsAppLink>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { OPEN_COOKIE_PREFERENCES_EVENT } from "@/lib/analytics";
|
||||||
|
import { serviceHubLinks } from "@/lib/site-content";
|
||||||
|
import Footer from "./Footer";
|
||||||
|
|
||||||
|
// o Link do Next, trocado por um <a> que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Footer contact and navigation", () => {
|
||||||
|
it("offers a direct mailto link without repeating the contact call to action", () => {
|
||||||
|
render(<Footer />);
|
||||||
|
|
||||||
|
// o convite principal fica na secao Contato, logo acima do rodape
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", { name: "Falar sobre meu projeto" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const emailLink = screen.getByRole("link", {
|
||||||
|
name: "[email protected]",
|
||||||
|
});
|
||||||
|
expect(emailLink).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"mailto:[email protected]",
|
||||||
|
);
|
||||||
|
expect(emailLink).not.toHaveAttribute("target", "_blank");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers WhatsApp in the contact column, in a new tab", () => {
|
||||||
|
render(<Footer />);
|
||||||
|
|
||||||
|
const contact = screen.getByRole("region", { name: "Contato" });
|
||||||
|
const whatsApp = within(contact).getByRole("link", { name: "WhatsApp" });
|
||||||
|
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
||||||
|
expect(whatsApp).toHaveAttribute("target", "_blank");
|
||||||
|
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the six services from the services hub as same-tab links", () => {
|
||||||
|
render(<Footer serviceLinks={serviceHubLinks} />);
|
||||||
|
|
||||||
|
expect(serviceHubLinks).toHaveLength(6);
|
||||||
|
const services = screen.getByRole("navigation", { name: "Serviços" });
|
||||||
|
const links = within(services).getAllByRole("link");
|
||||||
|
expect(links.map((link) => link.getAttribute("href"))).toEqual(
|
||||||
|
serviceHubLinks.map((service) => service.href),
|
||||||
|
);
|
||||||
|
expect(links.map((link) => link.textContent)).toEqual(
|
||||||
|
serviceHubLinks.map((service) => service.label),
|
||||||
|
);
|
||||||
|
for (const link of links) expect(link).not.toHaveAttribute("target");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links Cases, Privacidade and a discreet Currículo, with no PDF download", () => {
|
||||||
|
render(<Footer />);
|
||||||
|
|
||||||
|
const more = screen.getByRole("navigation", { name: "Mais" });
|
||||||
|
expect(within(more).getByRole("link", { name: "Cases" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/cases",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(more).getByRole("link", {
|
||||||
|
name: "Revisão de sistema que já existe",
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("href", "/ia");
|
||||||
|
expect(
|
||||||
|
within(more).getByRole("link", { name: "Privacidade" }),
|
||||||
|
).toHaveAttribute("href", "/privacidade");
|
||||||
|
const resume = within(more).getByRole("link", { name: "Currículo" });
|
||||||
|
expect(resume).toHaveAttribute("href", "/curriculo");
|
||||||
|
expect(within(more).getAllByRole("link").at(-1)).toBe(resume);
|
||||||
|
expect(screen.queryByRole("link", { name: /baixar|pdf/i })).toBeNull();
|
||||||
|
for (const link of screen.getAllByRole("link")) {
|
||||||
|
expect(link).not.toHaveAttribute("download");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens the cookie preferences from a button", () => {
|
||||||
|
const reopen = jest.fn();
|
||||||
|
window.addEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
|
||||||
|
render(<Footer />);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reopen).toHaveBeenCalledTimes(1);
|
||||||
|
window.removeEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states the location and rights in Portuguese", () => {
|
||||||
|
render(<Footer />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("Garuva, SC · Atendimento remoto em todo o Brasil"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Todos os direitos\s+reservados/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/remote worldwide|all rights/i)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Footer social links", () => {
|
||||||
|
it("keeps one LinkedIn profile in a new tab and no GitHub link", () => {
|
||||||
|
render(<Footer />);
|
||||||
|
|
||||||
|
const linkedin = screen.getByRole("link", { name: "LinkedIn" });
|
||||||
|
expect(linkedin).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://www.linkedin.com/in/evandrodevbr/",
|
||||||
|
);
|
||||||
|
expect(linkedin).toHaveAttribute("target", "_blank");
|
||||||
|
expect(linkedin).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
for (const link of screen.getAllByRole("link")) {
|
||||||
|
expect(link.getAttribute("href") ?? "").not.toMatch(/github\.com/);
|
||||||
|
}
|
||||||
|
expect(screen.queryByRole("link", { name: /github/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Instagram in the contact column, in a new tab", () => {
|
||||||
|
render(<Footer />);
|
||||||
|
|
||||||
|
const contact = screen.getByRole("region", { name: "Contato" });
|
||||||
|
const instagram = within(contact).getByRole("link", { name: "Instagram" });
|
||||||
|
expect(instagram).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://www.instagram.com/evandrodevbr/",
|
||||||
|
);
|
||||||
|
expect(instagram).toHaveAttribute("target", "_blank");
|
||||||
|
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the three offers once, with the same words as the home, and no old 'app feito com IA' name", () => {
|
||||||
|
render(<Footer />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Revisão de sistema que já existe" }),
|
||||||
|
).toHaveAttribute("href", "/ia");
|
||||||
|
expect(screen.queryByText(/app feito com IA/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the height of the cookie bar to the bottom padding, so the logo and the copyright are not under it at the end of the page", () => {
|
||||||
|
const { container } = render(<Footer />);
|
||||||
|
|
||||||
|
const footer = container.querySelector("footer") as HTMLElement;
|
||||||
|
// com a barra fechada a variável não existe: o 0px do fallback deixa o rodapé como era
|
||||||
|
expect(footer.className).toContain(
|
||||||
|
"pb-[calc(var(--sp-10)+var(--cookie-bar-height,0px))]",
|
||||||
|
);
|
||||||
|
// no celular o botão flutuante do WhatsApp (72 px) também continua à parte
|
||||||
|
expect(footer.className).toContain(
|
||||||
|
"max-md:pb-[calc(var(--sp-10)+72px+var(--cookie-bar-height,0px))]",
|
||||||
|
);
|
||||||
|
expect(footer.className).not.toMatch(/(?:^|\s)py-sp-10(?:\s|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-fetches the internal links only when the visitor reaches them, never by viewport (services, cases, /ia, privacy, résumé, home)", () => {
|
||||||
|
render(<Footer serviceLinks={serviceHubLinks} />);
|
||||||
|
|
||||||
|
const internal = screen
|
||||||
|
.getAllByRole("link")
|
||||||
|
.filter((link) => (link.getAttribute("href") ?? "").startsWith("/"));
|
||||||
|
// serviços do hub + Cases, /ia, Privacidade, Currículo + o logo para /#home
|
||||||
|
expect(internal.length).toBeGreaterThanOrEqual(serviceHubLinks.length + 5);
|
||||||
|
for (const link of internal) {
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "false");
|
||||||
|
fireEvent.mouseEnter(link);
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "null");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
openCookiePreferences,
|
||||||
|
trackEmailClick,
|
||||||
|
trackSocialClick,
|
||||||
|
} from "@/lib/analytics";
|
||||||
|
import { offerLabels } from "@/lib/offer-labels";
|
||||||
|
import {
|
||||||
|
CONTACT_EMAIL,
|
||||||
|
INSTAGRAM_URL,
|
||||||
|
LINKEDIN_URL,
|
||||||
|
type ServiceLink,
|
||||||
|
} from "@/lib/site-navigation";
|
||||||
|
import "./conversion.css";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
|
||||||
|
const headingClassName =
|
||||||
|
"font-mono text-[12px] font-medium uppercase tracking-[0.12em] text-[var(--meta)]";
|
||||||
|
// min-h-11 no toque; no desktop os links ficam mais juntos para o rodapé não crescer à toa
|
||||||
|
const linkClassName =
|
||||||
|
"inline-flex min-h-11 items-center text-left font-display text-[15px] leading-6 text-[var(--ink-80)] md:min-h-8 underline-offset-4 hover:text-[var(--ink)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)]";
|
||||||
|
|
||||||
|
export default function Footer({
|
||||||
|
serviceLinks = [],
|
||||||
|
}: {
|
||||||
|
/** Os serviços do hub; app/page.tsx os lê de lib/site-content.ts. */
|
||||||
|
serviceLinks?: ServiceLink[];
|
||||||
|
}) {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
// no celular o botão flutuante do WhatsApp (56 px + 16 px de margem) cobriria a última linha: o rodapé sobe o espaço dele.
|
||||||
|
// A barra de cookies (fixa no pé da tela) cobriria o logo e o © no fim da página: o rodapé também sobe a altura dela
|
||||||
|
// (--cookie-bar-height, publicada por GoogleAnalytics enquanto a barra está aberta; sem barra, 0).
|
||||||
|
<footer className="border-t border-[var(--border)] bg-[var(--paper)] px-[clamp(20px,6vw,64px)] pt-sp-10 pb-[calc(var(--sp-10)+var(--cookie-bar-height,0px))] text-[var(--ink)] max-md:pb-[calc(var(--sp-10)+72px+var(--cookie-bar-height,0px))]">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<div className="grid gap-sp-8 md:grid-cols-3 md:gap-sp-10">
|
||||||
|
<section aria-labelledby="footer-contact-title">
|
||||||
|
<p id="footer-contact-title" className={headingClassName}>
|
||||||
|
Contato
|
||||||
|
</p>
|
||||||
|
<ul className="mt-sp-2">
|
||||||
|
<li>
|
||||||
|
<WhatsAppLink
|
||||||
|
placement="footer"
|
||||||
|
className={`${linkClassName} font-semibold !text-[var(--ink)] focus-visible:!ring-[var(--ink)] focus-visible:!ring-offset-[var(--paper)]`}
|
||||||
|
>
|
||||||
|
WhatsApp
|
||||||
|
</WhatsAppLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
|
onClick={() => trackEmailClick({ placement: "footer" })}
|
||||||
|
className={`${linkClassName} break-all font-semibold !text-[var(--ink)]`}
|
||||||
|
>
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={LINKEDIN_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => trackSocialClick({ network: "linkedin" })}
|
||||||
|
className={linkClassName}
|
||||||
|
>
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={INSTAGRAM_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => trackSocialClick({ network: "instagram" })}
|
||||||
|
className={linkClassName}
|
||||||
|
>
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-sp-2 font-display text-[15px] leading-6 text-[var(--meta)]">
|
||||||
|
Garuva, SC · Atendimento remoto em todo o Brasil
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav aria-labelledby="footer-services-title">
|
||||||
|
<p id="footer-services-title" className={headingClassName}>
|
||||||
|
Serviços
|
||||||
|
</p>
|
||||||
|
<ul className="mt-sp-2">
|
||||||
|
{serviceLinks.map((service) => (
|
||||||
|
<li key={service.href}>
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href={service.href}
|
||||||
|
className={linkClassName}
|
||||||
|
>
|
||||||
|
{service.label}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav aria-labelledby="footer-links-title">
|
||||||
|
<p id="footer-links-title" className={headingClassName}>
|
||||||
|
Mais
|
||||||
|
</p>
|
||||||
|
<ul className="mt-sp-2">
|
||||||
|
<li>
|
||||||
|
<HoverPrefetchLink href="/cases" className={linkClassName}>
|
||||||
|
Cases
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<HoverPrefetchLink href="/ia" className={linkClassName}>
|
||||||
|
{offerLabels.revisao.name}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href="/privacidade"
|
||||||
|
className={linkClassName}
|
||||||
|
>
|
||||||
|
Privacidade
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCookiePreferences}
|
||||||
|
className={`cv-btn-link ${linkClassName}`}
|
||||||
|
>
|
||||||
|
Preferências de cookies
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href="/curriculo"
|
||||||
|
className={`${linkClassName} !text-[15px] !text-[var(--meta)]`}
|
||||||
|
>
|
||||||
|
Currículo
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-sp-8 flex flex-wrap items-center justify-between gap-x-sp-6 gap-y-sp-3 border-t border-[var(--border)] pt-sp-5">
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href="/#home"
|
||||||
|
aria-label="Evandro.dev.br — início"
|
||||||
|
className="inline-flex min-h-11 items-center"
|
||||||
|
>
|
||||||
|
{/* o ".dev" do logo é limão (#ccff00), quase invisível sobre o papel: o filtro o escurece (de 1,04:1
|
||||||
|
para 4:1) e o resto do logo, já quase preto, quase não muda */}
|
||||||
|
<Logo theme="light" className="w-40 [filter:brightness(0.5)]" />
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
<p className="font-display text-[15px] leading-6 text-[var(--meta)]">
|
||||||
|
© {currentYear} Evandro Fonseca Junior. Todos os direitos
|
||||||
|
reservados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment-options {"url": "https://evandro.dev.br/"}
|
||||||
|
*/
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { openCookiePreferences } from "@/lib/analytics";
|
||||||
|
import GoogleAnalytics from "./GoogleAnalytics";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caminho de GoogleAnalytics (expireAnalyticsCookies) quando o site roda no próprio domínio evandro.dev.br:
|
||||||
|
* host e domínio raiz são o mesmo, então só há um atributo Domain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/script", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
strategy: _strategy,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CONSENT_KEY = "evandro.analytics-consent.v1";
|
||||||
|
|
||||||
|
function setCookie(value: string) {
|
||||||
|
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
|
||||||
|
document.cookie = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: host is the site domain itself -> expires _ga on the path and once on the domain", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
expect(window.location.hostname).toBe("evandro.dev.br");
|
||||||
|
setCookie("_ga=1; Path=/");
|
||||||
|
setCookie("_ga_ABC=1; Path=/");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={openCookiePreferences}>
|
||||||
|
Preferências de cookies
|
||||||
|
</button>
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await screen.findByTestId("google-analytics-script");
|
||||||
|
const cookieWrites = jest.spyOn(document, "cookie", "set");
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
||||||
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
const writes = cookieWrites.mock.calls.map(([value]) => String(value));
|
||||||
|
// dois cookies (_ga e _ga_ABC), cada um: sem Domain e com Domain=evandro.dev.br (host == raiz, sem duplicar)
|
||||||
|
expect(writes).toHaveLength(4);
|
||||||
|
expect(writes.filter((w) => w.includes("Domain="))).toEqual([
|
||||||
|
expect.stringMatching(/^_ga=.*; Domain=evandro\.dev\.br$/),
|
||||||
|
expect.stringMatching(/^_ga_ABC=.*; Domain=evandro\.dev\.br$/),
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { openCookiePreferences } from "@/lib/analytics";
|
||||||
|
import GoogleAnalytics from "./GoogleAnalytics";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de GoogleAnalytics (complemento de GoogleAnalytics.test.tsx):
|
||||||
|
* modo de desenvolvimento, teclas da barra, reaceite, nova concessão depois de revogar,
|
||||||
|
* cookies de analytics x outros cookies, reaproveitamento de gtag/dataLayer e recuperação de falha de gravação.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/script", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
strategy: _strategy,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CONSENT_KEY = "evandro.analytics-consent.v1";
|
||||||
|
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
|
||||||
|
|
||||||
|
type AnalyticsWindow = Window & {
|
||||||
|
dataLayer?: IArguments[];
|
||||||
|
gtag?: (...args: unknown[]) => void;
|
||||||
|
[GA_DISABLE_KEY]?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyticsWindow = window as AnalyticsWindow;
|
||||||
|
|
||||||
|
function setCookie(value: string) {
|
||||||
|
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
|
||||||
|
document.cookie = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireCookie(name: string) {
|
||||||
|
setCookie(`${name}=; Max-Age=0; Path=/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COOKIES = ["_ga", "_ga_ABC123", "_gid", "_gat", "portfolio_session"];
|
||||||
|
|
||||||
|
function commands() {
|
||||||
|
return (analyticsWindow.dataLayer ?? []).map((entry) => Array.from(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandsNamed(...names: string[]) {
|
||||||
|
return commands().filter((entry) =>
|
||||||
|
names.every((name, index) => entry[index] === name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenPreferencesButton() {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={openCookiePreferences}>
|
||||||
|
Preferências de cookies
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||||
|
document.getElementById("google-analytics-script")?.remove();
|
||||||
|
localStorage.clear();
|
||||||
|
for (const name of COOKIES) expireCookie(name);
|
||||||
|
analyticsWindow.dataLayer = undefined;
|
||||||
|
analyticsWindow.gtag = undefined;
|
||||||
|
analyticsWindow[GA_DISABLE_KEY] = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
document.getElementById("google-analytics-script")?.remove();
|
||||||
|
for (const name of COOKIES) expireCookie(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GoogleAnalytics: modo e teclas", () => {
|
||||||
|
it("path: outside production -> the cookie bar and the choice are still there (dev and production behave the same), but the gtag script is never loaded", async () => {
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "development");
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
// a barra (e o foco por Tab) é a mesma da produção
|
||||||
|
const dialog = await screen.findByRole("dialog", {
|
||||||
|
name: "Cookies e privacidade",
|
||||||
|
});
|
||||||
|
expect(dialog).toBeVisible();
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
||||||
|
).not.toBe("");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Aceitar" }));
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
// mesmo com o consentimento, fora da produção o gtag não é baixado
|
||||||
|
await act(async () => {});
|
||||||
|
expect(screen.queryByTestId("google-analytics-script")).toBeNull();
|
||||||
|
expect(document.getElementById("google-analytics-script")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the same consent in production loads the gtag script (the only thing tied to NODE_ENV)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByTestId("google-analytics-script"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: Escape on the first-visit bar -> bar stays and no choice is stored", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
const accept = await screen.findByRole("button", { name: "Aceitar" });
|
||||||
|
accept.focus();
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog")).toBeVisible();
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBeNull();
|
||||||
|
expect(screen.queryByText(/Escolha atual/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: another key on the reopened bar -> stays open", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "denied");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<OpenPreferencesButton />
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.keyboard("x{Tab}");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: reopened with a refusal stored -> the bar announces 'recusado'", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "denied");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<OpenPreferencesButton />
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
|
||||||
|
/Escolha atual: recusado/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GoogleAnalytics: aceitar e recusar", () => {
|
||||||
|
it("path: reopened while already granted, 'Aceitar' only closes the bar", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
analyticsWindow.gtag = jest.fn();
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<OpenPreferencesButton />
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await screen.findByTestId("google-analytics-script");
|
||||||
|
const gtag = analyticsWindow.gtag as jest.Mock;
|
||||||
|
const callsAfterMount = gtag.mock.calls.length;
|
||||||
|
const opener = screen.getByRole("button", {
|
||||||
|
name: "Preferências de cookies",
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(opener);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Aceitar" }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(gtag.mock.calls).toHaveLength(callsAfterMount);
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
||||||
|
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
|
||||||
|
expect(opener).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: granting again after a revocation -> bootstrap runs once, GA is re-enabled", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<OpenPreferencesButton />
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await screen.findByTestId("google-analytics-script");
|
||||||
|
const opener = screen.getByRole("button", {
|
||||||
|
name: "Preferências de cookies",
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(opener);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
||||||
|
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
|
||||||
|
expect(screen.queryByTestId("google-analytics-script")).toBeNull();
|
||||||
|
|
||||||
|
await user.click(opener);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Aceitar" }));
|
||||||
|
|
||||||
|
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(false);
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
||||||
|
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
|
||||||
|
// "default" e "js" só na primeira ativação; a configuração dos dois destinos roda a cada concessão
|
||||||
|
expect(commandsNamed("consent", "default")).toHaveLength(1);
|
||||||
|
expect(commandsNamed("js")).toHaveLength(1);
|
||||||
|
expect(commandsNamed("config", "G-QTT9QT0T3S")).toHaveLength(2);
|
||||||
|
expect(commandsNamed("config", "AW-18432694647")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: first visit refused -> nothing is revoked because nothing was granted", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setCookie("_ga=keep-me; Path=/");
|
||||||
|
const cookieWrites = jest.spyOn(document, "cookie", "set");
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
|
||||||
|
expect(analyticsWindow[GA_DISABLE_KEY]).toBeUndefined();
|
||||||
|
expect(analyticsWindow.dataLayer).toBeUndefined();
|
||||||
|
expect(cookieWrites).not.toHaveBeenCalled();
|
||||||
|
expect(document.cookie).toContain("_ga=keep-me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: revoking after gtag disappeared -> still persists, disables GA and does not throw", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<OpenPreferencesButton />
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await screen.findByTestId("google-analytics-script");
|
||||||
|
delete analyticsWindow.gtag;
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
|
||||||
|
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GoogleAnalytics: cookies na revogação", () => {
|
||||||
|
it("path: only _ga and _ga_* are expired; look-alikes and other cookies stay", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
for (const name of COOKIES) setCookie(`${name}=1; Path=/`);
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<OpenPreferencesButton />
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await screen.findByTestId("google-analytics-script");
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
const remaining = document.cookie.split(";").map((c) => c.trim());
|
||||||
|
expect(remaining).toEqual(
|
||||||
|
expect.arrayContaining(["_gid=1", "_gat=1", "portfolio_session=1"]),
|
||||||
|
);
|
||||||
|
expect(remaining.some((c) => c.startsWith("_ga="))).toBe(false);
|
||||||
|
expect(remaining.some((c) => c.startsWith("_ga_ABC123="))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no analytics cookie present -> revocation writes no cookie", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
setCookie("portfolio_session=1; Path=/");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<OpenPreferencesButton />
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await screen.findByTestId("google-analytics-script");
|
||||||
|
const cookieWrites = jest.spyOn(document, "cookie", "set");
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
expect(cookieWrites).not.toHaveBeenCalled();
|
||||||
|
expect(document.cookie).toContain("portfolio_session=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: host is not the site domain (localhost) -> expiry is written without a Domain attribute", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
setCookie("_ga=1; Path=/");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<OpenPreferencesButton />
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await screen.findByTestId("google-analytics-script");
|
||||||
|
const cookieWrites = jest.spyOn(document, "cookie", "set");
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
expect(cookieWrites).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cookieWrites.mock.calls[0][0]).toMatch(/^_ga=; Max-Age=0;/);
|
||||||
|
expect(cookieWrites.mock.calls[0][0]).not.toContain("Domain=");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GoogleAnalytics: gtag, dataLayer e carregador", () => {
|
||||||
|
it("path: dataLayer already exists -> the same array receives the commands", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const existing: IArguments[] = [];
|
||||||
|
analyticsWindow.dataLayer = existing;
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
|
||||||
|
|
||||||
|
expect(analyticsWindow.dataLayer).toBe(existing);
|
||||||
|
expect(existing.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof analyticsWindow.gtag).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a gtag already exists -> it is reused instead of being replaced", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const gtag = jest.fn();
|
||||||
|
analyticsWindow.gtag = gtag;
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
|
||||||
|
|
||||||
|
expect(analyticsWindow.gtag).toBe(gtag);
|
||||||
|
expect(gtag).toHaveBeenCalledWith("js", expect.any(Date));
|
||||||
|
expect(analyticsWindow.dataLayer).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["denied", "denied"],
|
||||||
|
["no choice yet", null],
|
||||||
|
])(
|
||||||
|
"path: %s -> a loader left by next/script is removed",
|
||||||
|
async (_label, stored) => {
|
||||||
|
if (stored) localStorage.setItem(CONSENT_KEY, stored);
|
||||||
|
const loader = document.createElement("script");
|
||||||
|
loader.id = "google-analytics-script";
|
||||||
|
document.head.append(loader);
|
||||||
|
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(document.getElementById("google-analytics-script")).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("path: granted -> the loader stays", async () => {
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
const loader = document.createElement("script");
|
||||||
|
loader.id = "google-analytics-script";
|
||||||
|
document.head.append(loader);
|
||||||
|
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(document.getElementById("google-analytics-script")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GoogleAnalytics: foco e falha de gravação", () => {
|
||||||
|
it("path: opener is not an HTML element (SVG) -> closing the bar has no element to refocus", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "denied");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: elemento SVG de teste só para receber o foco */}
|
||||||
|
<svg data-testid="svg-opener" tabIndex={0} />
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
const svg = screen.getByTestId("svg-opener");
|
||||||
|
svg.focus();
|
||||||
|
expect(document.activeElement).toBe(svg);
|
||||||
|
|
||||||
|
act(() => openCookiePreferences());
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(document.activeElement).not.toBe(svg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: acceptance fails to persist, then succeeds -> the alert goes away and analytics turns on", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const setItem = jest
|
||||||
|
.spyOn(Storage.prototype, "setItem")
|
||||||
|
.mockImplementation(() => {
|
||||||
|
throw new Error("storage blocked");
|
||||||
|
});
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("alert", { name: "Falha ao salvar o consentimento" }),
|
||||||
|
).toHaveTextContent(
|
||||||
|
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("dialog")).toBeVisible();
|
||||||
|
expect(analyticsWindow.dataLayer).toBeUndefined();
|
||||||
|
|
||||||
|
setItem.mockRestore();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Aceitar" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
||||||
|
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { openCookiePreferences } from "@/lib/analytics";
|
||||||
|
import GoogleAnalytics from "./GoogleAnalytics";
|
||||||
|
|
||||||
|
jest.mock("next/script", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
strategy: _strategy,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CONSENT_KEY = "evandro.analytics-consent.v1";
|
||||||
|
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
|
||||||
|
const GA_SRC = "https://www.googletagmanager.com/gtag/js?id=G-QTT9QT0T3S";
|
||||||
|
|
||||||
|
type AnalyticsWindow = Window & {
|
||||||
|
dataLayer?: IArguments[];
|
||||||
|
gtag?: (...args: unknown[]) => void;
|
||||||
|
[GA_DISABLE_KEY]?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyticsWindow = window as AnalyticsWindow;
|
||||||
|
|
||||||
|
function consentUpdates(gtag: jest.Mock) {
|
||||||
|
return gtag.mock.calls.filter(
|
||||||
|
([command, action]) => command === "consent" && action === "update",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(value: string) {
|
||||||
|
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
|
||||||
|
document.cookie = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GoogleAnalytics consent", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||||
|
document.getElementById("google-analytics-script")?.remove();
|
||||||
|
localStorage.clear();
|
||||||
|
setCookie("_ga=; Max-Age=0; Path=/");
|
||||||
|
setCookie("_ga_PORTFOLIO=; Max-Age=0; Path=/");
|
||||||
|
setCookie("portfolio_session=; Max-Age=0; Path=/");
|
||||||
|
analyticsWindow.dataLayer = undefined;
|
||||||
|
analyticsWindow.gtag = undefined;
|
||||||
|
analyticsWindow[GA_DISABLE_KEY] = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for a decision before loading analytics", async () => {
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", { name: "Aceitar" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
|
||||||
|
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
|
||||||
|
/Usamos métricas de navegação do Google só se você permitir\./,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("google-analytics-script"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Política de privacidade" }),
|
||||||
|
).toHaveAttribute("href", "/privacidade");
|
||||||
|
expect(analyticsWindow.dataLayer).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the prompt as a thin bottom bar and publishes its height while it is open", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
const bar = await screen.findByRole("dialog");
|
||||||
|
expect(bar).toHaveClass("fixed", "inset-x-0", "bottom-0");
|
||||||
|
expect(document.documentElement).toHaveAttribute("data-cookie-bar", "open");
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
||||||
|
).toMatch(/^\d+px$/);
|
||||||
|
// o link vem antes dos botões na ordem de Tab dentro da barra
|
||||||
|
expect(bar.querySelector("a, button")).toBe(
|
||||||
|
screen.getByRole("link", { name: "Política de privacidade" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
||||||
|
).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists acceptance and enables analytics without advertising consent", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
|
||||||
|
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
||||||
|
expect(screen.getByTestId("google-analytics-script")).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
GA_SRC,
|
||||||
|
);
|
||||||
|
const commands = analyticsWindow.dataLayer?.map((entry) =>
|
||||||
|
Array.from(entry),
|
||||||
|
);
|
||||||
|
expect(commands).toContainEqual([
|
||||||
|
"consent",
|
||||||
|
"default",
|
||||||
|
{
|
||||||
|
analytics_storage: "denied",
|
||||||
|
ad_storage: "denied",
|
||||||
|
ad_user_data: "denied",
|
||||||
|
ad_personalization: "denied",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(commands).toContainEqual([
|
||||||
|
"consent",
|
||||||
|
"update",
|
||||||
|
{
|
||||||
|
analytics_storage: "granted",
|
||||||
|
ad_storage: "denied",
|
||||||
|
ad_user_data: "denied",
|
||||||
|
ad_personalization: "denied",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(commands).toContainEqual([
|
||||||
|
"config",
|
||||||
|
"G-QTT9QT0T3S",
|
||||||
|
{
|
||||||
|
allow_ad_personalization_signals: false,
|
||||||
|
anonymize_ip: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(commands).toContainEqual([
|
||||||
|
"config",
|
||||||
|
"AW-18432694647",
|
||||||
|
{ allow_ad_personalization_signals: false },
|
||||||
|
]);
|
||||||
|
expect(commands).toContainEqual(["set", "ads_data_redaction", true]);
|
||||||
|
// depois da escolha nada fica fixo na tela
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists refusal and keeps analytics and the bar absent after reload", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const firstRender = render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("google-analytics-script"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
firstRender.unmount();
|
||||||
|
const reloaded = render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
await act(async () => {});
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(reloaded.container).toBeEmptyDOMElement();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("google-analytics-script"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens from the footer link and fully revokes analytics only", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
setCookie("_ga=main; Path=/");
|
||||||
|
setCookie("_ga_PORTFOLIO=property; Path=/");
|
||||||
|
setCookie("portfolio_session=keep; Path=/");
|
||||||
|
const gtag = jest.fn();
|
||||||
|
analyticsWindow.gtag = gtag;
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={openCookiePreferences}>
|
||||||
|
Preferências de cookies
|
||||||
|
</button>
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByTestId("google-analytics-script");
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
const opener = screen.getByRole("button", {
|
||||||
|
name: "Preferências de cookies",
|
||||||
|
});
|
||||||
|
await user.click(opener);
|
||||||
|
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
|
||||||
|
/Escolha atual: permitido/,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Aceitar" })).toHaveFocus();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
|
||||||
|
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
|
||||||
|
expect(consentUpdates(gtag)).toContainEqual([
|
||||||
|
"consent",
|
||||||
|
"update",
|
||||||
|
{
|
||||||
|
analytics_storage: "denied",
|
||||||
|
ad_storage: "denied",
|
||||||
|
ad_user_data: "denied",
|
||||||
|
ad_personalization: "denied",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(document.cookie).not.toContain("_ga=");
|
||||||
|
expect(document.cookie).not.toContain("_ga_PORTFOLIO=");
|
||||||
|
expect(document.cookie).toContain("portfolio_session=keep");
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("google-analytics-script"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(opener).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores focus after closing with Escape or applying opened preferences", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "denied");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={openCookiePreferences}>
|
||||||
|
Preferências de cookies
|
||||||
|
</button>
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
const opener = screen.getByRole("button", {
|
||||||
|
name: "Preferências de cookies",
|
||||||
|
});
|
||||||
|
await user.click(opener);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Política de privacidade" }),
|
||||||
|
).toHaveAttribute("href", "/privacidade");
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(opener).toHaveFocus();
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
|
||||||
|
|
||||||
|
await user.click(opener);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Aceitar" }));
|
||||||
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(opener).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when local storage is unavailable", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||||
|
throw new Error("storage blocked");
|
||||||
|
});
|
||||||
|
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||||
|
throw new Error("storage blocked");
|
||||||
|
});
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("google-analytics-script"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports when revocation cannot be persisted", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||||
|
throw new Error("storage blocked");
|
||||||
|
});
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
await screen.findByTestId("google-analytics-script");
|
||||||
|
act(() => openCookiePreferences());
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("alert", {
|
||||||
|
name: "Falha ao salvar o consentimento",
|
||||||
|
}),
|
||||||
|
).toHaveTextContent(
|
||||||
|
"Não foi possível salvar a revogação. Tente novamente para concluir.",
|
||||||
|
);
|
||||||
|
expect(analyticsWindow[GA_DISABLE_KEY]).not.toBe(true);
|
||||||
|
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports when refusal cannot be persisted", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||||
|
throw new Error("storage blocked");
|
||||||
|
});
|
||||||
|
render(<GoogleAnalytics />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("alert", {
|
||||||
|
name: "Falha ao salvar o consentimento",
|
||||||
|
}),
|
||||||
|
).toHaveTextContent(
|
||||||
|
"Não foi possível salvar sua recusa. Tente novamente para concluir.",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
|
||||||
|
expect(screen.getByRole("dialog")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,32 +1,295 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Script from 'next/script';
|
import Script from "next/script";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
type AnalyticsConsent,
|
||||||
|
CONSENT_STORAGE_KEY,
|
||||||
|
COOKIE_BAR_ID,
|
||||||
|
GA_DISABLE_KEY,
|
||||||
|
GA_MEASUREMENT_ID,
|
||||||
|
GOOGLE_ADS_ID,
|
||||||
|
type Gtag,
|
||||||
|
OPEN_COOKIE_PREFERENCES_EVENT,
|
||||||
|
readConsent,
|
||||||
|
} from "@/lib/analytics";
|
||||||
|
import "./conversion.css";
|
||||||
|
|
||||||
const GA_MEASUREMENT_ID = 'G-QTT9QT0T3S';
|
const GA_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
|
||||||
|
const SITE_DOMAIN = "evandro.dev.br";
|
||||||
|
|
||||||
|
const deniedConsent = {
|
||||||
|
analytics_storage: "denied",
|
||||||
|
ad_storage: "denied",
|
||||||
|
ad_user_data: "denied",
|
||||||
|
ad_personalization: "denied",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function persistConsent(value: AnalyticsConsent): boolean {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CONSENT_STORAGE_KEY, value);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGtag(): Gtag {
|
||||||
|
window.dataLayer ??= [];
|
||||||
|
const dataLayer = window.dataLayer;
|
||||||
|
if (!window.gtag) {
|
||||||
|
window.gtag = function gtag() {
|
||||||
|
// biome-ignore lint/complexity/noArguments: The official gtag bootstrap requires an IArguments entry, not an Array.
|
||||||
|
dataLayer.push(arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return window.gtag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAnalyticsLoader() {
|
||||||
|
document.getElementById("google-analytics-script")?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCookie(value: string) {
|
||||||
|
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store is not universal and domain-scoped GA cookies must also be expired.
|
||||||
|
document.cookie = value;
|
||||||
|
}
|
||||||
|
function expireAnalyticsCookies() {
|
||||||
|
const cookieNames = new Set(
|
||||||
|
document.cookie
|
||||||
|
.split(";")
|
||||||
|
.map((cookie) => cookie.split("=")[0]?.trim())
|
||||||
|
.filter(
|
||||||
|
(name): name is string =>
|
||||||
|
name === "_ga" || Boolean(name?.startsWith("_ga_")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { hostname } = window.location;
|
||||||
|
const domains =
|
||||||
|
hostname === SITE_DOMAIN || hostname.endsWith(`.${SITE_DOMAIN}`)
|
||||||
|
? new Set([hostname, SITE_DOMAIN])
|
||||||
|
: new Set<string>();
|
||||||
|
|
||||||
|
for (const name of cookieNames) {
|
||||||
|
const expiration = `${name}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax`;
|
||||||
|
writeCookie(expiration);
|
||||||
|
for (const domain of domains) {
|
||||||
|
writeCookie(`${expiration}; Domain=${domain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const barButtonClassName =
|
||||||
|
"cv-btn inline-flex min-h-10 min-w-11 items-center justify-center border px-[10px] py-sp-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] motion-reduce:transition-none";
|
||||||
|
|
||||||
|
function GoogleAnalyticsConsent() {
|
||||||
|
const [consent, setConsent] = useState<AnalyticsConsent | null>();
|
||||||
|
const [preferencesOpen, setPreferencesOpen] = useState(false);
|
||||||
|
const [consentError, setConsentError] = useState<string | null>(null);
|
||||||
|
const configuredRef = useRef(false);
|
||||||
|
const barRef = useRef<HTMLElement>(null);
|
||||||
|
const acceptRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const openerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const enableAnalytics = useCallback(() => {
|
||||||
|
const gtag = getGtag();
|
||||||
|
window[GA_DISABLE_KEY] = false;
|
||||||
|
if (!configuredRef.current) {
|
||||||
|
gtag("consent", "default", deniedConsent);
|
||||||
|
gtag("set", "ads_data_redaction", true);
|
||||||
|
gtag("js", new Date());
|
||||||
|
configuredRef.current = true;
|
||||||
|
}
|
||||||
|
gtag("consent", "update", {
|
||||||
|
...deniedConsent,
|
||||||
|
analytics_storage: "granted",
|
||||||
|
});
|
||||||
|
gtag("config", GA_MEASUREMENT_ID, {
|
||||||
|
allow_ad_personalization_signals: false,
|
||||||
|
anonymize_ip: true,
|
||||||
|
});
|
||||||
|
// One loader serves both destinations; GT IDs are aliases, not extra installs.
|
||||||
|
gtag("config", GOOGLE_ADS_ID, {
|
||||||
|
allow_ad_personalization_signals: false,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedConsent = readConsent();
|
||||||
|
if (storedConsent === "granted") enableAnalytics();
|
||||||
|
setConsent(storedConsent);
|
||||||
|
}, [enableAnalytics]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (consent !== "granted" && consent !== undefined) {
|
||||||
|
removeAnalyticsLoader();
|
||||||
|
}
|
||||||
|
}, [consent]);
|
||||||
|
|
||||||
|
// O link "Preferências de cookies" do rodapé reabre a barra; quem pediu recebe o foco de volta ao fechar.
|
||||||
|
useEffect(() => {
|
||||||
|
const reopen = () => {
|
||||||
|
openerRef.current =
|
||||||
|
document.activeElement instanceof HTMLElement
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
setPreferencesOpen(true);
|
||||||
|
};
|
||||||
|
window.addEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const barOpen = consent === null || preferencesOpen;
|
||||||
|
|
||||||
|
// Enquanto a barra está na tela, publica a altura dela para o botão flutuante do WhatsApp subir.
|
||||||
|
useEffect(() => {
|
||||||
|
const bar = barRef.current;
|
||||||
|
if (!barOpen || !bar) return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const publish = () =>
|
||||||
|
root.style.setProperty("--cookie-bar-height", `${bar.offsetHeight}px`);
|
||||||
|
publish();
|
||||||
|
root.setAttribute("data-cookie-bar", "open");
|
||||||
|
const observer = new ResizeObserver(publish);
|
||||||
|
observer.observe(bar);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
root.style.removeProperty("--cookie-bar-height");
|
||||||
|
root.removeAttribute("data-cookie-bar");
|
||||||
|
};
|
||||||
|
}, [barOpen]);
|
||||||
|
|
||||||
|
// Reaberta pelo rodapé, a barra está no começo do documento: leva o foco para ela.
|
||||||
|
useEffect(() => {
|
||||||
|
if (preferencesOpen) acceptRef.current?.focus();
|
||||||
|
}, [preferencesOpen]);
|
||||||
|
|
||||||
|
function closePreferences() {
|
||||||
|
setPreferencesOpen(false);
|
||||||
|
openerRef.current?.focus();
|
||||||
|
openerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptAnalytics() {
|
||||||
|
if (consent === "granted") {
|
||||||
|
closePreferences();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!persistConsent("granted")) {
|
||||||
|
setConsentError(
|
||||||
|
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConsentError(null);
|
||||||
|
enableAnalytics();
|
||||||
|
setConsent("granted");
|
||||||
|
closePreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
function denyAnalytics() {
|
||||||
|
const revoking = consent === "granted";
|
||||||
|
if (!persistConsent("denied")) {
|
||||||
|
setConsentError(
|
||||||
|
revoking
|
||||||
|
? "Não foi possível salvar a revogação. Tente novamente para concluir."
|
||||||
|
: "Não foi possível salvar sua recusa. Tente novamente para concluir.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConsentError(null);
|
||||||
|
if (revoking) {
|
||||||
|
window[GA_DISABLE_KEY] = true;
|
||||||
|
window.gtag?.("consent", "update", deniedConsent);
|
||||||
|
expireAnalyticsCookies();
|
||||||
|
}
|
||||||
|
setConsent("denied");
|
||||||
|
closePreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consent === undefined) return null;
|
||||||
|
|
||||||
export default function GoogleAnalytics() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Script
|
{consent === "granted" && process.env.NODE_ENV === "production" && (
|
||||||
src="/sys/libs/vendor-utils.js"
|
<Script
|
||||||
strategy="afterInteractive"
|
id="google-analytics-script"
|
||||||
/>
|
data-testid="google-analytics-script"
|
||||||
<Script id="google-analytics" strategy="afterInteractive">
|
src={GA_SCRIPT_SRC}
|
||||||
{`
|
strategy="afterInteractive"
|
||||||
window.dataLayer = window.dataLayer || [];
|
/>
|
||||||
function gtag(){dataLayer.push(arguments);}
|
)}
|
||||||
gtag('js', new Date());
|
{barOpen && (
|
||||||
|
<section
|
||||||
gtag('config', '${GA_MEASUREMENT_ID}', {
|
ref={barRef}
|
||||||
transport_url: window.location.origin + '/sys/api/vitals',
|
id={COOKIE_BAR_ID}
|
||||||
cookie_flags: 'SameSite=None;Secure',
|
role="dialog"
|
||||||
allow_ad_personalization_signals: false,
|
aria-modal="false"
|
||||||
anonymize_ip: true,
|
aria-label="Cookies e privacidade"
|
||||||
page_location: window.location.href,
|
aria-describedby="analytics-consent-description"
|
||||||
page_title: document.title
|
onKeyDown={(event) => {
|
||||||
});
|
if (event.key === "Escape" && preferencesOpen) closePreferences();
|
||||||
`}
|
}}
|
||||||
</Script>
|
className="fixed inset-x-0 bottom-0 z-[9980] border-t border-white/20 bg-[var(--ink)] px-sp-4 pt-sp-2 pb-[max(var(--sp-2),env(safe-area-inset-bottom))] text-white shadow-[0_-8px_24px_rgba(0,0,0,0.35)] sm:px-sp-6"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
|
||||||
|
<p
|
||||||
|
id="analytics-consent-description"
|
||||||
|
className="min-w-0 flex-1 font-display text-[15px] leading-[1.3] text-white/85"
|
||||||
|
>
|
||||||
|
Usamos métricas de navegação do Google só se você permitir.{" "}
|
||||||
|
<a
|
||||||
|
className="whitespace-nowrap underline underline-offset-2 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||||
|
href="/privacidade"
|
||||||
|
>
|
||||||
|
Política de privacidade
|
||||||
|
</a>
|
||||||
|
{preferencesOpen && consent && (
|
||||||
|
<span className="sr-only">
|
||||||
|
{" "}
|
||||||
|
Escolha atual:{" "}
|
||||||
|
{consent === "granted" ? "permitido" : "recusado"}.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex shrink-0 gap-sp-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={acceptRef}
|
||||||
|
className={`${barButtonClassName} border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] hover:bg-white`}
|
||||||
|
onClick={acceptAnalytics}
|
||||||
|
>
|
||||||
|
Aceitar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${barButtonClassName} border-white/45 bg-transparent text-white hover:border-white`}
|
||||||
|
onClick={denyAnalytics}
|
||||||
|
>
|
||||||
|
Recusar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{consentError && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
aria-label="Falha ao salvar o consentimento"
|
||||||
|
className="mx-auto mt-sp-1 max-w-[1100px] font-display text-[15px] leading-5 text-[#ff9d94]"
|
||||||
|
>
|
||||||
|
{consentError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A barra de cookies e o consentimento aparecem em qualquer ambiente: no desenvolvimento a página fica igual à da
|
||||||
|
* produção (ordem do Tab, altura da barra, rodapé). Só o carregamento do gtag (o <Script> acima) fica preso à produção.
|
||||||
|
*/
|
||||||
|
export default function GoogleAnalytics() {
|
||||||
|
return <GoogleAnalyticsConsent />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment-options {"url": "https://www.evandro.dev.br/"}
|
||||||
|
*/
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { openCookiePreferences } from "@/lib/analytics";
|
||||||
|
import GoogleAnalytics from "./GoogleAnalytics";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caminho de GoogleAnalytics (expireAnalyticsCookies) quando o site roda em um subdomínio de evandro.dev.br:
|
||||||
|
* o cookie _ga é expirado no host e também no domínio raiz, cada um com seu atributo Domain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/script", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
strategy: _strategy,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CONSENT_KEY = "evandro.analytics-consent.v1";
|
||||||
|
|
||||||
|
function setCookie(value: string) {
|
||||||
|
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
|
||||||
|
document.cookie = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||||
|
localStorage.setItem(CONSENT_KEY, "granted");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: host is a subdomain of the site -> expires _ga on the path, on the host and on the root domain", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
expect(window.location.hostname).toBe("www.evandro.dev.br");
|
||||||
|
setCookie("_ga=1; Path=/");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={openCookiePreferences}>
|
||||||
|
Preferências de cookies
|
||||||
|
</button>
|
||||||
|
<GoogleAnalytics />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await screen.findByTestId("google-analytics-script");
|
||||||
|
const cookieWrites = jest.spyOn(document, "cookie", "set");
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
||||||
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
||||||
|
|
||||||
|
const writes = cookieWrites.mock.calls.map(([value]) => String(value));
|
||||||
|
expect(writes).toHaveLength(3);
|
||||||
|
expect(writes[0]).toMatch(/^_ga=; Max-Age=0;/);
|
||||||
|
expect(writes[0]).not.toContain("Domain=");
|
||||||
|
expect(writes[1]).toMatch(/; Domain=www\.evandro\.dev\.br$/);
|
||||||
|
expect(writes[2]).toMatch(/; Domain=evandro\.dev\.br$/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { COOKIE_BAR_ID } from "@/lib/analytics";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O menu do celular aberto (complemento de Header.test.tsx): botão de contato acima da barra de cookies, fecha ao
|
||||||
|
* trocar de rota e ao tocar no logo, deixa o conteúdo e o rodapé inertes, trava a rolagem, prende o foco e fecha
|
||||||
|
* com Esc (ouvido no document), devolvendo o foco ao botão do menu.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** O cabeçalho com as duas partes da página que o menu torna inertes: o conteúdo e o rodapé (ids do layout). */
|
||||||
|
function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<div id="main-content" tabIndex={-1}>
|
||||||
|
<button type="button">Botão do conteúdo</button>
|
||||||
|
</div>
|
||||||
|
<div id="site-footer">
|
||||||
|
<a href="#privacidade">Privacidade</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = () => document.getElementById("main-content") as HTMLElement;
|
||||||
|
const footer = () => document.getElementById("site-footer") as HTMLElement;
|
||||||
|
const menuButton = () =>
|
||||||
|
screen.getByRole("button", { name: /^(Abrir|Fechar) menu$/ });
|
||||||
|
const openMenu = () =>
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||||
|
const mobileMenu = () =>
|
||||||
|
screen.queryByRole("navigation", { name: "Navegação mobile" });
|
||||||
|
|
||||||
|
/** Os controles que o Tab percorre com o menu aberto: logo, botão do menu, os cinco links e o botão de contato do painel. */
|
||||||
|
function trapOrder() {
|
||||||
|
const panel = within(mobileMenu() as HTMLElement);
|
||||||
|
return [
|
||||||
|
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
|
||||||
|
menuButton(),
|
||||||
|
...panel.getAllByRole("link"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
document.documentElement.style.removeProperty("overflow");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.documentElement.style.removeProperty("overflow");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Header: menu do celular e a barra de cookies", () => {
|
||||||
|
it("adds the height of the cookie bar to the bottom spacing, so the contact button stays above it", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
|
||||||
|
const panel = (mobileMenu() as HTMLElement).parentElement
|
||||||
|
?.parentElement as HTMLElement;
|
||||||
|
expect(panel.className).toContain(
|
||||||
|
"pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Header: o menu fecha sozinho", () => {
|
||||||
|
it("closes when the route changes", () => {
|
||||||
|
const { rerender } = render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
expect(mobileMenu()).toBeInTheDocument();
|
||||||
|
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/cases");
|
||||||
|
rerender(<Page />);
|
||||||
|
|
||||||
|
expect(mobileMenu()).toBeNull();
|
||||||
|
expect(menuButton()).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(main()).not.toHaveAttribute("inert");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays open on a re-render that keeps the same route", () => {
|
||||||
|
const { rerender } = render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
|
||||||
|
rerender(<Page />);
|
||||||
|
|
||||||
|
expect(mobileMenu()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when the logo is tapped", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mobileMenu()).toBeNull();
|
||||||
|
expect(main()).not.toHaveAttribute("inert");
|
||||||
|
expect(document.documentElement.style.overflow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when the window grows to the desktop layout, where the menu button no longer exists", () => {
|
||||||
|
const original = window.matchMedia;
|
||||||
|
let onChange: ((event: { matches: boolean }) => void) | undefined;
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: (_type: string, listener: typeof onChange) => {
|
||||||
|
onChange = listener;
|
||||||
|
},
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
expect(window.matchMedia).toHaveBeenCalledWith("(min-width: 1280px)");
|
||||||
|
|
||||||
|
act(() => onChange?.({ matches: false }));
|
||||||
|
expect(mobileMenu()).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => onChange?.({ matches: true }));
|
||||||
|
expect(mobileMenu()).toBeNull();
|
||||||
|
expect(document.documentElement.style.overflow).toBe("");
|
||||||
|
} finally {
|
||||||
|
window.matchMedia = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Header: página inerte e sem rolagem com o menu aberto", () => {
|
||||||
|
it("makes the content and the footer inert and locks the page scroll while the menu is open, and undoes both on close", () => {
|
||||||
|
render(<Page />);
|
||||||
|
expect(main()).not.toHaveAttribute("inert");
|
||||||
|
|
||||||
|
openMenu();
|
||||||
|
expect(main()).toHaveAttribute("inert");
|
||||||
|
expect(footer()).toHaveAttribute("inert");
|
||||||
|
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||||
|
// o próprio cabeçalho nunca fica inerte
|
||||||
|
expect(screen.getByRole("banner")).not.toHaveAttribute("inert");
|
||||||
|
|
||||||
|
fireEvent.click(menuButton());
|
||||||
|
expect(main()).not.toHaveAttribute("inert");
|
||||||
|
expect(footer()).not.toHaveAttribute("inert");
|
||||||
|
expect(document.documentElement.style.overflow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives back the overflow the page already had", () => {
|
||||||
|
document.documentElement.style.overflow = "scroll";
|
||||||
|
render(<Page />);
|
||||||
|
|
||||||
|
openMenu();
|
||||||
|
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||||
|
fireEvent.click(menuButton());
|
||||||
|
|
||||||
|
expect(document.documentElement.style.overflow).toBe("scroll");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works on a page without a footer wrapper (the parts that exist are still made inert)", () => {
|
||||||
|
render(<Header />);
|
||||||
|
document.body.insertAdjacentHTML(
|
||||||
|
"beforeend",
|
||||||
|
'<div id="main-content"></div>',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
openMenu();
|
||||||
|
expect(main()).toHaveAttribute("inert");
|
||||||
|
} finally {
|
||||||
|
main().remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes everything when the header unmounts with the menu open", () => {
|
||||||
|
const { unmount } = render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(document.documentElement.style.overflow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the page before any other click handler of the document runs, so a link to #id finds its target focusable", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
const states: Array<{ inert: boolean; overflow: string }> = [];
|
||||||
|
const record = () =>
|
||||||
|
states.push({
|
||||||
|
inert: main().hasAttribute("inert"),
|
||||||
|
overflow: document.documentElement.style.overflow,
|
||||||
|
});
|
||||||
|
// o gerenciador de âncoras da home escuta o clique no document, na fase de captura
|
||||||
|
document.addEventListener("click", record, true);
|
||||||
|
try {
|
||||||
|
fireEvent.click(
|
||||||
|
within(mobileMenu() as HTMLElement).getByRole("link", {
|
||||||
|
name: "Sobre",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
document.removeEventListener("click", record, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(states).toEqual([{ inert: false, overflow: "" }]);
|
||||||
|
expect(mobileMenu()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not release the page for a click outside the header", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
|
||||||
|
// com o conteúdo inerte o clique real nem chega; o teste dispara direto para provar que o ouvinte só reage ao cabeçalho
|
||||||
|
fireEvent.click(footer().querySelector("a") as HTMLElement);
|
||||||
|
|
||||||
|
expect(main()).toHaveAttribute("inert");
|
||||||
|
expect(mobileMenu()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Header: Esc e foco com o menu aberto", () => {
|
||||||
|
it("closes with Escape heard on the document, wherever the focus is, and puts the focus back on the menu button", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
act(() => document.body.focus());
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(false);
|
||||||
|
|
||||||
|
expect(mobileMenu()).toBeNull();
|
||||||
|
expect(main()).not.toHaveAttribute("inert");
|
||||||
|
expect(menuButton()).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(menuButton()).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores Escape while the menu is closed", () => {
|
||||||
|
render(<Page />);
|
||||||
|
|
||||||
|
// sem menu aberto o ouvinte nem existe: o evento segue sem ser cancelado
|
||||||
|
expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(true);
|
||||||
|
expect(mobileMenu()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("traps Tab: from the last control it goes to the first, and Shift+Tab from the first goes to the last", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
const order = trapOrder();
|
||||||
|
const first = order[0];
|
||||||
|
const last = order[order.length - 1];
|
||||||
|
// o botão de contato do painel é o último; o da barra de desktop e a navegação de desktop ficam fora do ciclo
|
||||||
|
expect(last).toHaveAccessibleName("Falar sobre meu projeto");
|
||||||
|
expect(order).toHaveLength(1 + 1 + 5 + 1);
|
||||||
|
|
||||||
|
act(() => last.focus());
|
||||||
|
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
|
||||||
|
expect(first).toHaveFocus();
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(first, { key: "Tab", shiftKey: true })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(last).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets Tab move normally between two controls of the cycle", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
const order = trapOrder();
|
||||||
|
act(() => order[2].focus());
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(order[2], { key: "Tab" })).toBe(true);
|
||||||
|
expect(fireEvent.keyDown(order[2], { key: "Tab", shiftKey: true })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("brings the focus back into the cycle when it is outside it, in the direction of the key", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
const order = trapOrder();
|
||||||
|
act(() => document.body.focus());
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(false);
|
||||||
|
expect(order[0]).toHaveFocus();
|
||||||
|
|
||||||
|
act(() => document.body.focus());
|
||||||
|
expect(
|
||||||
|
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(order[order.length - 1]).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not trap Tab while the menu is closed", () => {
|
||||||
|
render(<Page />);
|
||||||
|
act(() => document.body.focus());
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes with the menu button and keeps the focus on it", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
act(() => menuButton().focus());
|
||||||
|
|
||||||
|
fireEvent.click(menuButton());
|
||||||
|
|
||||||
|
expect(mobileMenu()).toBeNull();
|
||||||
|
expect(menuButton()).toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Header: a barra de cookies aberta entra na armadilha de foco", () => {
|
||||||
|
/** Como no layout (GoogleAnalytics.tsx): a barra vem antes do cabeçalho e não fica inerte; link da política, Aceitar e Recusar. */
|
||||||
|
function PageWithCookieBar() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section id={COOKIE_BAR_ID} role="dialog" aria-label="Cookies">
|
||||||
|
<p>
|
||||||
|
Usamos métricas de navegação do Google só se você permitir.{" "}
|
||||||
|
<a href="/privacidade">Política de privacidade</a>
|
||||||
|
</p>
|
||||||
|
<button type="button">Aceitar</button>
|
||||||
|
<button type="button">Recusar</button>
|
||||||
|
</section>
|
||||||
|
<Page />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("cycles through the policy link, Aceitar and Recusar before the header, and back from the contact button", () => {
|
||||||
|
render(<PageWithCookieBar />);
|
||||||
|
openMenu();
|
||||||
|
const policy = screen.getByRole("link", {
|
||||||
|
name: "Política de privacidade",
|
||||||
|
});
|
||||||
|
const accept = screen.getByRole("button", { name: "Aceitar" });
|
||||||
|
const decline = screen.getByRole("button", { name: "Recusar" });
|
||||||
|
const order = trapOrder();
|
||||||
|
const last = order[order.length - 1];
|
||||||
|
expect(policy).not.toHaveAttribute("inert");
|
||||||
|
expect(accept).not.toHaveAttribute("inert");
|
||||||
|
|
||||||
|
// do botão de contato (o último do cabeçalho) o Tab vai para o link da política, em vez de dar a volta no cabeçalho
|
||||||
|
act(() => last.focus());
|
||||||
|
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
|
||||||
|
expect(policy).toHaveFocus();
|
||||||
|
|
||||||
|
// entre o link, Aceitar e Recusar o Tab segue normal; de Recusar vai para o logo, o primeiro do cabeçalho
|
||||||
|
expect(fireEvent.keyDown(policy, { key: "Tab" })).toBe(true);
|
||||||
|
act(() => accept.focus());
|
||||||
|
expect(fireEvent.keyDown(accept, { key: "Tab" })).toBe(true);
|
||||||
|
act(() => decline.focus());
|
||||||
|
expect(fireEvent.keyDown(decline, { key: "Tab" })).toBe(true);
|
||||||
|
|
||||||
|
// Shift+Tab do link da política (o primeiro do ciclo) volta ao botão de contato
|
||||||
|
act(() => policy.focus());
|
||||||
|
expect(fireEvent.keyDown(policy, { key: "Tab", shiftKey: true })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(last).toHaveFocus();
|
||||||
|
|
||||||
|
// e Shift+Tab de Aceitar ou de Recusar não é preso: o navegador anda um controle para trás
|
||||||
|
act(() => accept.focus());
|
||||||
|
expect(fireEvent.keyDown(accept, { key: "Tab", shiftKey: true })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes with Escape and puts the focus back on the menu button, even from the cookie bar", () => {
|
||||||
|
render(<PageWithCookieBar />);
|
||||||
|
openMenu();
|
||||||
|
act(() => screen.getByRole("button", { name: "Recusar" }).focus());
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(mobileMenu()).toBeNull();
|
||||||
|
expect(menuButton()).toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de Header (complemento de Header.test.tsx): a barra que escurece com a rolagem,
|
||||||
|
* as teclas do menu do celular que não o fecham e a rota ausente (fora do roteador).
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function setScrollY(value: number) {
|
||||||
|
Object.defineProperty(window, "scrollY", {
|
||||||
|
value,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = () => screen.getByRole("banner");
|
||||||
|
/** Rolada: borda visível e fundo opaco (sem backdrop-blur: o filtro refazia a pintura a cada quadro da rolagem). */
|
||||||
|
const isDark = () => header().className.includes("border-white/12");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
setScrollY(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
setScrollY(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Header: rolagem", () => {
|
||||||
|
it("path: page at the top -> transparent border and solid background", () => {
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
expect(isDark()).toBe(false);
|
||||||
|
expect(header()).toHaveClass("border-transparent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: scrolling down and back to the top -> bordered bar, then the plain one again, never with a backdrop blur", () => {
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
setScrollY(120);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
expect(isDark()).toBe(true);
|
||||||
|
expect(header()).not.toHaveClass("border-transparent");
|
||||||
|
expect(header().className).not.toContain("backdrop-blur");
|
||||||
|
|
||||||
|
// outro evento de rolagem sem mudar de lado: nada muda
|
||||||
|
setScrollY(300);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
expect(isDark()).toBe(true);
|
||||||
|
|
||||||
|
setScrollY(0);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
expect(isDark()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the background is fully opaque at the top and scrolled (no 95% fill: the titles showed through)", () => {
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
expect(header().className).toContain("bg-[var(--ink)]");
|
||||||
|
expect(header().className).not.toContain("bg-[var(--ink)]/");
|
||||||
|
|
||||||
|
setScrollY(120);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
expect(header().className).toContain("bg-[var(--ink)]");
|
||||||
|
expect(header().className).not.toContain("bg-[var(--ink)]/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: page already scrolled when the header mounts -> starts with the bordered bar", () => {
|
||||||
|
setScrollY(500);
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
expect(isDark()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmount -> the scroll listener is removed", () => {
|
||||||
|
const remove = jest.spyOn(window, "removeEventListener");
|
||||||
|
const { unmount } = render(<Header />);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Header: ícone do menu", () => {
|
||||||
|
it("path: open -> the outer bars move 10px (2px bar + 8px gap) to the middle and turn into a straight X; closed -> nothing moves", () => {
|
||||||
|
render(<Header />);
|
||||||
|
const button = screen.getByRole("button", { name: "Abrir menu" });
|
||||||
|
const bars = () =>
|
||||||
|
Array.from(button.querySelectorAll("span")) as HTMLElement[];
|
||||||
|
|
||||||
|
// 2 px de altura + 8 px de espaço entre as linhas = 10 px de centro a centro
|
||||||
|
expect(bars()[0]).toHaveClass("h-0.5");
|
||||||
|
expect(button).toHaveClass("gap-sp-2");
|
||||||
|
for (const bar of bars()) expect(bar.className).not.toMatch(/translate-y/);
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
const open = bars();
|
||||||
|
expect(open).toHaveLength(3);
|
||||||
|
expect(open[0]).toHaveClass("translate-y-[10px]", "rotate-45");
|
||||||
|
expect(open[1]).toHaveClass("opacity-0");
|
||||||
|
expect(open[2]).toHaveClass("-translate-y-[10px]", "-rotate-45");
|
||||||
|
expect(open[0].className).not.toContain("6px");
|
||||||
|
expect(open[2].className).not.toContain("6px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Header: teclado no menu do celular", () => {
|
||||||
|
it("path: a key other than Escape inside the open menu -> the menu stays open and the key is not blocked", () => {
|
||||||
|
render(<Header />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||||
|
const menu = screen.getByRole("navigation", { name: "Navegação mobile" });
|
||||||
|
// Tab entre dois controles do meio da lista: segue o caminho normal (o foco só é preso nas pontas)
|
||||||
|
const middle = within(menu).getByRole("link", { name: "Serviços" });
|
||||||
|
act(() => middle.focus());
|
||||||
|
|
||||||
|
// fireEvent devolve false só quando o evento foi cancelado (preventDefault)
|
||||||
|
expect(fireEvent.keyDown(middle, { key: "Tab" })).toBe(true);
|
||||||
|
expect(fireEvent.keyDown(menu, { key: "Enter" })).toBe(true);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
|
||||||
|
"aria-expanded",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: Escape on the menu button while the menu is open -> closes it and keeps the focus on the button", () => {
|
||||||
|
render(<Header />);
|
||||||
|
const button = screen.getByRole("button", { name: "Abrir menu" });
|
||||||
|
fireEvent.click(button);
|
||||||
|
act(() => button.focus());
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(button, { key: "Escape" })).toBe(false);
|
||||||
|
|
||||||
|
const closed = screen.getByRole("button", { name: "Abrir menu" });
|
||||||
|
expect(closed).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(closed).toHaveFocus();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: Escape while the menu is already closed -> nothing opens", () => {
|
||||||
|
render(<Header />);
|
||||||
|
const button = screen.getByRole("button", { name: "Abrir menu" });
|
||||||
|
|
||||||
|
fireEvent.keyDown(button, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Header: menu aberto, caminhos de borda do foco, do clique e da janela", () => {
|
||||||
|
/** O cabeçalho com o conteúdo da página que o menu aberto torna inerte (id do layout). */
|
||||||
|
function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<div id="main-content" tabIndex={-1}>
|
||||||
|
<button type="button">Botão do conteúdo</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const openMenu = () =>
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||||
|
const content = () => document.getElementById("main-content") as HTMLElement;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.documentElement.style.removeProperty("overflow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: Tab with no control left to focus in the header -> the key is not blocked and the focus does not move", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
const banner = screen.getByRole("banner");
|
||||||
|
for (const button of banner.querySelectorAll("button"))
|
||||||
|
button.setAttribute("disabled", "");
|
||||||
|
for (const link of banner.querySelectorAll("a"))
|
||||||
|
link.removeAttribute("href");
|
||||||
|
act(() => document.body.focus());
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(true);
|
||||||
|
expect(
|
||||||
|
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true }),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(document.body).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: click whose target is not an element (the document itself) -> the page stays locked", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
|
||||||
|
fireEvent.click(document);
|
||||||
|
|
||||||
|
expect(content()).toHaveAttribute("inert");
|
||||||
|
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: click on something inside the header that is not a link -> the page stays locked", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content()).toHaveAttribute("inert");
|
||||||
|
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: click on a link inside the header -> the page is released at once", () => {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
within(
|
||||||
|
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).getByRole("link", { name: "Cases" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content()).not.toHaveAttribute("inert");
|
||||||
|
expect(document.documentElement.style.overflow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no matchMedia at all in the browser -> the menu still opens and closes", () => {
|
||||||
|
const original = window.matchMedia;
|
||||||
|
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||||
|
try {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Fechar menu" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(content()).not.toHaveAttribute("inert");
|
||||||
|
} finally {
|
||||||
|
window.matchMedia = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: matchMedia returns an object with no change listener (older browsers) -> the menu still opens and closes", () => {
|
||||||
|
const original = window.matchMedia;
|
||||||
|
window.matchMedia = jest.fn().mockReturnValue({
|
||||||
|
matches: false,
|
||||||
|
}) as unknown as typeof window.matchMedia;
|
||||||
|
try {
|
||||||
|
render(<Page />);
|
||||||
|
openMenu();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Fechar menu" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(content()).not.toHaveAttribute("inert");
|
||||||
|
} finally {
|
||||||
|
window.matchMedia = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Header: rota ausente", () => {
|
||||||
|
it("path: usePathname gives null (outside the router) -> the header button goes to the home form", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue(null as unknown as string);
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
|
||||||
|
).toHaveAttribute("href", "/#contact");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import type { WhatsAppContexts } from "@/lib/whatsapp";
|
||||||
|
import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-next-link="" data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const contexts: WhatsAppContexts = {
|
||||||
|
// o whatsappSubject do serviço (lib/site-content.ts): uma frase em minúsculas, que cabe em "Quero conversar sobre {assunto}."
|
||||||
|
services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" },
|
||||||
|
cases: { "sev-modaintima": "SEV Moda Íntima" },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
function whatsAppHref(link: HTMLElement) {
|
||||||
|
return new URL(link.getAttribute("href") ?? "").searchParams.get("text");
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeWithHeader() {
|
||||||
|
const onLayoutReady = useHomeFragmentNavigation();
|
||||||
|
useEffect(() => {
|
||||||
|
onLayoutReady();
|
||||||
|
}, [onLayoutReady]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<section id="about" aria-label="Sobre">
|
||||||
|
<h2>Sobre</h2>
|
||||||
|
</section>
|
||||||
|
<section id="contact" aria-label="Contato">
|
||||||
|
<h2>Contato</h2>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Header", () => {
|
||||||
|
it("links to commercial destinations without promoting the résumé", () => {
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const navigation = screen.getByRole("navigation", {
|
||||||
|
name: "Navegação principal",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
within(navigation)
|
||||||
|
.getAllByRole("link")
|
||||||
|
.map((link) => link.getAttribute("href")),
|
||||||
|
).toEqual(["/cases", "/servicos", "/#process", "/#about", "/ia"]);
|
||||||
|
expect(
|
||||||
|
within(navigation)
|
||||||
|
.getAllByRole("link")
|
||||||
|
.map((link) => link.textContent),
|
||||||
|
).toEqual(["Cases", "Serviços", "Como trabalho", "Sobre", "IA"]);
|
||||||
|
// "Projetos" saiu: duplicava "Cases"
|
||||||
|
expect(
|
||||||
|
within(navigation).queryByRole("link", { name: "Projetos" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(navigation).queryByRole("link", { name: "Experiência" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
const contactLink = screen
|
||||||
|
.getAllByRole("link")
|
||||||
|
.find((link) => link.getAttribute("href") === "/#contact");
|
||||||
|
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", { name: /currículo|download cv/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces and controls the mobile menu", () => {
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const menuButton = screen.getByRole("button", { name: "Abrir menu" });
|
||||||
|
expect(menuButton).not.toHaveAttribute("aria-controls");
|
||||||
|
expect(menuButton).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
|
||||||
|
"aria-expanded",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
const mobileNavigation = screen.getByRole("navigation", {
|
||||||
|
name: "Navegação mobile",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
document.getElementById(menuButton.getAttribute("aria-controls") ?? ""),
|
||||||
|
).toBe(mobileNavigation);
|
||||||
|
expect(
|
||||||
|
within(mobileNavigation)
|
||||||
|
.getAllByRole("link")
|
||||||
|
.map((link) => link.getAttribute("href")),
|
||||||
|
).toEqual([
|
||||||
|
"/cases",
|
||||||
|
"/servicos",
|
||||||
|
"/#process",
|
||||||
|
"/#about",
|
||||||
|
"/ia",
|
||||||
|
"/#contact",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
within(mobileNavigation).queryByRole("link", {
|
||||||
|
name: /currículo|download cv/i,
|
||||||
|
}),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const mobileLink = within(mobileNavigation).getByRole("link", {
|
||||||
|
name: "Sobre",
|
||||||
|
});
|
||||||
|
act(() => mobileLink.focus());
|
||||||
|
fireEvent.keyDown(mobileLink, { key: "Escape" });
|
||||||
|
const openButton = screen.getByRole("button", { name: "Abrir menu" });
|
||||||
|
expect(openButton).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(document.activeElement).toBe(openButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["/#about", "Sobre"],
|
||||||
|
["/#contact", "Contato"],
|
||||||
|
])(
|
||||||
|
"closes the mobile menu and preserves destination focus for %s",
|
||||||
|
(href, destination) => {
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
render(<HomeWithHeader />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||||
|
const mobileNavigation = screen.getByRole("navigation", {
|
||||||
|
name: "Navegação mobile",
|
||||||
|
});
|
||||||
|
const mobileLink = within(mobileNavigation)
|
||||||
|
.getAllByRole("link")
|
||||||
|
.find((link) => link.getAttribute("href") === href);
|
||||||
|
if (!mobileLink)
|
||||||
|
throw new Error(`Missing navigation destination: ${href}`);
|
||||||
|
act(() => mobileLink.focus());
|
||||||
|
fireEvent.click(mobileLink);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Abrir menu" }),
|
||||||
|
).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("region", { name: destination })).toHaveFocus();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("makes the mobile menu a full-screen opaque panel with large links and the contact button at the bottom", () => {
|
||||||
|
render(<Header />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||||
|
|
||||||
|
const mobileNavigation = screen.getByRole("navigation", {
|
||||||
|
name: "Navegação mobile",
|
||||||
|
});
|
||||||
|
const panel = mobileNavigation.parentElement?.parentElement as HTMLElement;
|
||||||
|
expect(panel).toHaveClass("bg-[var(--ink)]");
|
||||||
|
expect(panel.className).toMatch(/h-\[calc\(100dvh-/);
|
||||||
|
const links = within(mobileNavigation).getAllByRole("link");
|
||||||
|
for (const link of links.slice(0, 5)) {
|
||||||
|
expect(link).toHaveClass("text-[22px]", "min-h-14");
|
||||||
|
}
|
||||||
|
const contact = links[5];
|
||||||
|
expect(contact).toHaveAccessibleName("Falar sobre meu projeto");
|
||||||
|
expect(contact.parentElement).toHaveClass("mt-auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses next/link for every internal link and only prefetches on hover or focus", () => {
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const navigation = screen.getByRole("navigation", {
|
||||||
|
name: "Navegação principal",
|
||||||
|
});
|
||||||
|
const internal = [
|
||||||
|
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
|
||||||
|
...within(navigation).getAllByRole("link"),
|
||||||
|
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
|
||||||
|
];
|
||||||
|
for (const link of internal) {
|
||||||
|
expect(link).toHaveAttribute("data-next-link");
|
||||||
|
expect(link.getAttribute("href")?.startsWith("/")).toBe(true);
|
||||||
|
}
|
||||||
|
// cabeçalho e logotipo: nada pré-carregado na abertura da página
|
||||||
|
const headerLinks = internal.slice(0, 1 + 5);
|
||||||
|
for (const link of headerLinks) {
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "false");
|
||||||
|
}
|
||||||
|
fireEvent.mouseEnter(headerLinks[1]);
|
||||||
|
expect(headerLinks[1]).toHaveAttribute("data-prefetch", "null");
|
||||||
|
expect(headerLinks[2]).toHaveAttribute("data-prefetch", "false");
|
||||||
|
fireEvent.focus(headerLinks[3]);
|
||||||
|
expect(headerLinks[3]).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the header button to the home form on the home and on pages without a subject", () => {
|
||||||
|
for (const pathname of ["/", "/privacidade"]) {
|
||||||
|
jest.mocked(usePathname).mockReturnValue(pathname);
|
||||||
|
const { unmount } = render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
|
const contact = screen.getAllByRole("link", {
|
||||||
|
name: "Falar sobre meu projeto",
|
||||||
|
});
|
||||||
|
expect(contact).toHaveLength(1);
|
||||||
|
expect(contact[0]).toHaveAttribute("href", "/#contact");
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
|
||||||
|
[
|
||||||
|
"/cases",
|
||||||
|
"Quero conversar sobre um projeto parecido com os cases do portfólio.",
|
||||||
|
],
|
||||||
|
["/servicos", "Quero conversar sobre sites, sistemas e aplicativos."],
|
||||||
|
[
|
||||||
|
"/curriculo",
|
||||||
|
"Quero conversar sobre um projeto, depois de ver o seu currículo.",
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"opens WhatsApp with the page subject in the header button on %s",
|
||||||
|
(pathname, sentence) => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue(pathname);
|
||||||
|
render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
|
const contact = screen.getByRole("link", {
|
||||||
|
name: "Falar sobre meu projeto",
|
||||||
|
});
|
||||||
|
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
||||||
|
expect(contact).toHaveAttribute("target", "_blank");
|
||||||
|
expect(whatsAppHref(contact)).toContain(sentence);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("opens WhatsApp with the service in the header button on a service page", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
|
||||||
|
render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
|
const contact = screen.getByRole("link", {
|
||||||
|
name: "Falar sobre meu projeto",
|
||||||
|
});
|
||||||
|
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
||||||
|
expect(contact).toHaveAttribute("target", "_blank");
|
||||||
|
expect(whatsAppHref(contact)).toContain(
|
||||||
|
"Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
|
||||||
|
);
|
||||||
|
expect(whatsAppHref(contact)).not.toContain("sobre Sites");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens WhatsApp with the case in the header button on a case page, also from the mobile menu", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/cases/sev-modaintima");
|
||||||
|
render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||||
|
const buttons = screen.getAllByRole("link", {
|
||||||
|
name: "Falar sobre meu projeto",
|
||||||
|
});
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
for (const button of buttons) {
|
||||||
|
expect(whatsAppHref(button)).toContain(
|
||||||
|
"Quero conversar sobre um projeto parecido com o case SEV Moda Íntima.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// abrir o WhatsApp pelo menu fecha o menu
|
||||||
|
fireEvent.click(buttons[1]);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a generic message on a service page the contexts do not know", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/servicos/inexistente");
|
||||||
|
render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
whatsAppHref(
|
||||||
|
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
|
||||||
|
),
|
||||||
|
).toContain("Quero conversar sobre um projeto.");
|
||||||
|
});
|
||||||
|
});
|
||||||
+281
-152
@@ -1,171 +1,300 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import { usePathname } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { MenuIcon, XIcon } from "lucide-react";
|
import { COOKIE_BAR_ID } from "@/lib/analytics";
|
||||||
import Image from "next/image";
|
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
|
headerOpensWhatsApp,
|
||||||
|
type WhatsAppContexts,
|
||||||
|
whatsAppSubjectForPath,
|
||||||
|
} from "@/lib/whatsapp";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
|
||||||
// Definição de Tipos para consistência de dados
|
const MENU_ID = "site-navigation-menu";
|
||||||
interface NavItem {
|
const CTA_LABEL = "Falar sobre meu projeto";
|
||||||
href: string;
|
/** Ids que ficam inertes com o menu aberto: o conteúdo da página e o rodapé (o id do rodapé vem de app/layout.tsx). */
|
||||||
label: string;
|
const INERT_IDS = ["main-content", "site-footer"];
|
||||||
|
/** A partir do breakpoint xl a barra mostra os links e o menu do celular deixa de existir. */
|
||||||
|
const DESKTOP_QUERY = "(min-width: 1280px)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controles que o Tab percorre com o menu aberto: todos os da barra de cookies, quando ela está aberta (o link "Política
|
||||||
|
* de privacidade", Aceitar e Recusar; ela vem antes do cabeçalho no layout e não fica inerte), e depois logo, botão do
|
||||||
|
* menu, links e botão de contato do painel.
|
||||||
|
* A navegação e o botão da barra de desktop ficam de fora (data-menu-trap-skip): estão escondidos no celular.
|
||||||
|
*/
|
||||||
|
function menuFocusables(header: HTMLElement) {
|
||||||
|
const cookieBar = document.getElementById(COOKIE_BAR_ID);
|
||||||
|
const roots = cookieBar ? [cookieBar, header] : [header];
|
||||||
|
return roots
|
||||||
|
.flatMap((root) =>
|
||||||
|
Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>("a[href], button:not([disabled])"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((element) => !element.closest("[data-menu-trap-skip]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
/** Tab (e Shift+Tab) com o menu aberto: o foco dá a volta entre o cabeçalho e a barra de cookies, sem sair para a página inerte. */
|
||||||
{ href: "#home", label: "Home" },
|
function trapTab(event: KeyboardEvent, header: HTMLElement) {
|
||||||
{ href: "#about", label: "Sobre" },
|
const focusables = menuFocusables(header);
|
||||||
{ href: "#skills", label: "Habilidades" },
|
const first = focusables[0];
|
||||||
{ href: "#experience", label: "Experiência" },
|
const last = focusables[focusables.length - 1];
|
||||||
{ href: "#projects", label: "Projetos" },
|
const [edge, opposite] = event.shiftKey ? [first, last] : [last, first];
|
||||||
{ href: "#contact", label: "Contato" },
|
const active = document.activeElement;
|
||||||
];
|
const leaving =
|
||||||
|
active === edge || !focusables.some((element) => element === active);
|
||||||
|
if (!leaving || !opposite) return;
|
||||||
|
|
||||||
export function Header() {
|
event.preventDefault();
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
opposite.focus();
|
||||||
const [scrolled, setScrolled] = useState(false);
|
}
|
||||||
|
|
||||||
// Hook de detecção de scroll para mudança de estado do Header
|
/**
|
||||||
useEffect(() => {
|
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
|
||||||
const handleScroll = () => {
|
* já com o assunto da página; na home e nas demais páginas leva ao formulário da home.
|
||||||
setScrolled(window.scrollY > 20);
|
*/
|
||||||
};
|
function ContactCta({
|
||||||
window.addEventListener("scroll", handleScroll);
|
className,
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
placement,
|
||||||
}, []);
|
viaWhatsApp,
|
||||||
|
subject,
|
||||||
return (
|
onNavigate,
|
||||||
<>
|
}: {
|
||||||
<motion.header
|
className: string;
|
||||||
initial={{ y: -100 }}
|
placement: string;
|
||||||
animate={{ y: 0 }}
|
viaWhatsApp: boolean;
|
||||||
className={cn(
|
subject?: string;
|
||||||
"fixed top-0 left-0 right-0 z-[100] transition-all duration-500 ease-in-out px-6 py-4",
|
onNavigate?: () => void;
|
||||||
scrolled
|
}) {
|
||||||
? "bg-black/40 backdrop-blur-xl border-b border-white/5 py-3"
|
if (viaWhatsApp) {
|
||||||
: "bg-transparent py-6"
|
return (
|
||||||
)}
|
<WhatsAppLink
|
||||||
|
className={className}
|
||||||
|
service={subject}
|
||||||
|
placement={placement}
|
||||||
|
onClick={onNavigate}
|
||||||
>
|
>
|
||||||
<nav className="max-w-7xl mx-auto flex items-center justify-between">
|
{CTA_LABEL}
|
||||||
{/* Logo com Identidade Visual de Engenheiro */}
|
</WhatsAppLink>
|
||||||
<motion.a
|
);
|
||||||
href="#home"
|
}
|
||||||
onClick={(e) => {
|
return (
|
||||||
e.preventDefault();
|
<HoverPrefetchLink
|
||||||
const element = document.querySelector("#home");
|
href="/#contact"
|
||||||
if (element) {
|
className={className}
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
onClick={onNavigate}
|
||||||
}
|
>
|
||||||
}}
|
{CTA_LABEL}
|
||||||
whileHover={{ scale: 1.05 }}
|
</HoverPrefetchLink>
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
);
|
||||||
>
|
}
|
||||||
<div className="w-8 h-8 flex items-center justify-center">
|
|
||||||
<Image
|
export function Header({
|
||||||
src="/edev_logo.svg"
|
whatsAppContexts,
|
||||||
alt="Evandro.dev Logo"
|
}: {
|
||||||
width={32}
|
/** Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx), para o WhatsApp falar da página aberta. */
|
||||||
height={32}
|
whatsAppContexts?: WhatsAppContexts;
|
||||||
className="w-8 h-8"
|
}) {
|
||||||
priority
|
const pathname = usePathname();
|
||||||
/>
|
const viaWhatsApp = headerOpensWhatsApp(pathname);
|
||||||
</div>
|
const subject = whatsAppSubjectForPath(pathname, whatsAppContexts);
|
||||||
<span className="text-xl font-black tracking-tighter text-white">
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
EVANDRO<span className="text-blue-500">.DEV</span>
|
const [scrolled, setScrolled] = useState(false);
|
||||||
</span>
|
const headerRef = useRef<HTMLElement>(null);
|
||||||
</motion.a>
|
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
// Trocou a rota: o menu fecha (o cabeçalho vive no layout e não remonta entre as páginas).
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname só dispara o fechamento a cada página
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let wasScrolled = false;
|
||||||
|
const updateScrolled = () => {
|
||||||
|
const nextScrolled = window.scrollY > 0;
|
||||||
|
if (nextScrolled === wasScrolled) return;
|
||||||
|
wasScrolled = nextScrolled;
|
||||||
|
setScrolled(nextScrolled);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateScrolled();
|
||||||
|
window.addEventListener("scroll", updateScrolled, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", updateScrolled);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeMobileMenu = () => setMobileMenuOpen(false);
|
||||||
|
|
||||||
|
// Menu aberto: o conteúdo e o rodapé ficam inertes, a página não rola, o Tab não sai do cabeçalho e o Esc fecha.
|
||||||
|
useEffect(() => {
|
||||||
|
const header = headerRef.current;
|
||||||
|
const menuButton = menuButtonRef.current;
|
||||||
|
if (!mobileMenuOpen || !header || !menuButton) return;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const inertTargets = INERT_IDS.flatMap((id) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
return element ? [element] : [];
|
||||||
|
});
|
||||||
|
for (const element of inertTargets) element.setAttribute("inert", "");
|
||||||
|
const previousOverflow = root.style.overflow;
|
||||||
|
root.style.overflow = "hidden";
|
||||||
|
|
||||||
|
// Devolve a página ao normal na hora, sem esperar o React: um link do menu para #id precisa achar o destino
|
||||||
|
// focável quando o gerenciador de âncoras da home (useHomeFragmentNavigation) tenta focá-lo no clique.
|
||||||
|
let released = false;
|
||||||
|
const release = () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
for (const element of inertTargets) element.removeAttribute("inert");
|
||||||
|
root.style.overflow = previousOverflow;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Tab") return trapTab(event, header);
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
release();
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
menuButton.focus();
|
||||||
|
};
|
||||||
|
// Na fase de captura da janela, antes do document: roda antes do gerenciador de âncoras da home.
|
||||||
|
const onLinkClick = (event: MouseEvent) => {
|
||||||
|
const link =
|
||||||
|
event.target instanceof Element
|
||||||
|
? event.target.closest("a[href]")
|
||||||
|
: null;
|
||||||
|
if (link && header.contains(link)) release();
|
||||||
|
};
|
||||||
|
const desktop = window.matchMedia?.(DESKTOP_QUERY);
|
||||||
|
const onViewportChange = (event: MediaQueryListEvent) => {
|
||||||
|
if (!event.matches) return;
|
||||||
|
release();
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("click", onLinkClick, true);
|
||||||
|
desktop?.addEventListener?.("change", onViewportChange);
|
||||||
|
return () => {
|
||||||
|
release();
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("click", onLinkClick, true);
|
||||||
|
desktop?.removeEventListener?.("change", onViewportChange);
|
||||||
|
};
|
||||||
|
}, [mobileMenuOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
ref={headerRef}
|
||||||
|
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
|
||||||
|
scrolled
|
||||||
|
? "border-white/12 bg-[var(--ink)]"
|
||||||
|
: "border-transparent bg-[var(--ink)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="ds-section-shell flex h-full items-center justify-between gap-sp-6">
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href="/#home"
|
||||||
|
aria-label="Evandro.dev.br — início"
|
||||||
|
className="flex min-h-11 shrink-0 items-center no-underline"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
>
|
||||||
|
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
|
||||||
{/* Desktop Navigation - Focada em Espaçamento Ótimo */}
|
<nav
|
||||||
<div className="hidden md:flex items-center gap-1 bg-white/5 p-1 rounded-full border border-white/10 backdrop-blur-md">
|
aria-label="Navegação principal"
|
||||||
{navItems.map((item) => (
|
data-menu-trap-skip=""
|
||||||
<a
|
className="hidden items-center xl:flex"
|
||||||
key={item.href}
|
>
|
||||||
href={item.href}
|
<ul className="flex list-none items-center gap-sp-4">
|
||||||
onClick={(e) => {
|
{NAVIGATION_ITEMS.map((item) => (
|
||||||
e.preventDefault();
|
<li key={item.href}>
|
||||||
const element = document.querySelector(item.href);
|
<HoverPrefetchLink
|
||||||
if (element) {
|
href={item.href}
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
|
||||||
}
|
>
|
||||||
}}
|
{item.label}
|
||||||
className="px-5 py-2 text-xs font-bold uppercase tracking-widest text-slate-300 hover:text-white hover:bg-white/10 rounded-full transition-all duration-300 cursor-pointer"
|
</HoverPrefetchLink>
|
||||||
>
|
</li>
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Botão de Ação CTA (Call to Action) */}
|
<div className="flex shrink-0 items-center">
|
||||||
<div className="hidden md:block">
|
<div className="hidden xl:block" data-menu-trap-skip="">
|
||||||
<a
|
<ContactCta
|
||||||
href="/Profile.pdf"
|
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
|
||||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
placement="header"
|
||||||
className="px-6 py-2 bg-white text-black text-xs font-bold uppercase tracking-widest rounded-full hover:bg-blue-500 hover:text-white transition-all duration-500 inline-block"
|
viaWhatsApp={viaWhatsApp}
|
||||||
>
|
subject={subject}
|
||||||
Download CV
|
/>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Toggle */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
ref={menuButtonRef}
|
||||||
className="md:hidden p-2 text-white bg-white/5 rounded-lg border border-white/10"
|
type="button"
|
||||||
|
className="flex h-11 w-11 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] xl:hidden"
|
||||||
|
// as linhas têm 2 px (h-0.5) e 8 px de espaço (gap-sp-2): de uma a outra são 10 px, e as de cima e de baixo andam 10 px até o meio, para o X fechar sem torcer
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
|
||||||
|
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
|
||||||
|
aria-expanded={mobileMenuOpen}
|
||||||
>
|
>
|
||||||
{isMenuOpen ? <XIcon size={20} /> : <MenuIcon size={20} />}
|
<span
|
||||||
|
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[10px] rotate-45" : ""}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`h-0.5 w-5 bg-white transition-opacity duration-300 ${mobileMenuOpen ? "opacity-0" : ""}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "-translate-y-[10px] -rotate-45" : ""}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</div>
|
||||||
</motion.header>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay com Framer Motion para fluidez */}
|
{mobileMenuOpen ? (
|
||||||
<AnimatePresence>
|
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
|
||||||
{isMenuOpen && (
|
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
|
||||||
<motion.div
|
<div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] lg:px-sp-8 xl:hidden">
|
||||||
initial={{ opacity: 0, x: "100%" }}
|
<div className="ds-section-shell flex flex-1 flex-col">
|
||||||
animate={{ opacity: 1, x: 0 }}
|
<nav
|
||||||
exit={{ opacity: 0, x: "100%" }}
|
id={MENU_ID}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
aria-label="Navegação mobile"
|
||||||
className="fixed inset-0 z-[110] bg-black/95 backdrop-blur-2xl md:hidden flex flex-col items-center justify-center gap-8"
|
className="flex flex-1 flex-col"
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
className="absolute top-8 right-8 p-4 text-white/50 hover:text-white"
|
|
||||||
>
|
>
|
||||||
<XIcon size={32} />
|
<ul className="flex list-none flex-col">
|
||||||
</button>
|
{NAVIGATION_ITEMS.map((item) => (
|
||||||
|
<li key={item.href} className="border-b border-white/10">
|
||||||
{navItems.map((item, i) => (
|
<HoverPrefetchLink
|
||||||
<motion.a
|
href={item.href}
|
||||||
key={item.href}
|
className="flex min-h-14 items-center font-display text-[22px] font-medium normal-case leading-tight tracking-normal text-white/85 no-underline transition-colors hover:text-white"
|
||||||
href={item.href}
|
onClick={closeMobileMenu}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{item.label}
|
||||||
transition={{ delay: i * 0.1 }}
|
</HoverPrefetchLink>
|
||||||
onClick={(e) => {
|
</li>
|
||||||
e.preventDefault();
|
))}
|
||||||
setIsMenuOpen(false);
|
</ul>
|
||||||
const element = document.querySelector(item.href);
|
<div className="mt-auto pt-sp-8">
|
||||||
if (element) {
|
<ContactCta
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
className="flex min-h-14 w-full items-center justify-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[18px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
|
||||||
}
|
placement="header_menu"
|
||||||
}}
|
viaWhatsApp={viaWhatsApp}
|
||||||
className="text-3xl font-black text-white uppercase tracking-tighter hover:text-blue-500 transition-colors cursor-pointer"
|
subject={subject}
|
||||||
>
|
onNavigate={closeMobileMenu}
|
||||||
{item.label}
|
/>
|
||||||
</motion.a>
|
</div>
|
||||||
))}
|
</nav>
|
||||||
<motion.a
|
</div>
|
||||||
href="/Profile.pdf"
|
</div>
|
||||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
) : null}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
</header>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: navItems.length * 0.1 }}
|
|
||||||
className="mt-8 px-8 py-4 bg-white text-black text-xl font-black uppercase tracking-tighter rounded-full hover:bg-blue-500 hover:text-white transition-all duration-300"
|
|
||||||
>
|
|
||||||
Download CV
|
|
||||||
</motion.a>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import Hero from "./Hero";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de Hero (complemento de Hero.test.tsx): anos de experiência (sem stats, sem anos, zero anos, com anos),
|
||||||
|
* links sociais (externo x mailto, com rastreio), faixa de clientes e endereço do trabalho em destaque.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("./cinematic/HeroField", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <div data-testid="hero-field" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||||
|
localStorage.setItem("evandro.analytics-consent.v1", "granted");
|
||||||
|
window.gtag = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
delete window.gtag;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Hero: anos de experiência (na linha de clientes)", () => {
|
||||||
|
it.each([
|
||||||
|
["no stats at all", undefined],
|
||||||
|
["stats without years", {}],
|
||||||
|
["zero years", { years: 0 }],
|
||||||
|
])("path: %s -> no experience text", (_label, stats) => {
|
||||||
|
const { container } = render(<Hero stats={stats} />);
|
||||||
|
|
||||||
|
expect(container.querySelector("dl")).toBeNull();
|
||||||
|
expect(screen.queryByText("anos de experiência")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: years given -> the number and its label sit in the same line as the clients", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Hero
|
||||||
|
stats={{ years: 7 }}
|
||||||
|
clients={[{ id: "lavacao-047", name: "Lavação 047" }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("dl")).toBeNull();
|
||||||
|
const strip = screen.getByText("Já entregue para:").parentElement;
|
||||||
|
expect(strip).toHaveTextContent("Lavação 047");
|
||||||
|
expect(strip).toHaveTextContent("7 anos de experiência");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: years given but no clients -> only the experience text, with no 'Já entregue para'", () => {
|
||||||
|
render(<Hero stats={{ years: 7 }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Já entregue para:")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Hero: links sociais", () => {
|
||||||
|
it("path: LinkedIn (http link) -> new tab with noopener and a social_click", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
const link = screen.getByRole("link", { name: "LinkedIn" });
|
||||||
|
|
||||||
|
expect(link).toHaveAttribute("target", "_blank");
|
||||||
|
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
fireEvent.click(link, { button: 0 });
|
||||||
|
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"social_click",
|
||||||
|
expect.objectContaining({ network: "linkedin" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: e-mail (mailto link) -> same tab, no rel, and an email_click from the hero", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
const link = screen.getByRole("link", { name: "Email" });
|
||||||
|
|
||||||
|
expect(link).not.toHaveAttribute("target");
|
||||||
|
expect(link).not.toHaveAttribute("rel");
|
||||||
|
link.addEventListener("click", (event) => event.preventDefault());
|
||||||
|
fireEvent.click(link, { button: 0 });
|
||||||
|
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"email_click",
|
||||||
|
expect.objectContaining({ placement: "hero" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Hero: faixa de clientes e trabalho em destaque", () => {
|
||||||
|
it("path: clients given -> the strip links each client to its case", () => {
|
||||||
|
render(<Hero clients={[{ id: "lavacao-047", name: "Lavação 047" }]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Já entregue para:")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: "Lavação 047" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/cases/lavacao-047",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no clients -> no strip", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Já entregue para:")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: workUrl given -> the browser bar of the featured work shows it", () => {
|
||||||
|
render(<Hero workUrl={{ text: "lavacao.example.com", secure: true }} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("hero-work")).toHaveTextContent(
|
||||||
|
"lavacao.example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: workUrl omitted -> the browser bar shows no address", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("hero-work")).not.toHaveTextContent(".com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the main button carries the id that hides the floating WhatsApp", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
|
||||||
|
).toHaveAttribute("id", "hero-contact-cta");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import { hydrateRoot, type Root } from "react-dom/client";
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
import Hero from "./Hero";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
"hydrates without attribute mismatches when reduced motion resolves to %s",
|
||||||
|
async (reducedMotion) => {
|
||||||
|
jest.spyOn(window, "matchMedia").mockImplementation((media) => ({
|
||||||
|
media,
|
||||||
|
matches: media === "(prefers-reduced-motion: reduce)" && reducedMotion,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
}));
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.innerHTML = renderToString(<Hero />);
|
||||||
|
document.body.append(container);
|
||||||
|
|
||||||
|
const consoleErrors = jest.spyOn(console, "error").mockImplementation();
|
||||||
|
const recoverableErrors: unknown[] = [];
|
||||||
|
let root: Root | undefined;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = hydrateRoot(container, <Hero />, {
|
||||||
|
onRecoverableError: (error) => recoverableErrors.push(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(recoverableErrors).toEqual([]);
|
||||||
|
expect(consoleErrors.mock.calls).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root?.unmount());
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import Hero from "./Hero";
|
||||||
|
import { HERO_CAPTIONS } from "./hero-captions";
|
||||||
|
|
||||||
|
// o Link do Next, trocado por um <a> que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clients = [
|
||||||
|
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
|
||||||
|
{ id: "lavacao-047", name: "Lavação 047" },
|
||||||
|
{ id: "sc-plus", name: "SC Plus Aquicultura" },
|
||||||
|
{ id: "as-servicos", name: "AS Serviços" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("Hero", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens WhatsApp straight from the main button, with the subject of the home, while 'Ver projetos entregues' stays secondary", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
const contactLink = screen.getByRole("link", {
|
||||||
|
name: /falar sobre meu projeto/i,
|
||||||
|
});
|
||||||
|
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
|
||||||
|
expect(contactLink.getAttribute("href")).toMatch(
|
||||||
|
/^https:\/\/wa\.me\/\d+\?/,
|
||||||
|
);
|
||||||
|
expect(contactLink).toHaveAttribute("target", "_blank");
|
||||||
|
expect(contactLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
const text = new URL(
|
||||||
|
contactLink.getAttribute("href") ?? "",
|
||||||
|
).searchParams.get("text");
|
||||||
|
expect(text).toBe(
|
||||||
|
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
|
||||||
|
);
|
||||||
|
// o botão primário é o único com o fundo limão; o secundário é contorno
|
||||||
|
expect(contactLink.className).toContain("bg-[var(--slash)]");
|
||||||
|
const projects = screen.getByRole("link", { name: /ver projetos/i });
|
||||||
|
expect(projects).toHaveAttribute("href", "/cases");
|
||||||
|
expect(projects.className).not.toContain("bg-[var(--slash)]");
|
||||||
|
expect(projects).not.toHaveAttribute("target");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the headline as one readable sentence despite the per-word animation", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||||
|
"Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights only the short stretch 'pedidos pelo WhatsApp' in lime", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
const highlighted = Array.from(
|
||||||
|
screen.getByRole("heading", { level: 1 }).querySelectorAll(".hero-word"),
|
||||||
|
)
|
||||||
|
.filter((word) => word.className.includes("text-[var(--slash)]"))
|
||||||
|
.map((word) => word.textContent);
|
||||||
|
expect(highlighted).toEqual(["pedidos", "pelo", "WhatsApp"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("link", { name: "LinkedIn" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://www.linkedin.com/in/evandrodevbr/",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("link", { name: "Email" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"mailto:[email protected]",
|
||||||
|
);
|
||||||
|
const linkedin = screen.getByRole("link", { name: "LinkedIn" });
|
||||||
|
expect(linkedin).toHaveAttribute("target", "_blank");
|
||||||
|
expect(linkedin).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull();
|
||||||
|
for (const link of screen.getAllByRole("link")) {
|
||||||
|
expect(link.getAttribute("href") ?? "").not.toMatch(/github\.com/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states who the work is for and how the contact works", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você fala com quem desenvolve.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: /falar sobre meu projeto/i }),
|
||||||
|
).toHaveAttribute("id", "hero-contact-cta");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the years of experience, with no repository numbers", () => {
|
||||||
|
render(<Hero stats={{ years: 5 }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("5")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("term")).toBeNull();
|
||||||
|
expect(screen.queryByText(/projetos desenvolvidos/i)).toBeNull();
|
||||||
|
expect(screen.queryByText(/públicos|privados|mais de/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the delivered clients, each linking to its case", () => {
|
||||||
|
render(<Hero clients={clients} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Já entregue para:")).toBeInTheDocument();
|
||||||
|
for (const { id, name } of clients) {
|
||||||
|
expect(screen.getByRole("link", { name })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
`/cases/${id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the work column with lazy posters that stay out of small screens", () => {
|
||||||
|
render(<Hero clients={clients} />);
|
||||||
|
|
||||||
|
const work = screen.getByTestId("hero-work");
|
||||||
|
expect(work).toHaveClass("hidden", "lg:block");
|
||||||
|
const images = work.querySelectorAll("img");
|
||||||
|
expect(images).toHaveLength(2);
|
||||||
|
for (const image of images) {
|
||||||
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
|
}
|
||||||
|
// pelo otimizador do next/image: o endereço original vai no parâmetro `url`
|
||||||
|
const original = (image: Element) =>
|
||||||
|
new URL(
|
||||||
|
image.getAttribute("src") ?? "",
|
||||||
|
"http://localhost",
|
||||||
|
).searchParams.get("url");
|
||||||
|
expect(original(images[0])).toBe("/videos/lavacao-047/desktop.webp");
|
||||||
|
expect(images[0]).toHaveAccessibleName(
|
||||||
|
"Site da Lavação 047 aberto no computador",
|
||||||
|
);
|
||||||
|
expect(original(images[1])).toBe("/videos/as-servicos/mobile.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a wide card to the SEV case below the buttons on small screens", () => {
|
||||||
|
render(<Hero clients={clients} />);
|
||||||
|
|
||||||
|
const small = screen.getByTestId("hero-work-mobile");
|
||||||
|
expect(small).toHaveClass("lg:hidden");
|
||||||
|
expect(small).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||||
|
expect(small).toHaveAccessibleName("Site da SEV Moda Íntima Ver case");
|
||||||
|
const buttons = screen.getByRole("link", { name: /ver projetos/i });
|
||||||
|
expect(
|
||||||
|
buttons.compareDocumentPosition(small) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the label above the headline in the display font, so the mono font is not downloaded before the LCP", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
const label = screen.getByText(
|
||||||
|
/Evandro Fonseca Junior · Engenharia de software/,
|
||||||
|
);
|
||||||
|
expect(label.className).toContain("font-display");
|
||||||
|
expect(label.className).not.toContain("font-mono");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Hero clients={clients} stats={{ years: 5 }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".font-mono")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-fetches 'Ver projetos entregues', the clients and the SEV card only when reached (hover, focus or touch), never by viewport", () => {
|
||||||
|
render(<Hero clients={clients} />);
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
screen.getByRole("link", { name: /ver projetos/i }),
|
||||||
|
screen.getByRole("link", { name: "SEV Moda Íntima" }),
|
||||||
|
screen.getByTestId("hero-work-mobile"),
|
||||||
|
];
|
||||||
|
for (const link of links) {
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "false");
|
||||||
|
fireEvent.mouseEnter(link);
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "null");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the range of each dive caption (--from and --to, from hero-captions.ts) on its own item, in order", () => {
|
||||||
|
const { container } = render(<Hero clients={clients} />);
|
||||||
|
|
||||||
|
const items = Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(".hero-captions li"),
|
||||||
|
);
|
||||||
|
expect(items).toHaveLength(HERO_CAPTIONS.length);
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const { title, text, from, to } = HERO_CAPTIONS[index];
|
||||||
|
expect(item).toHaveTextContent(`${title}${text}`);
|
||||||
|
expect(item.style.getPropertyValue("--from")).toBe(String(from));
|
||||||
|
expect(item.style.getPropertyValue("--to")).toBe(String(to));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+175
-123
@@ -1,145 +1,197 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
import { type CSSProperties, Fragment } from "react";
|
||||||
import Image from "next/image";
|
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
|
||||||
import { AuroraText } from "@/components/magicui/aurora-text";
|
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
|
||||||
import { AnimatedGradientText } from "@/components/magicui/animated-gradient-text";
|
import { HOME_SUBJECT } from "@/lib/whatsapp";
|
||||||
import { Github, Linkedin, MessageCircle, Mail, MapPin, Terminal } from "lucide-react";
|
import ClientStrip, { type ClientLink } from "./ClientStrip";
|
||||||
import TextType from "@/components/TextType";
|
import HeroField from "./cinematic/HeroField";
|
||||||
|
import { HERO_CTA_ID } from "./FloatingWhatsApp";
|
||||||
|
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
import { HERO_CAPTIONS } from "./hero-captions";
|
||||||
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
|
||||||
export default function Hero() {
|
const SOCIAL_LINKS = [
|
||||||
const getGreetingMessage = () => {
|
{
|
||||||
const hour = new Date().getHours();
|
href: LINKEDIN_URL,
|
||||||
if (hour >= 8 && hour < 12) return "Olá! Vi seu portfólio. Podemos conversar?";
|
label: "LinkedIn",
|
||||||
if (hour >= 13 && hour < 18) return "Boa tarde! Vi seu portfólio. Podemos conversar?";
|
onClick: () => trackSocialClick({ network: "linkedin" }),
|
||||||
return "Olá! Vi seu portfólio. Podemos conversar quando estiver disponível?";
|
},
|
||||||
};
|
{
|
||||||
|
href: `mailto:${CONTACT_EMAIL}`,
|
||||||
|
label: "Email",
|
||||||
|
onClick: () => trackEmailClick({ placement: "hero" }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const generateWhatsAppUrl = () => {
|
/**
|
||||||
const message = encodeURIComponent(getGreetingMessage());
|
* Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json
|
||||||
return `https://wa.me/5547992742365?text=${message}`;
|
* e aparecem na mesma linha de "Já entregue para" (ClientStrip).
|
||||||
};
|
*/
|
||||||
|
export type HeroStats = {
|
||||||
|
years?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** O destaque em limão fica num trecho curto: "pedidos pelo WhatsApp" (palavras 5 a 7). */
|
||||||
|
const ACCENT = new Set([5, 6, 7]);
|
||||||
|
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
|
||||||
|
const TITLE =
|
||||||
|
"Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha."
|
||||||
|
.split(" ")
|
||||||
|
.map((word, index) => ({
|
||||||
|
word,
|
||||||
|
// a mesma palavra se repete ("e"), então a posição entra na chave
|
||||||
|
key: `${word}-${index}`,
|
||||||
|
accent: ACCENT.has(index),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function Hero({
|
||||||
|
stats,
|
||||||
|
clients,
|
||||||
|
workUrl,
|
||||||
|
}: {
|
||||||
|
stats?: HeroStats;
|
||||||
|
clients?: ClientLink[];
|
||||||
|
/** Endereço mostrado na barra do navegador de HeroWork (o domínio próprio do site da SEV Moda Íntima). */
|
||||||
|
workUrl?: HeroWorkUrl;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<section id="home" className="relative min-h-screen flex items-center justify-center overflow-hidden pt-28 md:pt-20">
|
<section
|
||||||
{/* 1. Background Engineering: Grid Pattern & Radial Shading */}
|
id="home"
|
||||||
<div className="absolute inset-0 z-0 pointer-events-none">
|
className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
|
||||||
<div className="absolute inset-0 bg-radial-at-t from-blue-500/10 via-transparent to-transparent" />
|
>
|
||||||
</div>
|
<div className="hero-stage">
|
||||||
|
<div className="hero-glow" aria-hidden="true" />
|
||||||
|
<HeroField />
|
||||||
|
<div className="hero-scrim" aria-hidden="true" />
|
||||||
|
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
||||||
|
<ol className="hero-captions">
|
||||||
|
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
|
||||||
|
<li
|
||||||
|
key={title}
|
||||||
|
style={
|
||||||
|
{ "--i": index, "--from": from, "--to": to } as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="hero-caption-title">{title}</span>
|
||||||
|
{text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-6xl mx-auto px-6 grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
|
||||||
|
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)] lg:px-sp-12">
|
||||||
|
{/* Space Grotesk (a fonte do título, já carregada), e não a Geist Mono: assim nada de fonte nova é baixado antes do LCP. */}
|
||||||
|
<p className="hero-in mb-sp-4 flex items-center gap-sp-3 font-display text-[12px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)]">
|
||||||
|
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
||||||
|
Evandro Fonseca Junior · Engenharia de software
|
||||||
|
</p>
|
||||||
|
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
|
||||||
|
{/* No desktop o título ocupa até 54% da largura; a coluna HeroWork fica com 40%, à direita. */}
|
||||||
|
<h1 className="hero-title max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em] lg:max-w-[54%]">
|
||||||
|
{TITLE.map(({ word, key, accent }, index) => (
|
||||||
|
<Fragment key={key}>
|
||||||
|
<span
|
||||||
|
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
|
||||||
|
style={{ "--i": index } as CSSProperties}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>{" "}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</h1>
|
||||||
|
<p className="hero-in mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s] lg:max-w-[min(36em,54%)]">
|
||||||
|
Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você
|
||||||
|
fala com quem desenvolve.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Lado Esquerdo: Conteúdo Textual (80/20 do Impacto) */}
|
<div className="hero-in mt-sp-5 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
<div className="lg:col-span-7 text-left">
|
{/* Abre o WhatsApp direto, com o assunto da home: o formulário continua na seção de contato, ao fim da página. */}
|
||||||
<motion.div
|
<WhatsAppLink
|
||||||
initial={{ opacity: 0, x: -20 }}
|
id={HERO_CTA_ID}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
service={HOME_SUBJECT}
|
||||||
transition={{ duration: 0.6 }}
|
placement="hero"
|
||||||
>
|
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
||||||
<AnimatedGradientText className="mb-6 inline-flex items-center gap-2 px-4 py-2 rounded-full border border-blue-500/20 bg-blue-500/5 backdrop-blur-md">
|
>
|
||||||
<span className="relative flex h-2 w-2">
|
Falar sobre meu projeto
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
<svg
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-500"></span>
|
aria-hidden="true"
|
||||||
</span>
|
viewBox="0 0 16 16"
|
||||||
<span className="text-sm font-medium text-cyan-300 tracking-wide uppercase">
|
className="h-4 w-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
|
||||||
Systems Engineer available
|
fill="none"
|
||||||
</span>
|
stroke="currentColor"
|
||||||
</AnimatedGradientText>
|
strokeWidth="2"
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-7xl font-black text-white mb-4 tracking-tight leading-none">
|
|
||||||
Evandro <br />
|
|
||||||
<AuroraText className="italic">Fonseca Junior</AuroraText>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="h-10 flex items-center gap-2 mb-6">
|
|
||||||
<Terminal className="w-5 h-5 text-blue-400" />
|
|
||||||
<TextType
|
|
||||||
text={["Engenheiro de Software", "Next.js & Tauri Specialist", "High Performance Systems Architect"]}
|
|
||||||
className="text-2xl font-mono text-slate-400"
|
|
||||||
typingSpeed={60}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-lg text-slate-400 max-w-xl mb-10 leading-relaxed">
|
|
||||||
Engenheiro de Software especializado em aplicações de alta performance e sistemas críticos. Desenvolvimento com <span className="text-white font-medium">Next.js 15, React 19, Tauri</span> e arquitetura escalável. Transformo requisitos complexos em software robusto.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 items-center">
|
|
||||||
<motion.a
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
href={generateWhatsAppUrl()}
|
|
||||||
className="px-8 py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition-all shadow-lg shadow-blue-600/20 flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<MessageCircle className="w-5 h-5" />
|
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||||
Iniciar Projeto
|
</svg>
|
||||||
</motion.a>
|
</WhatsAppLink>
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href="/cases"
|
||||||
|
className="inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/55 motion-reduce:transition-none sm:text-[16px]"
|
||||||
|
>
|
||||||
|
Ver projetos entregues
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 px-4">
|
<ClientStrip
|
||||||
{[
|
clients={clients}
|
||||||
{ icon: Github, link: "https://github.com/evandrodevbr", rel: "me" },
|
years={stats?.years}
|
||||||
{ icon: Linkedin, link: "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/", rel: "me" },
|
className="hero-in mt-sp-3 [--d:.75s]"
|
||||||
{ icon: Mail, link: "mailto:[email protected]" }
|
/>
|
||||||
].map((social, i) => (
|
|
||||||
|
<HeroWork url={workUrl} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
|
||||||
|
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
|
||||||
|
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */}
|
||||||
|
<div className="max-lg:hidden">
|
||||||
|
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
|
||||||
|
{["Sites", "Sistemas web", "Aplicativos", "Integrações"].map(
|
||||||
|
(service) => (
|
||||||
|
<li key={service} className="flex items-center gap-sp-2">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-1 w-1 bg-[var(--slash)]"
|
||||||
|
/>
|
||||||
|
{service}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-sp-5">
|
||||||
|
{SOCIAL_LINKS.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={i}
|
key={link.label}
|
||||||
href={social.link}
|
href={link.href}
|
||||||
rel={social.rel ? `${social.rel} noopener noreferrer` : "noopener noreferrer"}
|
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||||
target={social.link.startsWith("mailto:") ? undefined : "_blank"}
|
rel={
|
||||||
className="p-3 rounded-lg border border-slate-800 text-slate-400 hover:text-white hover:border-slate-600 transition-all"
|
link.href.startsWith("http")
|
||||||
|
? "noopener noreferrer"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={link.onClick}
|
||||||
|
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
|
||||||
>
|
>
|
||||||
<social.icon className="w-5 h-5" />
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
|
||||||
|
Santa Catarina · Atendimento remoto
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-8 flex items-center gap-2 text-slate-500 text-sm">
|
<p
|
||||||
<MapPin className="w-4 h-4" />
|
aria-hidden="true"
|
||||||
<span>Garuva, SC | Remote Worldwide</span>
|
className="hero-cue hidden items-center gap-sp-3 font-display text-[12px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lado Direito: Visual (A "Âncora" do Design) */}
|
|
||||||
<div className="lg:col-span-5 relative flex justify-center">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
|
||||||
className="relative w-72 h-72 md:w-96 md:h-96"
|
|
||||||
>
|
>
|
||||||
{/* Elementos Orbitais (Vibe Engenharia) */}
|
Role para mergulhar
|
||||||
<div className="absolute inset-0 rounded-full border border-dashed border-blue-500/30 animate-[spin_20s_linear_infinite]" />
|
<span className="hero-cue-line" />
|
||||||
<div className="absolute inset-10 rounded-full border border-blue-500/10 animate-[spin_15s_linear_infinite_reverse]" />
|
</p>
|
||||||
|
|
||||||
{/* Foto de Perfil com Masking de Engenharia */}
|
|
||||||
<div className="relative z-10 w-full h-full rounded-[2.5rem] overflow-hidden border-2 border-slate-800 shadow-2xl">
|
|
||||||
<div className="absolute inset-0 bg-blue-500/10 z-10 hover:opacity-0 transition-opacity duration-500" />
|
|
||||||
<Image
|
|
||||||
src="/edev.jpeg"
|
|
||||||
alt="Evandro Fonseca Junior"
|
|
||||||
fill
|
|
||||||
className="object-cover grayscale hover:grayscale-0 transition-all duration-700 scale-105 hover:scale-100"
|
|
||||||
priority
|
|
||||||
fetchPriority="high"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Floating Stats Badge */}
|
|
||||||
<div className="absolute -bottom-6 -right-6 bg-slate-900 border border-slate-800 p-4 rounded-2xl shadow-2xl z-20 backdrop-blur-xl">
|
|
||||||
<p className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Exp. Level</p>
|
|
||||||
<p className="text-xl font-mono text-blue-400">Mid-Level Engineer</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll Indicator (Refinado) */}
|
|
||||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2">
|
|
||||||
<span className="text-[10px] text-slate-600 uppercase tracking-[0.2em]">Scroll</span>
|
|
||||||
<div className="w-[1px] h-12 bg-gradient-to-b from-blue-500 to-transparent" />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import HeroWork from "./HeroWork";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Os pôsteres do hero: pelo next/image com `sizes` do tamanho em que aparecem, sempre lazy e com prioridade baixa
|
||||||
|
* (o título é o LCP), e o cartão largo e clicável da SEV para as telas abaixo de 1024 px.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const original = (image: Element) =>
|
||||||
|
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
|
||||||
|
"url",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("HeroWork: pôsteres no tamanho certo", () => {
|
||||||
|
it("serves the three posters through the image optimizer, each with a `sizes` that matches how it shows", () => {
|
||||||
|
render(<HeroWork />);
|
||||||
|
|
||||||
|
const desktop = within(screen.getByTestId("hero-work")).getAllByRole("img");
|
||||||
|
expect(desktop).toHaveLength(2);
|
||||||
|
expect(original(desktop[0])).toBe("/videos/lavacao-047/desktop.webp");
|
||||||
|
expect(original(desktop[1])).toBe("/videos/as-servicos/mobile.webp");
|
||||||
|
// 1440x900 e 390x844 cheios para quadros de ~491x307 e ~130x299 era banda de sobra
|
||||||
|
expect(desktop[0]).toHaveAttribute(
|
||||||
|
"sizes",
|
||||||
|
"(min-width: 1400px) 500px, 36vw",
|
||||||
|
);
|
||||||
|
expect(desktop[1]).toHaveAttribute(
|
||||||
|
"sizes",
|
||||||
|
"(min-width: 1400px) 136px, 10vw",
|
||||||
|
);
|
||||||
|
|
||||||
|
// o cartão do celular usa o pôster de celular da SEV (o título em letras grandes cabe na faixa de 72 px), na largura
|
||||||
|
// da coluna (a tela menos as duas margens: cerca de 342 px num celular de 390 px), e não 100vw
|
||||||
|
const small = screen
|
||||||
|
.getByTestId("hero-work-mobile")
|
||||||
|
.querySelector("img") as HTMLImageElement;
|
||||||
|
expect(original(small)).toBe("/videos/sev-modaintima/mobile.webp");
|
||||||
|
expect(small).toHaveAttribute(
|
||||||
|
"sizes",
|
||||||
|
"(min-width: 1024px) 1px, calc(100vw - 48px)",
|
||||||
|
);
|
||||||
|
expect(small).toHaveAttribute("width", "390");
|
||||||
|
expect(small).toHaveAttribute("height", "844");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the headline of the SEV poster in the 72px strip of the mobile card: the position moves the strip to the line 'Nova Coleção' instead of the top of the poster", () => {
|
||||||
|
render(<HeroWork />);
|
||||||
|
|
||||||
|
const small = screen
|
||||||
|
.getByTestId("hero-work-mobile")
|
||||||
|
.querySelector("img") as HTMLImageElement;
|
||||||
|
expect(small).toHaveClass("object-cover", "object-[50%_57%]");
|
||||||
|
expect(small).not.toHaveClass("object-top");
|
||||||
|
// a faixa continua pequena (o título da página segue como o maior elemento da dobra)
|
||||||
|
expect((small.parentElement as HTMLElement).style.height).toBe("72px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => {
|
||||||
|
render(<HeroWork />);
|
||||||
|
|
||||||
|
const [browser, phone] = within(
|
||||||
|
screen.getByTestId("hero-work"),
|
||||||
|
).getAllByRole("img");
|
||||||
|
expect(browser).toHaveAttribute("width", "1440");
|
||||||
|
expect(browser).toHaveAttribute("height", "900");
|
||||||
|
expect(phone).toHaveAttribute("width", "390");
|
||||||
|
expect(phone).toHaveAttribute("height", "844");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never asks for a high priority: lazy, low fetch priority and no preload, so the headline stays the LCP", () => {
|
||||||
|
const { container } = render(<HeroWork />);
|
||||||
|
|
||||||
|
const images = container.querySelectorAll("img");
|
||||||
|
expect(images).toHaveLength(3);
|
||||||
|
for (const image of images) {
|
||||||
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
|
expect(image).toHaveAttribute("fetchpriority", "low");
|
||||||
|
}
|
||||||
|
// sem <link rel="preload">: o next/image só o insere com `preload` (ou fetchPriority="high")
|
||||||
|
expect(document.head.querySelector('link[rel="preload"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HeroWork: cartão do celular", () => {
|
||||||
|
it("shows a wide card with the SEV poster only below 1024px, while the column stays desktop-only", () => {
|
||||||
|
render(<HeroWork />);
|
||||||
|
|
||||||
|
const column = screen.getByTestId("hero-work");
|
||||||
|
expect(column).toHaveClass("hidden", "lg:block");
|
||||||
|
|
||||||
|
const card = screen.getByTestId("hero-work-mobile");
|
||||||
|
expect(card).toHaveClass("lg:hidden", "w-full");
|
||||||
|
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
|
||||||
|
expect(card.querySelector(".ds-phone")).toBeNull();
|
||||||
|
const imageBox = card.querySelector("img")?.parentElement as HTMLElement;
|
||||||
|
expect(imageBox).toHaveClass("w-full");
|
||||||
|
expect(imageBox.style.height).toBe("72px");
|
||||||
|
expect(card).toHaveTextContent("Site da SEV Moda Íntima");
|
||||||
|
expect(card).toHaveTextContent("Ver case");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a link to the SEV case, with a touch target of at least 48px and its own focus ring", () => {
|
||||||
|
render(<HeroWork />);
|
||||||
|
|
||||||
|
const card = screen.getByRole("link", {
|
||||||
|
name: "Site da SEV Moda Íntima Ver case",
|
||||||
|
});
|
||||||
|
expect(card).toBe(screen.getByTestId("hero-work-mobile"));
|
||||||
|
expect(card).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||||
|
expect(card).not.toHaveClass("pointer-events-none");
|
||||||
|
expect(card.className).toContain("focus-visible:outline-2");
|
||||||
|
expect(card.querySelector("span.min-h-12")).not.toBeNull();
|
||||||
|
// a imagem é decorativa: o texto do link já diz o que ela mostra
|
||||||
|
expect(card.querySelector("img")).toHaveAttribute("alt", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws no other link: the card is the only one in HeroWork", () => {
|
||||||
|
render(<HeroWork />);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("link")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the desktop column as a plain picture: not interactive, nothing to click", () => {
|
||||||
|
render(<HeroWork />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("hero-work")).toHaveClass("pointer-events-none");
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId("hero-work")).queryByRole("link"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the address to the browser bar of the desktop frame", () => {
|
||||||
|
render(<HeroWork url={{ text: "sevmodaintima.com.br", secure: true }} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId("hero-work")).getByText("sevmodaintima.com.br"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
import { BrowserFrame } from "./showcase/BrowserFrame";
|
||||||
|
import { PhoneFrame } from "./showcase/PhoneFrame";
|
||||||
|
|
||||||
|
/** Endereço mostrado na barra do navegador (mesmo formato de displayUrl em showcase/BrowserFrame). */
|
||||||
|
export type HeroWorkUrl = { text: string; secure: boolean } | null;
|
||||||
|
|
||||||
|
const DESKTOP_PROJECT = "lavacao-047";
|
||||||
|
const DESKTOP_PROJECT_TITLE = "Lavação 047";
|
||||||
|
const MOBILE_PROJECT = "as-servicos";
|
||||||
|
/** No celular o cartão mostra o site da SEV e leva ao case; o navegador da tela larga, ao lado do título, mostra o da Lavação 047. */
|
||||||
|
const SMALL_SCREEN_PROJECT = "sev-modaintima";
|
||||||
|
const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
|
||||||
|
const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
|
||||||
|
/**
|
||||||
|
* Altura da imagem do cartão de celular (px): a largura é a da coluna (cerca de 342 px num celular de 390 px). Com 72 px
|
||||||
|
* a área da imagem (cerca de 25 mil px²) fica abaixo da do H1 (cerca de 29 mil px² a 390 px): com 96 px ela era maior, e
|
||||||
|
* numa rede lenta a imagem, que chega depois, passava a ser o LCP no lugar do título (medido com rede e CPU limitadas).
|
||||||
|
*/
|
||||||
|
const SMALL_CARD_IMAGE_HEIGHT = 72;
|
||||||
|
/**
|
||||||
|
* Faixa de 72 px de altura: o pôster do computador (16:10) cortado nela mostrava só uma fatia do topo, e o título
|
||||||
|
* "Nova Coleção" saía pela metade. O pôster do celular (390x844, o mesmo site) tem o título em letras maiores: o
|
||||||
|
* `object-position` leva a faixa até a linha "Nova Coleção", que cabe inteira nela.
|
||||||
|
*/
|
||||||
|
const SMALL_CARD_IMAGE_POSITION = "object-[50%_57%]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trabalho real do hero, em dois formatos. Em telas de 1024 px ou mais, ao lado do título: o pôster do site da
|
||||||
|
* Lavação 047 no navegador (a barra de endereço só mostra domínio próprio; o da Lavação é de hospedagem, então ela
|
||||||
|
* fica com "pré-visualização") e o do aplicativo da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, um cartão largo e clicável
|
||||||
|
* (a largura da coluna, cerca de 350 px num celular) com o pôster do site da SEV, abaixo dos botões, que leva ao
|
||||||
|
* case da SEV; antes era uma moldura de celular de 65 por 160 px, pequena demais para ver o que era.
|
||||||
|
* Os pôsteres vão pelo next/image com `sizes` do tamanho em que aparecem (um arquivo de 1440 px para 491 px de tela
|
||||||
|
* era banda desperdiçada), em `loading="lazy"` e prioridade baixa: no celular o título segue como o maior elemento da
|
||||||
|
* dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e a imagem que fica escondida pelo breakpoint (display: none) nem é
|
||||||
|
* baixada. Em tela larga o pôster do navegador é maior que o título e, numa rede lenta, vira o LCP quando chega.
|
||||||
|
*/
|
||||||
|
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
|
||||||
|
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
|
||||||
|
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
|
||||||
|
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
data-testid="hero-work"
|
||||||
|
className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
|
||||||
|
>
|
||||||
|
<BrowserFrame url={url} className="w-[88%]">
|
||||||
|
{/* a moldura mostra ~491 px em 1440 px de tela: 36vw até 1400 px, e fixo dali em diante */}
|
||||||
|
<Image
|
||||||
|
src={desktop.poster}
|
||||||
|
alt={`Site da ${DESKTOP_PROJECT_TITLE} aberto no computador`}
|
||||||
|
width={desktop.width}
|
||||||
|
height={desktop.height}
|
||||||
|
sizes="(min-width: 1400px) 500px, 36vw"
|
||||||
|
loading="lazy"
|
||||||
|
fetchPriority="low"
|
||||||
|
className="block h-auto w-full"
|
||||||
|
/>
|
||||||
|
</BrowserFrame>
|
||||||
|
{/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
|
||||||
|
<PhoneFrame
|
||||||
|
className="right-0 bottom-[-12%] w-[26%]"
|
||||||
|
style={{ position: "absolute" }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={phone.poster}
|
||||||
|
alt="Site da AS Serviços aberto no celular"
|
||||||
|
width={phone.width}
|
||||||
|
height={phone.height}
|
||||||
|
sizes="(min-width: 1400px) 136px, 10vw"
|
||||||
|
loading="lazy"
|
||||||
|
fetchPriority="low"
|
||||||
|
className="absolute inset-0 block h-full w-full object-cover object-top"
|
||||||
|
/>
|
||||||
|
</PhoneFrame>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HoverPrefetchLink
|
||||||
|
data-testid="hero-work-mobile"
|
||||||
|
href={SMALL_SCREEN_HREF}
|
||||||
|
className="hero-in group mt-sp-5 flex w-full flex-col overflow-hidden border border-white/20 bg-white/[0.05] font-display no-underline transition-colors hover:border-white/45 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none [--d:.8s] lg:hidden"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="relative block w-full overflow-hidden bg-[var(--ink)]"
|
||||||
|
style={{ height: SMALL_CARD_IMAGE_HEIGHT }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={smallCard.poster}
|
||||||
|
alt=""
|
||||||
|
width={smallCard.width}
|
||||||
|
height={smallCard.height}
|
||||||
|
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
|
||||||
|
loading="lazy"
|
||||||
|
fetchPriority="low"
|
||||||
|
className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
|
||||||
|
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import HomeFaq, { FAQ_ITEMS } from "./HomeFaq";
|
||||||
|
|
||||||
|
describe("HomeFaq", () => {
|
||||||
|
it("shows the five questions the site already backs up, closed by default", () => {
|
||||||
|
const { container } = render(<HomeFaq />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Perguntas frequentes" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const summaries = Array.from(container.querySelectorAll("summary")).map(
|
||||||
|
(summary) => summary.textContent?.replace("+", "").trim(),
|
||||||
|
);
|
||||||
|
expect(summaries).toEqual([
|
||||||
|
"Como começa um projeto?",
|
||||||
|
"Quanto custa?",
|
||||||
|
"Você atende fora de Santa Catarina?",
|
||||||
|
"Dá para continuar um sistema que já existe ou que foi feito com IA?",
|
||||||
|
"O que preciso ter para começar?",
|
||||||
|
]);
|
||||||
|
for (const details of container.querySelectorAll("details")) {
|
||||||
|
expect(details.open).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers from what the site already says: first conversation, remote in all of Brazil, and the AI review", () => {
|
||||||
|
render(<HomeFaq />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Com uma conversa para entender o problema/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/o atendimento é remoto, para empresas de todo o Brasil/,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Ver a revisão de app feito com IA" }),
|
||||||
|
).toHaveAttribute("href", "/ia");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the questions the owner has not answered yet", () => {
|
||||||
|
const { container } = render(<HomeFaq />);
|
||||||
|
|
||||||
|
for (const unanswered of [
|
||||||
|
"O código e os acessos ficam comigo?",
|
||||||
|
"Em nome de quem ficam domínio e hospedagem?",
|
||||||
|
"Como funciona o pagamento?",
|
||||||
|
]) {
|
||||||
|
// preparadas na lista, com resposta vazia...
|
||||||
|
expect(FAQ_ITEMS.find((item) => item.question === unanswered)).toEqual({
|
||||||
|
question: unanswered,
|
||||||
|
answer: "",
|
||||||
|
});
|
||||||
|
// ...e fora da página
|
||||||
|
expect(screen.queryByText(unanswered)).toBeNull();
|
||||||
|
}
|
||||||
|
expect(container.querySelectorAll("details")).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers 'Quanto custa?' with the owner's sentence only: no value, no deadline, and no promise that the first talk is free", () => {
|
||||||
|
render(<HomeFaq />);
|
||||||
|
|
||||||
|
const details = screen
|
||||||
|
.getByText("Quanto custa?")
|
||||||
|
.closest("details") as HTMLElement;
|
||||||
|
expect(
|
||||||
|
within(details).getByText(
|
||||||
|
"Depois da primeira conversa você recebe escopo, valor e prazo por escrito, antes de qualquer compromisso.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(details.textContent).not.toMatch(
|
||||||
|
/R\$|\d|grátis|gratuit|sem custo|sem compromisso/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts showing a prepared question as soon as it gets an answer", () => {
|
||||||
|
render(
|
||||||
|
<HomeFaq
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
question: "Como funciona o pagamento?",
|
||||||
|
answer: "Resposta do dono.",
|
||||||
|
},
|
||||||
|
{ question: "Ainda sem resposta?", answer: " " },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = screen
|
||||||
|
.getByText("Como funciona o pagamento?")
|
||||||
|
.closest("details") as HTMLElement;
|
||||||
|
expect(within(details).getByText("Resposta do dono.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Ainda sem resposta?")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing, not even the heading, when no question has an answer", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<HomeFaq items={[{ question: "Pergunta?", answer: "" }]} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states no price, deadline, guarantee or payment policy", () => {
|
||||||
|
const { container } = render(<HomeFaq />);
|
||||||
|
|
||||||
|
expect(container.textContent).not.toMatch(
|
||||||
|
/R\$|garantia|\d+ dias|em até \d+|parcel|entrada de/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Faq, type FaqEntry } from "./Faq";
|
||||||
|
|
||||||
|
export type FaqItem = FaqEntry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perguntas da home. As cinco primeiras só dizem o que o site já sustenta (contato, rodapé, /ia) ou o que o dono
|
||||||
|
* já autorizou ("Quanto custa?": escopo, valor e prazo por escrito depois da primeira conversa, sem número nenhum).
|
||||||
|
* As três últimas estão preparadas, sem resposta: o dono as responde depois e elas passam a aparecer.
|
||||||
|
* Nunca escrever aqui prazo, preço, garantia ou política de pagamento que ele não tenha dito.
|
||||||
|
*/
|
||||||
|
export const FAQ_ITEMS: FaqItem[] = [
|
||||||
|
{
|
||||||
|
question: "Como começa um projeto?",
|
||||||
|
answer:
|
||||||
|
"Com uma conversa para entender o problema, sem precisar chegar com a solução definida. Depois dela, você recebe o escopo e a proposta por escrito.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Quanto custa?",
|
||||||
|
answer:
|
||||||
|
"Depois da primeira conversa você recebe escopo, valor e prazo por escrito, antes de qualquer compromisso.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Você atende fora de Santa Catarina?",
|
||||||
|
answer:
|
||||||
|
"Sim. Minha base é em Garuva, SC, e o atendimento é remoto, para empresas de todo o Brasil.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question:
|
||||||
|
"Dá para continuar um sistema que já existe ou que foi feito com IA?",
|
||||||
|
answer:
|
||||||
|
"Sim. Antes de propor qualquer coisa, avalio o que pode ser aproveitado, quais riscos precisam de correção e o que falta para atender usuários em produção. Nem todo problema exige começar de novo.",
|
||||||
|
link: { href: "/ia", label: "Ver a revisão de app feito com IA" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "O que preciso ter para começar?",
|
||||||
|
answer:
|
||||||
|
"Basta contar o que você quer resolver e, se houver, o prazo que tem em mente. Se já existe um sistema, uma planilha ou um protótipo, mostrar ajuda, mas não é obrigatório.",
|
||||||
|
},
|
||||||
|
{ question: "O código e os acessos ficam comigo?", answer: "" },
|
||||||
|
{ question: "Em nome de quem ficam domínio e hospedagem?", answer: "" },
|
||||||
|
{ question: "Como funciona o pagamento?", answer: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perguntas frequentes da home, antes do contato (`#faq`). O desenho é o de `Faq`, o mesmo das páginas de serviço:
|
||||||
|
* só aparecem as perguntas com resposta; sem nenhuma, a seção não existe.
|
||||||
|
*/
|
||||||
|
export default function HomeFaq({ items = FAQ_ITEMS }: { items?: FaqItem[] }) {
|
||||||
|
return <Faq items={items} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base do pré-carregamento de âncoras da home em HoverPrefetchLink (complemento de
|
||||||
|
* HoverPrefetchLink.test.tsx).
|
||||||
|
*
|
||||||
|
* isHomeAnchor (CC 3): href em texto (é âncora da home | não é) | href em objeto (caminho "/" e hash | caminho "/" sem
|
||||||
|
* hash | outro caminho). O que faltava: o objeto com caminho "/" e SEM hash, que não é âncora e pré-carrega.
|
||||||
|
* HoverPrefetchLink (CC 5): caminho desconhecido | na home | fora da home com âncora da home | fora da home com outro
|
||||||
|
* link, cada um ativado ou não. Já cobertos; aqui entram as trocas de página com o link já ativado.
|
||||||
|
* O dublê de next/navigation sem `usePathname` está em HoverPrefetchLink.nopathname.paths.test.tsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const link = () => screen.getByRole("link", { name: "Ir" });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/privacidade");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isHomeAnchor, vista de fora da home", () => {
|
||||||
|
it.each([
|
||||||
|
["objeto com caminho / e sem hash", { pathname: "/" }],
|
||||||
|
["objeto com caminho / e hash vazio", { pathname: "/", hash: "" }],
|
||||||
|
["objeto com outro caminho e hash", { pathname: "/cases", hash: "#x" }],
|
||||||
|
["objeto só com hash (a página aberta)", { hash: "#x" }],
|
||||||
|
["objeto com caminho / e só a busca", { pathname: "/", query: { a: "b" } }],
|
||||||
|
])(
|
||||||
|
"path: %s -> não é âncora da home, pré-carrega ao chegar",
|
||||||
|
(_name, href) => {
|
||||||
|
render(<HoverPrefetchLink href={href}>Ir</HoverPrefetchLink>);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"objeto com caminho /, hash e busca",
|
||||||
|
{ pathname: "/", hash: "#contact", query: { tipo: "site" } },
|
||||||
|
],
|
||||||
|
["objeto com caminho / e hash sem #", { pathname: "/", hash: "contact" }],
|
||||||
|
["texto com busca e hash", "/?tipo=site#contact"],
|
||||||
|
["texto com busca de vários parâmetros e hash", "/?a=b&c=d#contact"],
|
||||||
|
])("path: %s -> é âncora da home, nunca pré-carrega", (_name, href) => {
|
||||||
|
render(<HoverPrefetchLink href={href}>Ir</HoverPrefetchLink>);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
fireEvent.focus(link());
|
||||||
|
fireEvent.touchStart(link());
|
||||||
|
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["texto com busca e sem hash", "/?tipo=site"],
|
||||||
|
["texto de outra página com hash", "/cases#x"],
|
||||||
|
["texto de outra página com busca e hash", "/cases?a=b#x"],
|
||||||
|
["texto com dois caminhos e hash (//host)", "//cdn.example.com/#x"],
|
||||||
|
])(
|
||||||
|
"path: %s -> não é âncora da home, pré-carrega ao chegar",
|
||||||
|
(_name, href) => {
|
||||||
|
render(<HoverPrefetchLink href={href}>Ir</HoverPrefetchLink>);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HoverPrefetchLink: quando a página aberta muda", () => {
|
||||||
|
it("path: leaves the home anchor, comes back to the home and goes away again -> prefetching follows the page, never the link's earlier state", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
const { rerender } = render(
|
||||||
|
<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>,
|
||||||
|
);
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/servicos");
|
||||||
|
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/cases/lavacao-047");
|
||||||
|
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a home anchor never reached from another page, then the visitor reaches the home -> prefetching only starts with the first touch", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>,
|
||||||
|
);
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
|
||||||
|
fireEvent.touchStart(link());
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type HoverPrefetchLinkType from "./HoverPrefetchLink";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caminho-base de HoverPrefetchLink que só existe com next/navigation sem `usePathname` (os testes que trocam o
|
||||||
|
* módulo por um dublê só com `notFound`): o `navigation.usePathname ?? (() => null)` do módulo cai no `null` e
|
||||||
|
* nada muda no pré-carregamento, nem para uma âncora da home vista de outra página.
|
||||||
|
* Arquivo à parte: o módulo é carregado isolado, depois de registrar o dublê, e não pode ter sido importado antes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function loadWithoutUsePathname() {
|
||||||
|
const React = jest.requireActual<typeof import("react")>("react");
|
||||||
|
let loaded: typeof HoverPrefetchLinkType | undefined;
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
// o mesmo React do teste, para os hooks do componente isolado
|
||||||
|
jest.doMock("react", () => React);
|
||||||
|
jest.doMock("next/navigation", () => ({ notFound: jest.fn() }));
|
||||||
|
loaded = require("./HoverPrefetchLink").default;
|
||||||
|
});
|
||||||
|
jest.dontMock("react");
|
||||||
|
jest.dontMock("next/navigation");
|
||||||
|
if (!loaded) throw new Error("o módulo isolado não carregou");
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HoverPrefetchLink sem usePathname em next/navigation", () => {
|
||||||
|
it("path: unknown page -> a home anchor waits for the first touch and then prefetches as usual", () => {
|
||||||
|
const Isolated = loadWithoutUsePathname();
|
||||||
|
render(<Isolated href="/#contact">Ir</Isolated>);
|
||||||
|
const link = screen.getByRole("link", { name: "Ir" });
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "false");
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link);
|
||||||
|
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unknown page -> a link to another page behaves the same", () => {
|
||||||
|
const Isolated = loadWithoutUsePathname();
|
||||||
|
render(<Isolated href="/cases">Ir</Isolated>);
|
||||||
|
const link = screen.getByRole("link", { name: "Ir" });
|
||||||
|
|
||||||
|
fireEvent.focus(link);
|
||||||
|
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de HoverPrefetchLink (complemento de HoverPrefetchLink.test.tsx):
|
||||||
|
* o aviso do toque ao dono do link, o pré-carregamento que liga uma vez só e não desliga,
|
||||||
|
* e as demais propriedades do link, que passam inalteradas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const link = () => screen.getByRole("link", { name: "Cases" });
|
||||||
|
|
||||||
|
describe("HoverPrefetchLink", () => {
|
||||||
|
it("path: touch with the caller's handler -> the handler runs once, with the event, and prefetching starts", () => {
|
||||||
|
const onTouchStart = jest.fn();
|
||||||
|
render(
|
||||||
|
<HoverPrefetchLink href="/cases" onTouchStart={onTouchStart}>
|
||||||
|
Cases
|
||||||
|
</HoverPrefetchLink>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.touchStart(link());
|
||||||
|
|
||||||
|
expect(onTouchStart).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTouchStart.mock.calls[0][0]).toMatchObject({ type: "touchstart" });
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: hover, then focus, then touch -> prefetching stays on and each handler runs every time", () => {
|
||||||
|
const onMouseEnter = jest.fn();
|
||||||
|
const onFocus = jest.fn();
|
||||||
|
const onTouchStart = jest.fn();
|
||||||
|
render(
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href="/cases"
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
>
|
||||||
|
Cases
|
||||||
|
</HoverPrefetchLink>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
fireEvent.focus(link());
|
||||||
|
fireEvent.touchStart(link());
|
||||||
|
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
expect(onMouseEnter).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onFocus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTouchStart).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: leaving the link after reaching it -> prefetching is not switched off again", () => {
|
||||||
|
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
fireEvent.mouseLeave(link());
|
||||||
|
fireEvent.blur(link());
|
||||||
|
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: events the link does not watch (click, key press) -> prefetching stays off", () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
render(
|
||||||
|
<HoverPrefetchLink href="/cases" onClick={onClick}>
|
||||||
|
Cases
|
||||||
|
</HoverPrefetchLink>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(link());
|
||||||
|
fireEvent.keyDown(link(), { key: "Enter" });
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: other props -> reach the link as they were given", () => {
|
||||||
|
render(
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href="/#home"
|
||||||
|
aria-label="Evandro.dev.br — início"
|
||||||
|
className="flex min-h-11"
|
||||||
|
>
|
||||||
|
Cases
|
||||||
|
</HoverPrefetchLink>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const home = screen.getByRole("link", { name: "Evandro.dev.br — início" });
|
||||||
|
expect(home).toHaveAttribute("href", "/#home");
|
||||||
|
expect(home).toHaveClass("flex", "min-h-11");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const link = () => screen.getByRole("link", { name: "Cases" });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not prefetch when the page opens", () => {
|
||||||
|
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
|
||||||
|
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
expect(link()).toHaveAttribute("href", "/cases");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["mouse", (element: Element) => fireEvent.mouseEnter(element)],
|
||||||
|
["keyboard focus", (element: Element) => fireEvent.focus(element)],
|
||||||
|
["touch", (element: Element) => fireEvent.touchStart(element)],
|
||||||
|
])("starts prefetching when the visitor reaches it by %s", (_label, reach) => {
|
||||||
|
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
|
||||||
|
|
||||||
|
reach(link());
|
||||||
|
|
||||||
|
// null = o comportamento padrão do Link (pré-carrega a rota)
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the callers' own handlers", () => {
|
||||||
|
const onMouseEnter = jest.fn();
|
||||||
|
const onFocus = jest.fn();
|
||||||
|
const onClick = jest.fn();
|
||||||
|
render(
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href="/cases"
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
Cases
|
||||||
|
</HoverPrefetchLink>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
fireEvent.focus(link());
|
||||||
|
fireEvent.click(link());
|
||||||
|
|
||||||
|
expect(onMouseEnter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onFocus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only prefetches the link that was reached, not its siblings", () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>
|
||||||
|
<HoverPrefetchLink href="/servicos">Serviços</HoverPrefetchLink>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
expect(screen.getByRole("link", { name: "Serviços" })).toHaveAttribute(
|
||||||
|
"data-prefetch",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("links para um ponto da home", () => {
|
||||||
|
it.each([["/#contact"], ["/#process"], ["/?tipo=site#contact"]])(
|
||||||
|
"never prefetches %s from another page, even on hover, focus or touch (it would pull the whole home)",
|
||||||
|
(href) => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/privacidade");
|
||||||
|
render(<HoverPrefetchLink href={href}>Cases</HoverPrefetchLink>);
|
||||||
|
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
fireEvent.focus(link());
|
||||||
|
fireEvent.touchStart(link());
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("never prefetches an object href with the home path and a hash from another page", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/cases");
|
||||||
|
render(
|
||||||
|
<HoverPrefetchLink href={{ pathname: "/", hash: "contact" }}>
|
||||||
|
Cases
|
||||||
|
</HoverPrefetchLink>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops prefetching a home anchor that was reached on the home once the visitor moves to another page (the header does not remount)", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
const { rerender } = render(
|
||||||
|
<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>,
|
||||||
|
);
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/cases");
|
||||||
|
rerender(<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>);
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the hover prefetch for a home anchor while on the home itself (it is the same page)", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
render(<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the hover prefetch for a link to another page, and for the home without a hash, from anywhere", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/privacidade");
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>
|
||||||
|
<HoverPrefetchLink href="/">Início</HoverPrefetchLink>
|
||||||
|
<HoverPrefetchLink href="/cases/sev-modaintima#video">
|
||||||
|
Vídeo
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
<HoverPrefetchLink href={{ pathname: "/servicos", hash: "x" }}>
|
||||||
|
Serviços
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of ["Cases", "Início", "Vídeo", "Serviços"]) {
|
||||||
|
const item = screen.getByRole("link", { name });
|
||||||
|
fireEvent.mouseEnter(item);
|
||||||
|
expect(item).toHaveAttribute("data-prefetch", "null");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still runs the caller's handlers on a home anchor seen from another page", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/privacidade");
|
||||||
|
const onMouseEnter = jest.fn();
|
||||||
|
render(
|
||||||
|
<HoverPrefetchLink href="/#contact" onMouseEnter={onMouseEnter}>
|
||||||
|
Cases
|
||||||
|
</HoverPrefetchLink>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
expect(onMouseEnter).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefetches as usual when the router is unknown (no pathname)", () => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue(null as unknown as string);
|
||||||
|
render(<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(link());
|
||||||
|
expect(link()).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import * as navigation from "next/navigation";
|
||||||
|
import { type ComponentProps, useState } from "react";
|
||||||
|
|
||||||
|
type LinkHref = ComponentProps<typeof Link>["href"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O caminho da página aberta. Sem `usePathname` (testes que trocam next/navigation por um dublê só com
|
||||||
|
* `notFound`, por exemplo) vale `null`: sem caminho conhecido, nada muda no pré-carregamento.
|
||||||
|
*/
|
||||||
|
const usePathname: () => string | null = navigation.usePathname ?? (() => null);
|
||||||
|
|
||||||
|
/** Link para um ponto da home: `/#contact` ou `/?tipo=site#contact`. */
|
||||||
|
function isHomeAnchor(href: LinkHref) {
|
||||||
|
if (typeof href === "string") return /^\/(\?[^#]*)?#/.test(href);
|
||||||
|
return href.pathname === "/" && Boolean(href.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link interno que só pré-carrega a rota quando a pessoa chega nele (mouse, teclado ou toque).
|
||||||
|
* O padrão do Link pré-carrega todo link visível ao abrir a página, e o cabeçalho e a faixa de clientes
|
||||||
|
* somavam 15 pedidos disputando a rede na abertura. Com `prefetch={false}` o App Router não pré-carrega nem no hover.
|
||||||
|
* Padrão da documentação do Next: node_modules/next/dist/docs/01-app/02-guides/prefetching.md.
|
||||||
|
*
|
||||||
|
* Fora da home, um link para um ponto dela (`/#contact`) não pré-carrega nem no hover: o pré-carregamento puxaria
|
||||||
|
* a home inteira, com o CSS da vitrine, para quem só passou o mouse (e o navegador avisava "preloaded but not
|
||||||
|
* used"). Na própria home o link leva à página em que a pessoa já está, e o pré-carregamento segue o padrão. O
|
||||||
|
* caminho vem de `usePathname`, e não de uma leitura única no hover: o cabeçalho e o rodapé não remontam entre as
|
||||||
|
* páginas, e um link ativado na home não pode continuar pré-carregando depois que a pessoa sair dela.
|
||||||
|
*/
|
||||||
|
export default function HoverPrefetchLink({
|
||||||
|
onMouseEnter,
|
||||||
|
onFocus,
|
||||||
|
onTouchStart,
|
||||||
|
...props
|
||||||
|
}: Omit<ComponentProps<typeof Link>, "prefetch">) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const homeAnchorFromElsewhere =
|
||||||
|
pathname !== null && pathname !== "/" && isHomeAnchor(props.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
prefetch={active && !homeAnchorFromElsewhere ? null : false}
|
||||||
|
onMouseEnter={(event) => {
|
||||||
|
setActive(true);
|
||||||
|
onMouseEnter?.(event);
|
||||||
|
}}
|
||||||
|
onFocus={(event) => {
|
||||||
|
setActive(true);
|
||||||
|
onFocus?.(event);
|
||||||
|
}}
|
||||||
|
onTouchStart={(event) => {
|
||||||
|
setActive(true);
|
||||||
|
onTouchStart?.(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
theme?: "dark" | "light";
|
||||||
|
className?: string;
|
||||||
|
loading?: "eager" | "lazy";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({
|
||||||
|
theme = "dark",
|
||||||
|
className = "w-48",
|
||||||
|
loading = "lazy",
|
||||||
|
}: Readonly<LogoProps>) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={`/brand/wordmark-${theme}.webp`}
|
||||||
|
alt="Evandro.dev.br"
|
||||||
|
width={1600}
|
||||||
|
height={330}
|
||||||
|
sizes="240px"
|
||||||
|
loading={loading}
|
||||||
|
className={`block h-auto max-w-full ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Projects from "./Projects";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Ver os cases" na coluna dos botões da vitrine: na vitrine fixa o conjunto de aparelhos (`.ds-reel-set`, com a legenda
|
||||||
|
* e os botões no canto esquerdo dele) fica centrado no palco, e esse canto muda com a altura da janela. Projects mede o
|
||||||
|
* canto e dá ao bloco do link o mesmo recuo; sem vitrine fixa (celular, movimento reduzido) o recuo é zero.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch: _prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => <a {...props}>{children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./Section", () => ({
|
||||||
|
Section: ({ children, title }: { children: ReactNode; title: string }) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./showcase/ShowcaseProvider", () => ({
|
||||||
|
ShowcaseProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let pinned = true;
|
||||||
|
jest.mock("./showcase/ProjectReel", () => ({
|
||||||
|
ProjectReel: () => (pinned ? <div className="ds-reel-set" /> : <ul />),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Os cantos esquerdos que a página teria: o do bloco do link e o do conjunto de aparelhos. */
|
||||||
|
const lefts = { exit: 100, set: 503 };
|
||||||
|
|
||||||
|
class FakeResizeObserver {
|
||||||
|
static instances: FakeResizeObserver[] = [];
|
||||||
|
observed = new Set<Element>();
|
||||||
|
constructor(private readonly callback: () => void) {
|
||||||
|
FakeResizeObserver.instances.push(this);
|
||||||
|
}
|
||||||
|
observe(target: Element) {
|
||||||
|
this.observed.add(target);
|
||||||
|
}
|
||||||
|
unobserve(target: Element) {
|
||||||
|
this.observed.delete(target);
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
this.observed.clear();
|
||||||
|
}
|
||||||
|
fire() {
|
||||||
|
this.callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalRect = Element.prototype.getBoundingClientRect;
|
||||||
|
const originalObserver = global.ResizeObserver;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pinned = true;
|
||||||
|
lefts.exit = 100;
|
||||||
|
lefts.set = 503;
|
||||||
|
FakeResizeObserver.instances = [];
|
||||||
|
global.ResizeObserver =
|
||||||
|
FakeResizeObserver as unknown as typeof ResizeObserver;
|
||||||
|
Element.prototype.getBoundingClientRect = function rect(this: Element) {
|
||||||
|
const left = this.classList.contains("ds-reel-set")
|
||||||
|
? lefts.set
|
||||||
|
: this.getAttribute("data-reel-stagger") === "left"
|
||||||
|
? lefts.exit
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
right: left,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
} as DOMRect;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Element.prototype.getBoundingClientRect = originalRect;
|
||||||
|
global.ResizeObserver = originalObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitBlock = () =>
|
||||||
|
screen.getByRole("link", { name: "Ver os cases" })
|
||||||
|
.parentElement as HTMLElement;
|
||||||
|
|
||||||
|
describe("Projects: saída da vitrine", () => {
|
||||||
|
it("indents the link to the left corner of the pinned reel, where the caption and the buttons start", () => {
|
||||||
|
render(<Projects projects={[]} />);
|
||||||
|
|
||||||
|
expect(exitBlock().style.paddingLeft).toBe("403px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the reel when the window changes size (the stage measures itself), and when the reel is observed", () => {
|
||||||
|
render(<Projects projects={[]} />);
|
||||||
|
expect(exitBlock().style.paddingLeft).toBe("403px");
|
||||||
|
|
||||||
|
lefts.set = 567;
|
||||||
|
act(() => {
|
||||||
|
for (const observer of FakeResizeObserver.instances) observer.fire();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitBlock().style.paddingLeft).toBe("467px");
|
||||||
|
// observa a seção (largura da janela) e o conjunto de aparelhos (a barra de cookies muda a altura do palco)
|
||||||
|
const observed = FakeResizeObserver.instances.flatMap((observer) => [
|
||||||
|
...observer.observed,
|
||||||
|
]);
|
||||||
|
expect(observed.some((element) => element.tagName === "SECTION")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
observed.some((element) => element.classList.contains("ds-reel-set")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the link at the start of the column without a pinned reel (phone, reduced motion, short window)", () => {
|
||||||
|
pinned = false;
|
||||||
|
|
||||||
|
render(<Projects projects={[]} />);
|
||||||
|
|
||||||
|
expect(exitBlock().style.paddingLeft).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never indents to the left of its own column, and works where there is no ResizeObserver", () => {
|
||||||
|
lefts.set = 40;
|
||||||
|
global.ResizeObserver = undefined as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
|
render(<Projects projects={[]} />);
|
||||||
|
|
||||||
|
expect(exitBlock().style.paddingLeft).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
getFeaturedProjects,
|
||||||
|
type PortfolioProjectView,
|
||||||
|
} from "@/lib/portfolio-data";
|
||||||
|
import { isClientProject } from "@/lib/proof";
|
||||||
|
import Projects from "./Projects";
|
||||||
|
|
||||||
|
// o Link do Next, trocado por um <a> que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
|
||||||
|
jest.mock("next/link", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
prefetch,
|
||||||
|
...props
|
||||||
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>) => (
|
||||||
|
<a data-prefetch={String(prefetch)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./Section", () => ({
|
||||||
|
Section: ({ children, title }: { children: ReactNode; title: string }) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const providerProjects = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("./showcase/ShowcaseProvider", () => ({
|
||||||
|
ShowcaseProvider: ({
|
||||||
|
children,
|
||||||
|
projects,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
projects: PortfolioProjectView[];
|
||||||
|
}) => {
|
||||||
|
providerProjects(projects);
|
||||||
|
return <div data-testid="showcase-provider">{children}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./showcase/ProjectReel", () => ({
|
||||||
|
ProjectReel: () => <div data-testid="project-reel" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// A página entrega só os projetos de cliente; aqui usamos os reais do portfólio, com uma demo indisponível de propósito
|
||||||
|
const clientProjects: PortfolioProjectView[] = getFeaturedProjects(null)
|
||||||
|
.filter(isClientProject)
|
||||||
|
.map((project, index) => ({
|
||||||
|
...project,
|
||||||
|
demoAvailability: index === 0 ? ("unavailable" as const) : "available",
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Projects", () => {
|
||||||
|
beforeEach(() => providerProjects.mockClear());
|
||||||
|
|
||||||
|
it("presents the client projects once, through the reel only", () => {
|
||||||
|
const { container } = render(<Projects projects={clientProjects} />);
|
||||||
|
|
||||||
|
expect(clientProjects.length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByTestId("project-reel")).toBeInTheDocument();
|
||||||
|
expect(providerProjects).toHaveBeenCalledWith(clientProjects);
|
||||||
|
// sem grade de cards: nem artigos, nem capas provisórias
|
||||||
|
expect(container.querySelector("article")).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-cover, .ds-cover-fallback")).toBeNull();
|
||||||
|
expect(screen.queryByRole("heading", { level: 3 })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the availability badge, the code link and every GitHub link", () => {
|
||||||
|
const { container } = render(<Projects projects={clientProjects} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/Demo indisponível|indisponível na última/i),
|
||||||
|
).toBeNull();
|
||||||
|
expect(screen.queryByText(/Ver codigo|Ver código/i)).toBeNull();
|
||||||
|
expect(screen.queryByText(/Abrir projeto/i)).toBeNull();
|
||||||
|
for (const link of container.querySelectorAll("a")) {
|
||||||
|
expect(link.getAttribute("href") ?? "").not.toMatch(/github\.com/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends with a secondary link to all the cases", () => {
|
||||||
|
render(<Projects projects={clientProjects} />);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "Ver os cases" });
|
||||||
|
expect(link).toHaveAttribute("href", "/cases");
|
||||||
|
// secundário: contorno, sem o fundo verde-limão do contato
|
||||||
|
expect(link.className).toContain("border");
|
||||||
|
expect(link.className).not.toContain("bg-[var(--slash)]");
|
||||||
|
expect(screen.getAllByRole("link")).toEqual([link]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-fetches /cases only when the visitor reaches the link, not when it comes into view", () => {
|
||||||
|
render(<Projects projects={clientProjects} />);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "Ver os cases" });
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "false");
|
||||||
|
fireEvent.mouseEnter(link);
|
||||||
|
expect(link).toHaveAttribute("data-prefetch", "null");
|
||||||
|
});
|
||||||
|
});
|
||||||
+83
-191
@@ -1,207 +1,99 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||||
import { motion } from "motion/react";
|
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||||
import { BentoGrid, BentoGridItem } from "@/components/magicui/bento-grid";
|
import { secondaryButtonDark } from "./button-styles";
|
||||||
import {
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
ExternalLink,
|
import { Section } from "./Section";
|
||||||
Github,
|
import { ProjectReel } from "./showcase/ProjectReel";
|
||||||
Star,
|
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
|
||||||
Zap,
|
|
||||||
Globe,
|
|
||||||
Cpu,
|
|
||||||
Code2,
|
|
||||||
Terminal,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
|
||||||
|
|
||||||
// --- Configuração de Cores (Harmonizado com a seção anterior) ---
|
/**
|
||||||
const CATEGORY_THEMES: Record<string, any> = {
|
* Quanto "Ver os cases" anda para a direita para ficar na coluna da legenda e dos botões da vitrine. Na vitrine fixa
|
||||||
"full-stack": { color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/20", icon: Globe },
|
* (a partir de 1024 px) o conjunto de aparelhos, com a legenda e os botões no canto esquerdo dele, fica centrado no
|
||||||
iot: { color: "text-purple-400", bg: "bg-purple-500/10", border: "border-purple-500/20", icon: Cpu },
|
* palco, e esse canto muda com a altura da janela (o palco mede a si mesmo): só medindo dá para alinhar o link a ele.
|
||||||
api: { color: "text-orange-400", bg: "bg-orange-500/10", border: "border-orange-500/20", icon: Code2 },
|
* Sem vitrine fixa (celular, movimento reduzido, janela baixa) não há conjunto e o link fica no começo da coluna.
|
||||||
web: { color: "text-green-400", bg: "bg-green-500/10", border: "border-green-500/20", icon: Globe },
|
*/
|
||||||
desktop: { color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20", icon: Terminal },
|
function useReelColumnOffset(exitRef: RefObject<HTMLElement | null>) {
|
||||||
};
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
// Funções auxiliares para Schema
|
useEffect(() => {
|
||||||
function getApplicationCategory(category: string): string {
|
const exit = exitRef.current;
|
||||||
const map: Record<string, string> = {
|
const section = exit?.closest("section");
|
||||||
"full-stack": "WebApplication",
|
if (!exit || !section) return;
|
||||||
"iot": "DesktopApplication",
|
let observedSet: Element | null = null;
|
||||||
"api": "WebApplication",
|
|
||||||
"web": "WebApplication",
|
|
||||||
"desktop": "DesktopApplication"
|
|
||||||
};
|
|
||||||
return map[category.toLowerCase()] || "WebApplication";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOperatingSystem(category: string): string {
|
const refresh = () => {
|
||||||
if (category.toLowerCase() === "desktop") return "Windows, Linux";
|
const set = section.querySelector(".ds-reel-set");
|
||||||
return "Web Browser";
|
if (set !== observedSet) {
|
||||||
}
|
if (observedSet) resizes?.unobserve(observedSet);
|
||||||
|
if (set) resizes?.observe(set);
|
||||||
export default function Projects() {
|
observedSet = set;
|
||||||
const { data, loading, error } = usePortfolioData();
|
}
|
||||||
|
// o padding do próprio bloco não muda o `left` dele: a medida é só a distância até o canto do conjunto
|
||||||
const projects = useMemo(() =>
|
setOffset(
|
||||||
data?.projects.filter((p) => p.featured) || [],
|
set
|
||||||
[data]);
|
? Math.max(
|
||||||
|
0,
|
||||||
const projectsJsonLd = useMemo(() => {
|
Math.round(
|
||||||
if (!projects || projects.length === 0) return null;
|
set.getBoundingClientRect().left -
|
||||||
|
exit.getBoundingClientRect().left,
|
||||||
return {
|
),
|
||||||
"@context": "https://schema.org",
|
)
|
||||||
"@type": "ItemList",
|
: 0,
|
||||||
"itemListElement": projects.map((project, index) => ({
|
);
|
||||||
"@type": "ListItem",
|
|
||||||
"position": index + 1,
|
|
||||||
"item": {
|
|
||||||
"@type": "SoftwareApplication",
|
|
||||||
"name": project.title,
|
|
||||||
"description": project.description,
|
|
||||||
"applicationCategory": getApplicationCategory(project.category),
|
|
||||||
"operatingSystem": getOperatingSystem(project.category),
|
|
||||||
"url": project.demoUrl || project.githubUrl || `https://evandro.dev.br/#projects`,
|
|
||||||
"offers": {
|
|
||||||
"@type": "Offer",
|
|
||||||
"price": "0",
|
|
||||||
"priceCurrency": "BRL"
|
|
||||||
},
|
|
||||||
"softwareVersion": "1.0.0",
|
|
||||||
"author": {
|
|
||||||
"@type": "Person",
|
|
||||||
"name": "Evandro Fonseca Junior",
|
|
||||||
"url": "https://evandro.dev.br"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
};
|
};
|
||||||
}, [projects]);
|
const resizes =
|
||||||
|
typeof ResizeObserver === "undefined"
|
||||||
|
? undefined
|
||||||
|
: new ResizeObserver(refresh);
|
||||||
|
|
||||||
if (loading) return <div className="py-20 text-center animate-pulse text-slate-500 font-mono">Loading_Assets...</div>;
|
refresh();
|
||||||
if (error) return <div className="py-20 text-center text-red-500">System_Error: Failed to sync projects.</div>;
|
resizes?.observe(section);
|
||||||
|
// a vitrine fixa chega depois (pacote sob demanda) e sai quando a janela deixa de comportá-la
|
||||||
|
const mutations = new MutationObserver(refresh);
|
||||||
|
mutations.observe(section, { childList: true, subtree: true });
|
||||||
|
return () => {
|
||||||
|
resizes?.disconnect();
|
||||||
|
mutations.disconnect();
|
||||||
|
};
|
||||||
|
}, [exitRef]);
|
||||||
|
|
||||||
return (
|
return offset;
|
||||||
<section id="projects" className="py-32 px-6 relative">
|
|
||||||
{/* Background Decor */}
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-blue-500/5 via-transparent to-transparent pointer-events-none" />
|
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto relative z-10">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="text-left mb-20"
|
|
||||||
>
|
|
||||||
<span className="text-blue-500 font-mono text-sm tracking-[0.3em] uppercase block mb-4">Produção_Intelectual</span>
|
|
||||||
<h2 className="text-5xl md:text-7xl font-black text-white tracking-tighter">
|
|
||||||
Projetos <span className="text-slate-700 italic">Principais</span>
|
|
||||||
</h2>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<BentoGrid className="grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{projects.map((project, index) => {
|
|
||||||
const theme = CATEGORY_THEMES[project.category.toLowerCase()] || CATEGORY_THEMES.web;
|
|
||||||
|
|
||||||
// Lógica de Grid Inteligente:
|
|
||||||
// Index 0: NutriScan (Destaque - 2 colunas)
|
|
||||||
// Index 1: Twins Lab (Complemento - 1 coluna)
|
|
||||||
// Demais: 1 coluna cada (Preenche a linha 2 perfeitamente)
|
|
||||||
const colSpan = index === 0 ? "md:col-span-2" : "md:col-span-1";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BentoGridItem
|
|
||||||
key={project.id}
|
|
||||||
className={`${colSpan} group relative flex flex-col justify-between overflow-hidden rounded-3xl border border-slate-800 bg-slate-900/40 backdrop-blur-xl p-8 transition-all duration-500 hover:border-blue-500/30 min-h-[22rem]`}
|
|
||||||
>
|
|
||||||
{/* Header: Icon & Links */}
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="flex items-start justify-between mb-8">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className={`p-4 rounded-2xl ${theme.bg} border ${theme.border} group-hover:scale-110 transition-transform duration-500`}>
|
|
||||||
<theme.icon className={`w-6 h-6 ${theme.color}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-bold text-white tracking-tight">{project.title}</h3>
|
|
||||||
<p className="text-sm text-slate-500 font-medium">{project.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{project.githubUrl && <ProjectLink href={project.githubUrl} icon={Github} />}
|
|
||||||
{project.demoUrl && <ProjectLink href={project.demoUrl} icon={ExternalLink} highlight />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Badges de Tecnologias */}
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
|
||||||
{project.technologies.slice(0, 5).map((tech: string) => (
|
|
||||||
<span key={tech} className="px-2 py-1 text-[10px] font-mono font-bold bg-slate-950 text-slate-400 border border-slate-800 rounded-md">
|
|
||||||
{tech}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-slate-400 text-sm leading-relaxed mb-6 line-clamp-3 group-hover:line-clamp-none transition-all duration-500">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer: Metrics & Status */}
|
|
||||||
<div className="relative z-10 pt-6 border-t border-slate-800/50 mt-auto flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-[10px] uppercase tracking-widest text-slate-600 font-bold">Complexity</span>
|
|
||||||
<ComplexityStars rating={project.metrics?.complexity || 3} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`w-2 h-2 rounded-full animate-pulse ${project.status === 'Em Produção' ? 'bg-emerald-500' : 'bg-blue-500'}`} />
|
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase">{project.status}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Background Hover Glow */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
|
|
||||||
</BentoGridItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</BentoGrid>
|
|
||||||
|
|
||||||
{/* JSON-LD Schema for Projects */}
|
|
||||||
{projectsJsonLd && (
|
|
||||||
<script
|
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(projectsJsonLd) }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Subcomponentes Auxiliares ---
|
/** Vitrine única da home: recebe só projetos de cliente (o filtro vem de app/page.tsx). */
|
||||||
|
export default function Projects({
|
||||||
|
projects,
|
||||||
|
}: {
|
||||||
|
projects: PortfolioProjectView[];
|
||||||
|
}) {
|
||||||
|
const exitRef = useRef<HTMLDivElement>(null);
|
||||||
|
const offset = useReelColumnOffset(exitRef);
|
||||||
|
|
||||||
function ProjectLink({ href, icon: Icon, highlight }: { href: string; icon: any; highlight?: boolean }) {
|
|
||||||
return (
|
return (
|
||||||
<a
|
<Section
|
||||||
href={href}
|
id="projects"
|
||||||
target="_blank"
|
eyebrow="Projetos"
|
||||||
rel="noopener noreferrer"
|
title="Veja o que já saiu do papel."
|
||||||
className={`p-2 rounded-xl transition-all duration-300 hover:scale-110 ${highlight ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20' : 'bg-slate-900 border border-slate-800 text-slate-500 hover:text-white'
|
description="Projetos feitos para empresas. Abra um case para entender o problema, a solução e as decisões por trás da interface."
|
||||||
}`}
|
dark
|
||||||
|
disableChildrenAnimation
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<ShowcaseProvider projects={projects}>
|
||||||
</a>
|
<ProjectReel />
|
||||||
);
|
</ShowcaseProvider>
|
||||||
}
|
|
||||||
|
|
||||||
function ComplexityStars({ rating }: { rating: number }) {
|
<div
|
||||||
return (
|
ref={exitRef}
|
||||||
<div className="flex gap-1">
|
className="mt-sp-8"
|
||||||
{[...Array(5)].map((_, i) => (
|
data-reel-stagger="left"
|
||||||
<div key={i} className={`w-1.5 h-1.5 rounded-full ${i < rating ? 'bg-yellow-500' : 'bg-slate-800'}`} />
|
style={offset ? { paddingLeft: offset } : undefined}
|
||||||
))}
|
>
|
||||||
</div>
|
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
||||||
|
Ver os cases
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
|
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
||||||
|
import { Reveal } from "./Reveal";
|
||||||
|
import { Section } from "./Section";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entradas por rolagem sem biblioteca de animação: o componente só marca data-shown (ou data-loaded) quando o
|
||||||
|
* IntersectionObserver avisa e o CSS (globals.css) faz a transição. Aqui vale o contrato com o CSS: as classes,
|
||||||
|
* as variáveis e o momento em que as marcas aparecem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Observer = {
|
||||||
|
callback: IntersectionObserverCallback;
|
||||||
|
observed: Element[];
|
||||||
|
};
|
||||||
|
let observers: Observer[] = [];
|
||||||
|
const original = global.IntersectionObserver;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
observers = [];
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 800,
|
||||||
|
});
|
||||||
|
global.IntersectionObserver = class {
|
||||||
|
private readonly record: Observer;
|
||||||
|
constructor(callback: IntersectionObserverCallback) {
|
||||||
|
this.record = { callback, observed: [] };
|
||||||
|
observers.push(this.record);
|
||||||
|
}
|
||||||
|
observe(target: Element) {
|
||||||
|
this.record.observed.push(target);
|
||||||
|
}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.IntersectionObserver = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Avisa que `target` entrou inteiro na janela. */
|
||||||
|
function reveal(target: Element) {
|
||||||
|
act(() => {
|
||||||
|
for (const observer of observers)
|
||||||
|
if (observer.observed.includes(target))
|
||||||
|
observer.callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 1,
|
||||||
|
intersectionRect: { height: 400 },
|
||||||
|
target,
|
||||||
|
} as unknown as IntersectionObserverEntry,
|
||||||
|
],
|
||||||
|
{} as IntersectionObserver,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Reveal", () => {
|
||||||
|
it("renders its children and waits for the viewport to mark them as shown", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Reveal>
|
||||||
|
<p>Conteúdo</p>
|
||||||
|
</Reveal>,
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
|
||||||
|
expect(screen.getByText("Conteúdo")).toBeInTheDocument();
|
||||||
|
expect(wrapper).toHaveClass("ds-reveal");
|
||||||
|
expect(wrapper).not.toHaveAttribute("data-shown");
|
||||||
|
|
||||||
|
reveal(wrapper);
|
||||||
|
expect(wrapper).toHaveAttribute("data-shown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the CSS its offset, duration and delay as custom properties", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Reveal y={40} duration={0.9} delay={0.12} className="extra">
|
||||||
|
x
|
||||||
|
</Reveal>,
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
|
||||||
|
expect(wrapper.style.getPropertyValue("--reveal-y")).toBe("40px");
|
||||||
|
expect(wrapper.style.getPropertyValue("--reveal-duration")).toBe("0.9s");
|
||||||
|
expect(wrapper.style.getPropertyValue("--reveal-delay")).toBe("0.12s");
|
||||||
|
expect(wrapper).toHaveClass("ds-reveal", "extra");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses no inline opacity or transform: the hidden state lives in the CSS, so no script means visible content", () => {
|
||||||
|
const { container } = render(<Reveal>x</Reveal>);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
|
||||||
|
expect(wrapper.style.opacity).toBe("");
|
||||||
|
expect(wrapper.style.transform).toBe("");
|
||||||
|
expect(wrapper.style.clipPath).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DrawLine", () => {
|
||||||
|
it("draws once the line is mostly on screen, and takes its delay from the prop", () => {
|
||||||
|
const { container } = render(<DrawLine delay={0.16} className="x" />);
|
||||||
|
const line = container.firstElementChild as HTMLElement;
|
||||||
|
|
||||||
|
expect(line).toHaveClass("ds-draw-line", "x");
|
||||||
|
expect(line.style.getPropertyValue("--reveal-delay")).toBe("0.16s");
|
||||||
|
expect(line).not.toHaveAttribute("data-shown");
|
||||||
|
|
||||||
|
reveal(line);
|
||||||
|
expect(line).toHaveAttribute("data-shown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LazyRevealImage", () => {
|
||||||
|
it("shows the picture (data-loaded) only after it has loaded", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<LazyRevealImage src="/foto.webp" alt="Foto" className="h-40" />,
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
const image = screen.getByAltText("Foto");
|
||||||
|
|
||||||
|
expect(wrapper).toHaveClass("ds-lazy-reveal", "h-40");
|
||||||
|
expect(wrapper).not.toHaveAttribute("data-loaded");
|
||||||
|
|
||||||
|
// o next/image só repassa o onLoad depois de decodificar a imagem (uma microtarefa)
|
||||||
|
await act(async () => {
|
||||||
|
image.dispatchEvent(new Event("load"));
|
||||||
|
});
|
||||||
|
expect(wrapper).toHaveAttribute("data-loaded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not wait forever for an image that was already complete before the page hydrated", async () => {
|
||||||
|
const complete = jest
|
||||||
|
.spyOn(HTMLImageElement.prototype, "complete", "get")
|
||||||
|
.mockReturnValue(true);
|
||||||
|
const width = jest
|
||||||
|
.spyOn(HTMLImageElement.prototype, "naturalWidth", "get")
|
||||||
|
.mockReturnValue(640);
|
||||||
|
|
||||||
|
let view: ReturnType<typeof render> | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
view = render(<LazyRevealImage src="/foto.webp" alt="Foto" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view?.container.firstElementChild).toHaveAttribute("data-loaded");
|
||||||
|
complete.mockRestore();
|
||||||
|
width.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Section", () => {
|
||||||
|
it("renders the heading, the description and the content, and marks only the header as it enters", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Section
|
||||||
|
id="sobre"
|
||||||
|
eyebrow="Sobre"
|
||||||
|
title="Quem faz"
|
||||||
|
description="Texto de apoio"
|
||||||
|
aside={<span>lateral</span>}
|
||||||
|
>
|
||||||
|
<p>Corpo</p>
|
||||||
|
</Section>,
|
||||||
|
);
|
||||||
|
const header = container.querySelector(".ds-section-header") as HTMLElement;
|
||||||
|
|
||||||
|
expect(container.querySelector("section#sobre")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "Quem faz" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Texto de apoio")).toHaveClass(
|
||||||
|
"ds-section-description",
|
||||||
|
"ds-reveal",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-reveal");
|
||||||
|
// um <div>, não um <aside>: dentro de <main> o <aside> seria um landmark "complementary" fora do topo
|
||||||
|
const side = screen.getByText("lateral").parentElement;
|
||||||
|
expect(side?.tagName).toBe("DIV");
|
||||||
|
expect(side).toHaveClass("ds-reveal");
|
||||||
|
expect(container.querySelector("aside")).toBeNull();
|
||||||
|
expect(screen.queryByRole("complementary")).toBeNull();
|
||||||
|
expect(screen.getByText("Corpo")).toBeInTheDocument();
|
||||||
|
expect(header).not.toHaveAttribute("data-shown");
|
||||||
|
|
||||||
|
reveal(header);
|
||||||
|
expect(header).toHaveAttribute("data-shown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staggers the header items with growing delays", () => {
|
||||||
|
render(
|
||||||
|
<Section
|
||||||
|
eyebrow="Sobre"
|
||||||
|
title="Quem faz"
|
||||||
|
description="Texto"
|
||||||
|
aside={<span>lateral</span>}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</Section>,
|
||||||
|
);
|
||||||
|
const delay = (element: HTMLElement) =>
|
||||||
|
Number.parseFloat(element.style.getPropertyValue("--reveal-delay"));
|
||||||
|
|
||||||
|
const eyebrow = delay(screen.getByText("Sobre"));
|
||||||
|
const description = delay(screen.getByText("Texto"));
|
||||||
|
// o conteúdo lateral é um <div class="ds-reveal">, não um <aside> (ver Section.tsx)
|
||||||
|
const aside = delay(
|
||||||
|
screen.getByText("lateral").closest(".ds-reveal") as HTMLElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(eyebrow).toBeLessThan(description);
|
||||||
|
expect(description).toBeLessThan(aside);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps the body in Reveal, unless the section animates its own children", () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<Section title="Titulo">
|
||||||
|
<p>Corpo</p>
|
||||||
|
</Section>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Corpo").closest(".ds-reveal")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Section title="Titulo" disableChildrenAnimation>
|
||||||
|
<p>Corpo</p>
|
||||||
|
</Section>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Corpo").closest(".ds-reveal")).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-section-shell")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dark skin when asked", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Section title="Titulo" dark>
|
||||||
|
x
|
||||||
|
</Section>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("section")).toHaveClass(
|
||||||
|
"ds-section",
|
||||||
|
"ds-section-dark",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type CSSProperties, type ReactNode, useRef } from "react";
|
||||||
|
import { useInView } from "@/app/hooks/useInView";
|
||||||
|
|
||||||
|
interface RevealProps {
|
||||||
|
children: ReactNode;
|
||||||
|
y?: number;
|
||||||
|
duration?: number;
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conteúdo que sobe e aparece uma vez, quando chega à janela. A animação é CSS (.ds-reveal em globals.css):
|
||||||
|
* só opacidade e deslocamento, que a placa de vídeo compõe sem redesenhar. Sem JavaScript ou com movimento
|
||||||
|
* reduzido, o conteúdo já nasce visível.
|
||||||
|
*/
|
||||||
|
export function Reveal({
|
||||||
|
children,
|
||||||
|
y = 36,
|
||||||
|
duration = 0.8,
|
||||||
|
delay = 0,
|
||||||
|
className = "",
|
||||||
|
}: RevealProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const shown = useInView(ref);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`ds-reveal ${className}`.trim()}
|
||||||
|
data-shown={shown ? "" : undefined}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--reveal-y": `${y}px`,
|
||||||
|
"--reveal-duration": `${duration}s`,
|
||||||
|
"--reveal-delay": `${delay}s`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loaded 100 of 437 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user