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
|
||||
/coverage
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -39,3 +41,23 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
corepack [email protected] install --frozen-lockfile
|
||||
corepack [email protected] 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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
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`.
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
+120
-177
@@ -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";
|
||||
import { LucideIcon, Brain, Zap, Target, Rocket, Users, Lightbulb, Code, Shield, Terminal, Cpu } from "lucide-react";
|
||||
import Squares from "@/components/Squares";
|
||||
|
||||
// --- Types & Interfaces (Tipagem Forte para Previsibilidade) ---
|
||||
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[] = [
|
||||
/**
|
||||
* 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
|
||||
* aparece só no primeiro cartão: antes ela se repetia em cinco textos ("Sem intermediários", "Uma pessoa cuida",
|
||||
* "Quem responde pelo projeto", "Você acompanha as decisões" e "Você fala com quem desenvolve", que segue só no hero).
|
||||
*/
|
||||
const STORY_DATA = [
|
||||
{
|
||||
id: "KERNEL_INIT",
|
||||
icon: Brain,
|
||||
title: "Engenharia de Fundamentos",
|
||||
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.",
|
||||
gradient: "from-blue-600 to-cyan-500",
|
||||
accent: "text-blue-400",
|
||||
label: "Quem desenvolve",
|
||||
title: "Quem conversa com você escreve o código",
|
||||
content:
|
||||
"A mesma pessoa entende o problema, decide a estrutura e desenvolve o sistema, do começo ao fim.",
|
||||
proof:
|
||||
"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",
|
||||
icon: Zap,
|
||||
title: "Entrega sob Restrições",
|
||||
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.",
|
||||
gradient: "from-purple-600 to-pink-600",
|
||||
accent: "text-purple-400",
|
||||
label: "Sem começar do zero",
|
||||
title: "Aproveito o que você já tem",
|
||||
content:
|
||||
"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.",
|
||||
proof:
|
||||
"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[] = [
|
||||
{
|
||||
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>
|
||||
);
|
||||
};
|
||||
/** Atraso (s) de cada cartão: entram em escada, como na vitrine de serviços. */
|
||||
const cardDelay = (index: number) => 0.08 * (index + 1);
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<section id="about" className="relative py-32">
|
||||
{/* Background Gradients */}
|
||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
|
||||
<div className="absolute top-[-10%] right-[-10%] w-[600px] h-[600px] bg-blue-900/10 rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] left-[-10%] w-[600px] h-[600px] bg-purple-900/10 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div className="container max-w-6xl mx-auto px-6 relative z-10">
|
||||
<SectionHeader />
|
||||
|
||||
<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} />
|
||||
))}
|
||||
<Section
|
||||
id="about"
|
||||
eyebrow="Quem conduz o projeto"
|
||||
title="Evandro Fonseca Junior"
|
||||
description="Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas."
|
||||
disableChildrenAnimation
|
||||
aside={
|
||||
<div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
|
||||
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-slash" />
|
||||
Garuva, SC · Atendimento remoto
|
||||
</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">
|
||||
{VALUES_DATA.map((val, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
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"
|
||||
}
|
||||
>
|
||||
{/* 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 */}
|
||||
<div className="ds-grid-2 items-start">
|
||||
<div className="flex flex-col gap-sp-6">
|
||||
{STORY_DATA.map((step, index) => (
|
||||
<Reveal
|
||||
key={step.title}
|
||||
y={20}
|
||||
duration={0.8}
|
||||
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">
|
||||
<val.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<h4 className="text-white font-bold mb-2 text-sm uppercase tracking-wide group-hover:text-blue-200 transition-colors">{val.title}</h4>
|
||||
<p className="text-slate-500 text-xs leading-relaxed group-hover:text-slate-400 transition-colors">{val.description}</p>
|
||||
</motion.div>
|
||||
<article className="ds-card cv-press cv-lift flex-1">
|
||||
<div className="ds-kicker">{step.label}</div>
|
||||
<DrawLine delay={0.03} />
|
||||
<h3 className="ds-card-title">{step.title}</h3>
|
||||
<p className="ds-copy" data-reel="right">
|
||||
{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>
|
||||
|
||||
{/* SEO Semantic Content - Visible but subtle */}
|
||||
<div className="mt-24 pt-8 border-t border-slate-900">
|
||||
<div className="flex items-center justify-center gap-2 text-slate-700 text-xs font-mono uppercase tracking-widest mb-4">
|
||||
<Cpu className="w-3 h-3" />
|
||||
<span>System_Signature</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-700 text-center max-w-2xl mx-auto leading-relaxed font-mono">
|
||||
Evandro Fonseca Junior :: Engenheiro Full-Stack <br />
|
||||
Next.js / React / Angular / Node.js / Tauri / PostgreSQL / IoT <br />
|
||||
Garuva, Santa Catarina, Brasil :: Lat: -26.0264, Long: -48.8544
|
||||
</p>
|
||||
</div>
|
||||
<Reveal
|
||||
y={20}
|
||||
duration={0.8}
|
||||
delay={cardDelay(STORY_DATA.length)}
|
||||
className="lg:sticky lg:top-28"
|
||||
>
|
||||
<article className="ds-card cv-press cv-lift">
|
||||
<figure>
|
||||
<LazyRevealImage
|
||||
src={profilePhoto}
|
||||
alt="Retrato de Evandro Fonseca Junior"
|
||||
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>
|
||||
</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>"),
|
||||
);
|
||||
});
|
||||
});
|
||||
+424
-184
@@ -1,223 +1,463 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Send, User, Mail, MessageSquare, Sparkles } from "lucide-react";
|
||||
import { ShimmerButton as MagicShimmerButton } from "@/components/magicui/shimmer-button";
|
||||
import Squares from "@/components/Squares";
|
||||
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
|
||||
import type { FormEvent, MouseEvent, ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics";
|
||||
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 ---
|
||||
// Substitua pelo seu número real: CódigoPaís + DDD + Número (sem espaços ou símbolos)
|
||||
const WHATSAPP_NUMBER = "5547992742365";
|
||||
type ContactField = "name" | "email" | "message";
|
||||
type FieldErrors = Partial<Record<ContactField, string>>;
|
||||
|
||||
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;
|
||||
email: string;
|
||||
subject: 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)
|
||||
* Estendemos a interface para aceitar propriedades de estilo customizadas.
|
||||
* Abre o WhatsApp em outra aba. Sem "noopener" na chamada, porque com ele o window.open sempre devolve null
|
||||
* 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> {
|
||||
shimmerColor?: string;
|
||||
shimmerSize?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
disabled?: boolean;
|
||||
function openWhatsApp(url: string) {
|
||||
const tab = tryOpenTab(url);
|
||||
if (tab) {
|
||||
if (detachOpener(tab)) return;
|
||||
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,
|
||||
className,
|
||||
shimmerColor = "#3b82f6",
|
||||
shimmerSize = "0.1em",
|
||||
...props
|
||||
}: ExtendedShimmerProps) => {
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
/** 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 (
|
||||
<MagicShimmerButton
|
||||
{...props}
|
||||
className={className}
|
||||
// @ts-ignore - Ignora restrição de tipos da lib original se necessário
|
||||
shimmerColor={shimmerColor}
|
||||
shimmerSize={shimmerSize}
|
||||
>
|
||||
{children}
|
||||
</MagicShimmerButton>
|
||||
<div>
|
||||
<label htmlFor={id} className={labelClassName}>
|
||||
{label}
|
||||
</label>
|
||||
{children({
|
||||
id,
|
||||
"aria-invalid": error ? true : undefined,
|
||||
"aria-describedby": description || undefined,
|
||||
})}
|
||||
{error && (
|
||||
<p id={errorId} className={errorClassName}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default function Contact() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||
useEffect(() => {
|
||||
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||
}, []);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "full-stack",
|
||||
subject: DEFAULT_CONTACT_SUBJECT as string,
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
// Definimos os emojis via Unicode Escape para máxima compatibilidade
|
||||
const icons = {
|
||||
rocket: "\u{1F680}",
|
||||
person: "\u{1F464}",
|
||||
email: "\u{1F4E7}",
|
||||
tools: "\u{1F6E0}",
|
||||
chat: "\u{1F4AC}"
|
||||
// O tipo de projeto pode vir da oferta escolhida na home: pelo endereço (`/?tipo=site#contact`, ao chegar de outra
|
||||
// página) ou pelo evento que o botão da oferta dispara na própria página. Tipo desconhecido não muda nada.
|
||||
useEffect(() => {
|
||||
const choose = (kind: string | null | undefined) => {
|
||||
const subject = contactSubjectForKind(kind);
|
||||
if (!subject) return;
|
||||
setFormData((current) => ({ ...current, subject }));
|
||||
setChosenOffer(contactKindForSubject(subject) ?? null);
|
||||
};
|
||||
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*
|
||||
|
||||
*Origem:* Portfólio Profissional
|
||||
*Prioridade:* Alta (Lead Qualificado)
|
||||
|
||||
---
|
||||
*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 updateField = (field: ContactField | "subject", value: string) => {
|
||||
setFormData((current) => ({ ...current, [field]: value }));
|
||||
if (field === "subject") setChosenOffer(null);
|
||||
if (field !== "subject" && errors[field]) {
|
||||
setErrors((current) => ({ ...current, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const inputStyles =
|
||||
"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 labelStyles =
|
||||
"text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 flex items-center gap-2 transition-colors group-hover:text-slate-400";
|
||||
/** Mostra os erros, leva o foco ao primeiro campo inválido e diz se há algum. */
|
||||
const hasInvalidField = (form: HTMLFormElement | null) => {
|
||||
const found = validate(formData);
|
||||
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 (
|
||||
<section id="contact" className="w-full py-24 relative">
|
||||
|
||||
|
||||
<div className="w-full max-w-4xl mx-auto px-6 relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative bg-zinc-900/40 backdrop-blur-xl border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<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" />
|
||||
|
||||
<div className="mb-10 text-center space-y-2">
|
||||
<h2 className="text-3xl md:text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-b from-white to-white/60">
|
||||
Vamos Construir o Futuro
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm md:text-base max-w-lg mx-auto">
|
||||
Descreva seu desafio técnico. Retorno com uma arquitetura sólida em até 24h.
|
||||
<section
|
||||
id="contact"
|
||||
aria-labelledby="contact-title"
|
||||
className="ds-section ds-section-dark"
|
||||
>
|
||||
<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">
|
||||
<div className="min-w-0">
|
||||
<p className="ds-eyebrow">Contato</p>
|
||||
<h2
|
||||
id="contact-title"
|
||||
className="ds-section-title mt-sp-4 max-w-[19ch] text-balance"
|
||||
>
|
||||
Vamos conversar sobre o que sua empresa precisa.
|
||||
</h2>
|
||||
<p className="mt-sp-5 max-w-[44ch] font-display text-base leading-8 text-white/70">
|
||||
Conte o que você quer resolver. Pode ser um projeto novo, uma
|
||||
melhoria ou uma integração. Não precisa chegar com a solução
|
||||
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>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8 relative">
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3 group">
|
||||
<label className={labelStyles}>
|
||||
<User className="w-3 h-3 text-blue-500" /> NOME COMPLETO
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Ex: Alan Turing"
|
||||
className={inputStyles}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 group">
|
||||
<label className={labelStyles}>
|
||||
<Mail className="w-3 h-3 text-blue-500" /> E-MAIL CORPORATIVO
|
||||
</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 className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
|
||||
<WhatsAppLink
|
||||
placement="contact_direct"
|
||||
service={directSubject}
|
||||
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
|
||||
>
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
|
||||
<WhatsAppMark className="h-6 w-6" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display">
|
||||
<span className="block text-[15px] font-semibold text-white group-hover:text-[#25d366]">
|
||||
Prefere conversar direto?
|
||||
</span>
|
||||
<span className="mt-1 block text-[15px] text-white/65">
|
||||
WhatsApp · {whatsAppDisplay}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[#25d366]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</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">
|
||||
<CustomShimmerButton
|
||||
type="submit"
|
||||
className="w-full h-14 rounded-xl shadow-lg shadow-blue-500/20"
|
||||
shimmerColor="#60a5fa"
|
||||
shimmerSize="0.1em"
|
||||
disabled={isSubmitting}
|
||||
<form
|
||||
id={CONTACT_FORM_ID}
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
aria-labelledby="contact-form-title"
|
||||
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
|
||||
>
|
||||
<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">
|
||||
{isSubmitting ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
Prepare sua mensagem
|
||||
</h3>
|
||||
<p className="mt-1 font-display text-[15px] leading-6 text-white/65">
|
||||
Você revisa no WhatsApp antes de enviar.
|
||||
</p>
|
||||
</footer>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
</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>
|
||||
</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 (
|
||||
<>
|
||||
<Script
|
||||
src="/sys/libs/vendor-utils.js"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="google-analytics" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', '${GA_MEASUREMENT_ID}', {
|
||||
transport_url: window.location.origin + '/sys/api/vitals',
|
||||
cookie_flags: 'SameSite=None;Secure',
|
||||
allow_ad_personalization_signals: false,
|
||||
anonymize_ip: true,
|
||||
page_location: window.location.href,
|
||||
page_title: document.title
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
{consent === "granted" && process.env.NODE_ENV === "production" && (
|
||||
<Script
|
||||
id="google-analytics-script"
|
||||
data-testid="google-analytics-script"
|
||||
src={GA_SCRIPT_SRC}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
{barOpen && (
|
||||
<section
|
||||
ref={barRef}
|
||||
id={COOKIE_BAR_ID}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label="Cookies e privacidade"
|
||||
aria-describedby="analytics-consent-description"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && preferencesOpen) closePreferences();
|
||||
}}
|
||||
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.");
|
||||
});
|
||||
});
|
||||
+280
-151
@@ -1,171 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MenuIcon, XIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { COOKIE_BAR_ID } from "@/lib/analytics";
|
||||
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
|
||||
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
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
const MENU_ID = "site-navigation-menu";
|
||||
const CTA_LABEL = "Falar sobre meu projeto";
|
||||
/** 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). */
|
||||
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[] = [
|
||||
{ href: "#home", label: "Home" },
|
||||
{ href: "#about", label: "Sobre" },
|
||||
{ href: "#skills", label: "Habilidades" },
|
||||
{ href: "#experience", label: "Experiência" },
|
||||
{ href: "#projects", label: "Projetos" },
|
||||
{ href: "#contact", label: "Contato" },
|
||||
];
|
||||
/** 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. */
|
||||
function trapTab(event: KeyboardEvent, header: HTMLElement) {
|
||||
const focusables = menuFocusables(header);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const [edge, opposite] = event.shiftKey ? [first, last] : [last, first];
|
||||
const active = document.activeElement;
|
||||
const leaving =
|
||||
active === edge || !focusables.some((element) => element === active);
|
||||
if (!leaving || !opposite) return;
|
||||
|
||||
export function Header() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
event.preventDefault();
|
||||
opposite.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
|
||||
* já com o assunto da página; na home e nas demais páginas leva ao formulário da home.
|
||||
*/
|
||||
function ContactCta({
|
||||
className,
|
||||
placement,
|
||||
viaWhatsApp,
|
||||
subject,
|
||||
onNavigate,
|
||||
}: {
|
||||
className: string;
|
||||
placement: string;
|
||||
viaWhatsApp: boolean;
|
||||
subject?: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
if (viaWhatsApp) {
|
||||
return (
|
||||
<WhatsAppLink
|
||||
className={className}
|
||||
service={subject}
|
||||
placement={placement}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{CTA_LABEL}
|
||||
</WhatsAppLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HoverPrefetchLink
|
||||
href="/#contact"
|
||||
className={className}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{CTA_LABEL}
|
||||
</HoverPrefetchLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({
|
||||
whatsAppContexts,
|
||||
}: {
|
||||
/** Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx), para o WhatsApp falar da página aberta. */
|
||||
whatsAppContexts?: WhatsAppContexts;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const viaWhatsApp = headerOpensWhatsApp(pathname);
|
||||
const subject = whatsAppSubjectForPath(pathname, whatsAppContexts);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Hook de detecção de scroll para mudança de estado do Header
|
||||
// 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(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 20);
|
||||
setMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
let wasScrolled = false;
|
||||
const updateScrolled = () => {
|
||||
const nextScrolled = window.scrollY > 0;
|
||||
if (nextScrolled === wasScrolled) return;
|
||||
wasScrolled = nextScrolled;
|
||||
setScrolled(nextScrolled);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<motion.header
|
||||
initial={{ y: -100 }}
|
||||
animate={{ y: 0 }}
|
||||
className={cn(
|
||||
"fixed top-0 left-0 right-0 z-[100] transition-all duration-500 ease-in-out px-6 py-4",
|
||||
scrolled
|
||||
? "bg-black/40 backdrop-blur-xl border-b border-white/5 py-3"
|
||||
: "bg-transparent py-6"
|
||||
)}
|
||||
>
|
||||
<nav className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
{/* Logo com Identidade Visual de Engenheiro */}
|
||||
<motion.a
|
||||
href="#home"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector("#home");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
<Image
|
||||
src="/edev_logo.svg"
|
||||
alt="Evandro.dev Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xl font-black tracking-tighter text-white">
|
||||
EVANDRO<span className="text-blue-500">.DEV</span>
|
||||
</span>
|
||||
</motion.a>
|
||||
<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 */}
|
||||
<div className="hidden md:flex items-center gap-1 bg-white/5 p-1 rounded-full border border-white/10 backdrop-blur-md">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(item.href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
data-menu-trap-skip=""
|
||||
className="hidden items-center xl:flex"
|
||||
>
|
||||
<ul className="flex list-none items-center gap-sp-4">
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
<li key={item.href}>
|
||||
<HoverPrefetchLink
|
||||
href={item.href}
|
||||
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}
|
||||
</HoverPrefetchLink>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Botão de Ação CTA (Call to Action) */}
|
||||
<div className="hidden md:block">
|
||||
<a
|
||||
href="/Profile.pdf"
|
||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
||||
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"
|
||||
>
|
||||
Download CV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Mobile Toggle */}
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="md:hidden p-2 text-white bg-white/5 rounded-lg border border-white/10"
|
||||
>
|
||||
{isMenuOpen ? <XIcon size={20} /> : <MenuIcon size={20} />}
|
||||
</button>
|
||||
</ul>
|
||||
</nav>
|
||||
</motion.header>
|
||||
|
||||
{/* Mobile Menu Overlay com Framer Motion para fluidez */}
|
||||
<AnimatePresence>
|
||||
{isMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: "100%" }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed inset-0 z-[110] bg-black/95 backdrop-blur-2xl md:hidden flex flex-col items-center justify-center gap-8"
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="hidden xl:block" data-menu-trap-skip="">
|
||||
<ContactCta
|
||||
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)]"
|
||||
placement="header"
|
||||
viaWhatsApp={viaWhatsApp}
|
||||
subject={subject}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
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}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className="absolute top-8 right-8 p-4 text-white/50 hover:text-white"
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileMenuOpen ? (
|
||||
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
|
||||
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
|
||||
<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">
|
||||
<div className="ds-section-shell flex flex-1 flex-col">
|
||||
<nav
|
||||
id={MENU_ID}
|
||||
aria-label="Navegação mobile"
|
||||
className="flex flex-1 flex-col"
|
||||
>
|
||||
<XIcon size={32} />
|
||||
</button>
|
||||
|
||||
{navItems.map((item, i) => (
|
||||
<motion.a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsMenuOpen(false);
|
||||
const element = document.querySelector(item.href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}}
|
||||
className="text-3xl font-black text-white uppercase tracking-tighter hover:text-blue-500 transition-colors cursor-pointer"
|
||||
>
|
||||
{item.label}
|
||||
</motion.a>
|
||||
))}
|
||||
<motion.a
|
||||
href="/Profile.pdf"
|
||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
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>
|
||||
</>
|
||||
<ul className="flex list-none flex-col">
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
<li key={item.href} className="border-b border-white/10">
|
||||
<HoverPrefetchLink
|
||||
href={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"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
{item.label}
|
||||
</HoverPrefetchLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-auto pt-sp-8">
|
||||
<ContactCta
|
||||
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}
|
||||
subject={subject}
|
||||
onNavigate={closeMobileMenu}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
+176
-124
@@ -1,145 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { AuroraText } from "@/components/magicui/aurora-text";
|
||||
import { AnimatedGradientText } from "@/components/magicui/animated-gradient-text";
|
||||
import { Github, Linkedin, MessageCircle, Mail, MapPin, Terminal } from "lucide-react";
|
||||
import TextType from "@/components/TextType";
|
||||
import { type CSSProperties, Fragment } from "react";
|
||||
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
|
||||
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
|
||||
import { HOME_SUBJECT } from "@/lib/whatsapp";
|
||||
import ClientStrip, { type ClientLink } from "./ClientStrip";
|
||||
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 getGreetingMessage = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 8 && hour < 12) return "Olá! Vi seu portfólio. Podemos conversar?";
|
||||
if (hour >= 13 && hour < 18) return "Boa tarde! Vi seu portfólio. Podemos conversar?";
|
||||
return "Olá! Vi seu portfólio. Podemos conversar quando estiver disponível?";
|
||||
};
|
||||
const SOCIAL_LINKS = [
|
||||
{
|
||||
href: LINKEDIN_URL,
|
||||
label: "LinkedIn",
|
||||
onClick: () => trackSocialClick({ network: "linkedin" }),
|
||||
},
|
||||
{
|
||||
href: `mailto:${CONTACT_EMAIL}`,
|
||||
label: "Email",
|
||||
onClick: () => trackEmailClick({ placement: "hero" }),
|
||||
},
|
||||
];
|
||||
|
||||
const generateWhatsAppUrl = () => {
|
||||
const message = encodeURIComponent(getGreetingMessage());
|
||||
return `https://wa.me/5547992742365?text=${message}`;
|
||||
};
|
||||
/**
|
||||
* Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json
|
||||
* 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 (
|
||||
<section id="home" className="relative min-h-screen flex items-center justify-center overflow-hidden pt-28 md:pt-20">
|
||||
{/* 1. Background Engineering: Grid Pattern & Radial Shading */}
|
||||
<div className="absolute inset-0 z-0 pointer-events-none">
|
||||
<div className="absolute inset-0 bg-radial-at-t from-blue-500/10 via-transparent to-transparent" />
|
||||
</div>
|
||||
<section
|
||||
id="home"
|
||||
className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
|
||||
>
|
||||
<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="lg:col-span-7 text-left">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<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">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-500"></span>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-cyan-300 tracking-wide uppercase">
|
||||
Systems Engineer available
|
||||
</span>
|
||||
</AnimatedGradientText>
|
||||
|
||||
<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"
|
||||
<div className="hero-in mt-sp-5 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
|
||||
{/* Abre o WhatsApp direto, com o assunto da home: o formulário continua na seção de contato, ao fim da página. */}
|
||||
<WhatsAppLink
|
||||
id={HERO_CTA_ID}
|
||||
service={HOME_SUBJECT}
|
||||
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]"
|
||||
>
|
||||
Falar sobre meu projeto
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
className="h-4 w-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
Iniciar Projeto
|
||||
</motion.a>
|
||||
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||
</svg>
|
||||
</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">
|
||||
{[
|
||||
{ icon: Github, link: "https://github.com/evandrodevbr", rel: "me" },
|
||||
{ icon: Linkedin, link: "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/", rel: "me" },
|
||||
{ icon: Mail, link: "mailto:[email protected]" }
|
||||
].map((social, i) => (
|
||||
<ClientStrip
|
||||
clients={clients}
|
||||
years={stats?.years}
|
||||
className="hero-in mt-sp-3 [--d:.75s]"
|
||||
/>
|
||||
|
||||
<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
|
||||
key={i}
|
||||
href={social.link}
|
||||
rel={social.rel ? `${social.rel} noopener noreferrer` : "noopener noreferrer"}
|
||||
target={social.link.startsWith("mailto:") ? undefined : "_blank"}
|
||||
className="p-3 rounded-lg border border-slate-800 text-slate-400 hover:text-white hover:border-slate-600 transition-all"
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
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>
|
||||
))}
|
||||
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
|
||||
Santa Catarina · Atendimento remoto
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-2 text-slate-500 text-sm">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>Garuva, SC | Remote Worldwide</span>
|
||||
</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"
|
||||
</div>
|
||||
<p
|
||||
aria-hidden="true"
|
||||
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"
|
||||
>
|
||||
{/* Elementos Orbitais (Vibe Engenharia) */}
|
||||
<div className="absolute inset-0 rounded-full border border-dashed border-blue-500/30 animate-[spin_20s_linear_infinite]" />
|
||||
<div className="absolute inset-10 rounded-full border border-blue-500/10 animate-[spin_15s_linear_infinite_reverse]" />
|
||||
|
||||
{/* 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>
|
||||
Role para mergulhar
|
||||
<span className="hero-cue-line" />
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+84
-192
@@ -1,207 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { BentoGrid, BentoGridItem } from "@/components/magicui/bento-grid";
|
||||
import {
|
||||
ExternalLink,
|
||||
Github,
|
||||
Star,
|
||||
Zap,
|
||||
Globe,
|
||||
Cpu,
|
||||
Code2,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import { secondaryButtonDark } from "./button-styles";
|
||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||
import { Section } from "./Section";
|
||||
import { ProjectReel } from "./showcase/ProjectReel";
|
||||
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
|
||||
|
||||
// --- Configuração de Cores (Harmonizado com a seção anterior) ---
|
||||
const CATEGORY_THEMES: Record<string, any> = {
|
||||
"full-stack": { color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/20", icon: Globe },
|
||||
iot: { color: "text-purple-400", bg: "bg-purple-500/10", border: "border-purple-500/20", icon: Cpu },
|
||||
api: { color: "text-orange-400", bg: "bg-orange-500/10", border: "border-orange-500/20", icon: Code2 },
|
||||
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 },
|
||||
};
|
||||
/**
|
||||
* Quanto "Ver os cases" anda para a direita para ficar na coluna da legenda e dos botões da vitrine. Na vitrine fixa
|
||||
* (a partir de 1024 px) o conjunto de aparelhos, com a legenda e os botões no canto esquerdo dele, fica centrado no
|
||||
* 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.
|
||||
* Sem vitrine fixa (celular, movimento reduzido, janela baixa) não há conjunto e o link fica no começo da coluna.
|
||||
*/
|
||||
function useReelColumnOffset(exitRef: RefObject<HTMLElement | null>) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
// Funções auxiliares para Schema
|
||||
function getApplicationCategory(category: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"full-stack": "WebApplication",
|
||||
"iot": "DesktopApplication",
|
||||
"api": "WebApplication",
|
||||
"web": "WebApplication",
|
||||
"desktop": "DesktopApplication"
|
||||
};
|
||||
return map[category.toLowerCase()] || "WebApplication";
|
||||
}
|
||||
useEffect(() => {
|
||||
const exit = exitRef.current;
|
||||
const section = exit?.closest("section");
|
||||
if (!exit || !section) return;
|
||||
let observedSet: Element | null = null;
|
||||
|
||||
function getOperatingSystem(category: string): string {
|
||||
if (category.toLowerCase() === "desktop") return "Windows, Linux";
|
||||
return "Web Browser";
|
||||
}
|
||||
|
||||
export default function Projects() {
|
||||
const { data, loading, error } = usePortfolioData();
|
||||
|
||||
const projects = useMemo(() =>
|
||||
data?.projects.filter((p) => p.featured) || [],
|
||||
[data]);
|
||||
|
||||
const projectsJsonLd = useMemo(() => {
|
||||
if (!projects || projects.length === 0) return null;
|
||||
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}))
|
||||
const refresh = () => {
|
||||
const set = section.querySelector(".ds-reel-set");
|
||||
if (set !== observedSet) {
|
||||
if (observedSet) resizes?.unobserve(observedSet);
|
||||
if (set) resizes?.observe(set);
|
||||
observedSet = set;
|
||||
}
|
||||
// o padding do próprio bloco não muda o `left` dele: a medida é só a distância até o canto do conjunto
|
||||
setOffset(
|
||||
set
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
set.getBoundingClientRect().left -
|
||||
exit.getBoundingClientRect().left,
|
||||
),
|
||||
)
|
||||
: 0,
|
||||
);
|
||||
};
|
||||
}, [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>;
|
||||
if (error) return <div className="py-20 text-center text-red-500">System_Error: Failed to sync projects.</div>;
|
||||
refresh();
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
return offset;
|
||||
}
|
||||
|
||||
// --- 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 (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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'
|
||||
}`}
|
||||
<Section
|
||||
id="projects"
|
||||
eyebrow="Projetos"
|
||||
title="Veja o que já saiu do papel."
|
||||
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" />
|
||||
</a>
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectReel />
|
||||
</ShowcaseProvider>
|
||||
|
||||
<div
|
||||
ref={exitRef}
|
||||
className="mt-sp-8"
|
||||
data-reel-stagger="left"
|
||||
style={offset ? { paddingLeft: offset } : undefined}
|
||||
>
|
||||
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
||||
Ver os cases
|
||||
</HoverPrefetchLink>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ComplexityStars({ rating }: { rating: number }) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className={`w-1.5 h-1.5 rounded-full ${i < rating ? 'bg-yellow-500' : 'bg-slate-800'}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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