Compare commits

...
Author SHA1 Message Date
evandro 67b32fcab3 feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s
Etapa 1 inteira (logica com 100% de cobertura, cena 3D, toque, sons, bancada de desempenho, e2e) e a base da etapa 2 (mapa novo no Tiled, texturas, imagens dos itens, sons conferidos, golpe recortado e medido). Pagina /jogo fora do menu e do sitemap ate a medicao no celular.
2026-11-10 19:37:10 -03:00
evandro c7d09d63d4 feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s
Hero e movimento
- cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py):
  marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao
  cursor e, parada no topo, passa sozinha pelos estados com legenda
- três níveis de aparelho (full, lite, static); sem WebGL fica o pôster
- título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
- faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções
- rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa
  compartilhada entre a lista de cases e o case

Ofertas
- atendimento no WhatsApp com IA e nota fiscal para a reforma tributária
  (páginas de serviço, cartões da home, formulário e aviso com data de validade)
- página /ia atualizada, com fontes
- peças 3D em loop nos cartões e topos de página (3d/pecas.py)

Design
- hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta
- um destino por rótulo: "Falar sobre meu projeto" leva ao formulário,
  "Chamar no WhatsApp" abre o WhatsApp
- logo montado em blocos, 404 redesenhado, grades sem célula vazia
- ícones do PWA e manifesto novos

Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
2026-11-10 03:48:25 -03:00
evandro 4671837c44 fix(deps): atualiza next para 16.4.3 (corrige GHSA-2464-fhr4-w9xw, GHSA-673q-mjm6-r5xr e GHSA-p3m5-59mp-jv5r)
CI / audit (push) Successful in 10m16s
CI / verify (push) Successful in 11m43s
CI / deploy (push) Successful in 1m26s
CI / e2e (push) Successful in 16m8s
2026-10-24 18:02:03 -03:00
evandro 8e5d548598 docs(readme): redesenha o README com hero em SVG [skip ci] 2026-10-24 05:07:38 -03:00
evandro 558c3d5b48 test(e2e): limite do Tab sobe para 37 com o quinto cliente na faixa do hero
CI / deploy (push) Successful in 1m15s
CI / audit (push) Failing after 5m36s
CI / verify (push) Successful in 11m44s
CI / e2e (push) Successful in 16m10s
2026-10-24 05:07:36 -03:00
evandro bcdf0099c5 feat(cases): adiciona o case Julia Beauty com vídeo de rolagem
CI / audit (push) Failing after 5m27s
CI / e2e (push) Failing after 11m23s
CI / verify (push) Successful in 11m31s
CI / deploy (push) Successful in 1m19s
2026-10-24 04:50:58 -03:00
evandro 594daecd2b docs(readme): padroniza README (hero, badges, quickstart, arquitetura)
CI / audit (push) Failing after 5m41s
CI / verify (push) Successful in 13m27s
CI / deploy (push) Successful in 1m14s
CI / e2e (push) Successful in 16m39s
2026-10-24 04:13:02 +00:00
Evandro Fonseca Junior 34c7eb7f8c merge: integra o deploy da Vercel e unifica CI e deploy num pipeline
CI / e2e (push) Failing after 6m52s
CI / verify (push) Failing after 6m59s
CI / deploy (push) Skipped
CI / audit (push) Successful in 10m17s
Traz da main do Gitea o deploy pela Vercel (vercel.json) e o README redesenhado, atualizado para a stack atual (sem Motion, GSAP, React Three Fiber, Drei, Cobe, clsx e tailwind-merge). CI e deploy passam a viver em .gitea/workflows/ci.yml, porque o Gitea ignora .github/workflows quando .gitea/workflows existe: verify (lint, tipos, testes, build) -> deploy na Vercel (padrão -> produção, demais -> preview, PR não publica); audit e e2e em jobs próprios. Vercel CLI fixo em 61.1.0.
2026-10-15 00:27:00 -03:00
Evandro Fonseca Junior 02298779df test(e2e): fluxos de contato, navegação, teclado e menu do celular mais rígidos
CI / verify (push) Failing after 7m13s
CI / e2e (push) Failing after 7m1s
2026-10-14 22:54:27 -03:00
Evandro Fonseca Junior 8903f3c57e perf(movimento): vitrine e 3D mais leves, WebGL por software lembrado na sessão e suporte ao WebKit 2026-10-14 22:54:27 -03:00
Evandro Fonseca Junior 9fd1570426 feat(home): seção Sobre com prova por ponto, contato por oferta e hero com a Lavação 047 na tela larga 2026-10-14 22:54:26 -03:00
Evandro Fonseca Junior d356c364e0 fix(cases): um só convite ao WhatsApp no fim do case e testes alinhados 2026-10-14 22:54:19 -03:00
Evandro Fonseca Junior 2fd8b063d9 feat(servicos): topo, entregáveis e cases por serviço, assunto próprio no WhatsApp e títulos curtos 2026-10-14 22:54:18 -03:00
Evandro Fonseca Junior cf360041bd test: dublê central de next/navigation para todos os testes 2026-10-14 22:54:18 -03:00
Evandro Fonseca Junior 2cd4a98c55 test: e2e mais rígida e .gitattributes
Zero telas sem texto no computador e no celular; atalho de teclado em até 4 Tabs e linear em até 36 com o inventário das paradas; ciclo de foco do menu com a barra de cookies; oferta e cabeçalho levando ao formulário; foco vindo de outra página; separador da faixa de clientes; contagem de pôsteres da SEV. .gitattributes com eol=lf.
2026-10-14 19:14:49 -03:00
Evandro Fonseca Junior 204ebd5fa5 fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve
Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
2026-10-14 19:14:48 -03:00
Evandro Fonseca Junior 9e2d65a926 fix(home): oferta leva ao formulário, ofertas logo depois da vitrine
O botão das ofertas rola até o contato com o tipo marcado e não quebra mais as âncoras; ofertas com o que você recebe e primeiro passo, logo depois da vitrine; bloco de IA fora da home; cartão largo da SEV no hero do celular; separador da faixa de clientes só entre itens; favicon de 13 para 1,8 KB; pré-carregamento só no hover e nunca de /#... fora da home; X do menu e cabeçalho opaco; WhatsApp com "o case"; /ia com 3 passos e modelo de relatório ilustrativo; Sobre alinhado ao currículo; CSP Report-Only com os domínios do Ads.
2026-10-14 19:14:47 -03:00
Evandro Fonseca Junior fc44a69fb3 feat(servicos): ofertas no topo de /servicos, capa por assunto e diagramas
/servicos abre com as 3 ofertas e depois outros serviços; H1 de criação de sites e de engenharia com o nome da oferta; capa de cada serviço pelo assunto (SEV fora do próprio case cai de ~11 para 2 imagens); diagrama de fluxo em SVG nos serviços sem case; cases com o que o cliente passou a poder fazer e contato logo depois de Como ficou; FAQ num componente só; lastmod por rota; ProfessionalService com areaServed.
2026-10-14 19:14:46 -03:00
Evandro Fonseca Junior e63f3c2ebd fix(deps): versões corrigidas de sharp, source-map-js e handlebars
Overrides do pnpm para sharp 0.35.5, source-map-js 1.2.2 e handlebars 4.7.10 (alertas altos e críticos do pnpm audit), mantendo o Next 16.3.8; o 16.4.1 saiu hoje e fica para depois. @playwright/test e @axe-core/playwright fixados na versão exata, como o resto do projeto.
2026-10-14 17:56:06 -03:00
Evandro Fonseca Junior 1a60795107 chore: testes de ponta a ponta no CI e limpeza do repositório
Job e2e separado no CI; testes de teclado, rolagem, sem JS, axe no celular e menu do celular; limites de metadados derivados do sitemap; sai @react-three/fiber e @react-three/drei; pastas de ferramentas (.augment, .kiro, .claude/settings.local.json) fora do Git.
2026-10-14 17:01:37 -03:00
Evandro Fonseca Junior 6ac87824f3 perf(movimento): sem tempo morto, vídeos regravados e 3D sem React Three Fiber
Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
2026-10-14 17:01:37 -03:00
Evandro Fonseca Junior f8545cd442 feat(home): ofertas que vendem o título, WhatsApp no hero e menu do celular acessível
Três ofertas (site que traz pedidos pelo WhatsApp, sistema para tirar a operação da planilha, revisão de sistema existente) com miniatura e formulário com o tipo escolhido; botão principal do hero abre o WhatsApp; moldura da SEV no hero do celular; menu fecha ao navegar, Esc, foco preso incluindo a barra de cookies e espaço para ela; CinematicHome como componente de servidor com ilha cliente; /ia com H1 de oferta e cartão do que você recebe; botão flutuante em aside com transform; AVIF antes de WebP; copy sem repetir contato direto.
2026-10-14 17:01:36 -03:00
Evandro Fonseca Junior de6af74a0c feat(cases): prova certa por serviço, páginas internas com trabalho no topo
Diagnóstico sem case que não cabe; desktop com o GCC Installer como projeto próprio; IoT com o Twins Lab e a nota da Apolo. Mosaico de pôsteres em /servicos e /cases, faixa baixa em uma coluna no currículo, privacidade e laboratório; Laboratório como lista compacta; SEV começando por catálogo, pedido no WhatsApp e painel; Joinville e SC nos títulos; imagens do LCP com fetchPriority e sizes reais; pré-carregamento só no hover; 404 com Open Graph próprio.
2026-10-14 17:01:35 -03:00
Evandro Fonseca Junior 7b633f0f4a chore: dependências e teste de ponta a ponta
Saem gsap, motion, clsx e tailwind-merge; entram server-only, @playwright/test e @axe-core/playwright. Suíte e2e (rotas, axe, teclado, navegação, sem JS, rolagem) rodável com pnpm test:e2e contra BASE_URL.
2026-10-14 14:15:50 -03:00
Evandro Fonseca Junior 318a764190 perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
2026-10-14 14:15:50 -03:00
Evandro Fonseca Junior 10f170abf4 feat(home): posicionamento com prova, menu sem recarregar e WhatsApp em todo o site
H1 'Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.'; HeroWork maior com a SEV; menu Cases, Serviços, Como trabalho, Sobre e IA com next/link e pré-carregamento no hover; menu do celular em tela cheia; WhatsApp flutuante e no rodapé com a mensagem da rota; rodapé em todas as páginas; FAQ da home; /ia como oferta de revisão de app feito com IA; formulário com noscript, validação do mailto e plano B do pop-up; cartões sem foco indevido; sem a fonte Monument.
2026-10-14 14:15:25 -03:00
Evandro Fonseca Junior e80f73d075 feat(cases): mídia no topo, títulos de busca curtos e CreativeWork
Case com mídia ao lado do título e play acessível; problema de cada cliente reescrito com base nos dados; serviços relacionados em 2 cards; FAQ antes do próximo passo e bloco 'Onde já apliquei isso'; Apolo citada em IoT e arquitetura. metaTitle até 43 caracteres e descrições até 155 com teste; JSON-LD CreativeWork; imagens de compartilhamento abaixo de 100 KB; 404 e robots corrigidos; server-only nos módulos com node:.
2026-10-14 14:15:25 -03:00
Evandro Fonseca Junior c9a0b80656 perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
2026-10-14 03:32:23 -03:00
Evandro Fonseca Junior 8d07b8c6bb feat(home): prova no topo, contato sem atrito e conversão medida
Home mais curta (hero, vitrine de clientes, sobre, serviços, IA resumida, depoimentos e contato), faixa de clientes e telas reais no hero, sem números do GitHub. Barra de cookies fina, WhatsApp fixo no celular, formulário com e-mail opcional e alternativa por e-mail, página /ia, eventos de e-mail, redes e currículo e conversão do Ads opcional por variável. Rodapé em 3 colunas. Remove componentes órfãos (LighthouseMetrics, Skills, Experience, contagem do GitHub).
2026-10-14 03:32:23 -03:00
Evandro Fonseca Junior 6e1022b58c feat(cases): cases de clientes, serviços ligados à prova e SEO por página
Cases da SEV Moda Íntima e da Lavação 047; case no formato cliente, problema, entrega, mídia e contato, com detalhes técnicos recolhidos; sem GitHub, ressalvas e fontes de commit. /cases separa clientes e Laboratório. Serviços mostram o case de cliente certo e o bloco de investimento e prazo só aparece preenchido. Metadata social própria por página, not-found com noindex, sitemap com /ia.
2026-10-14 03:31:57 -03:00
Evandro Fonseca Junior 3756905a1b fix(transicao): a home nao pisca mais duas vezes ao abrir
CI / verify (push) Failing after 6m28s
A hidratacao e a revelacao de Suspense tambem sao transicoes do React, e o <ViewTransition name="page"> do template animava a pagina inteira (page-out/page-in) nas duas, logo na chegada. Com update="none", so a troca de pagina anima.
2026-10-14 01:13:44 -03:00
Evandro Fonseca Junior 838b96349f chore: notas de agentes e videos extras
AGENTS.md/CLAUDE.md e videos-extras/ (rolagem da Adriana Imoveis e cortes 1080x1350 para redes sociais).
2026-10-14 00:42:50 -03:00
Evandro Fonseca Junior fbcedc062f ci: build do deploy na infraestrutura da Vercel
Deploy Vercel / deploy (push) Successful in 1m27s
2026-10-13 23:00:00 -03:00
Evandro Fonseca Junior a97985633d ci: deploy na Vercel via Gitea Actions
Deploy Vercel / deploy (push) Failing after 1m13s
2026-10-13 22:20:47 -03:00
Evandro Fonseca Junior 3681141394 merge: integra main do GitHub (b2f3899) mantendo o README novo
# Conflicts:
#	README.md
2026-10-13 22:14:56 -03:00
evandro ea05ce4a5f docs: redesign do README (padrão TwitterBR-MOD) — dados atualizados
CI / verify (push) Failing after 5m26s
2026-10-13 21:48:04 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 d61ec7479f fix(showcase): video ampliado ao lado dos controles tambem em janelas de desktop curtas
CI / verify (push) Failing after 5m54s
Em 1280x600 o video ficava com 352x250; com os controles em coluna ele passa a 835x553.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:39:34 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 01bbebcf52 docs(videos): script de recodificacao para rolagem controlada e README da vitrine
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:37:01 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 ba05cdfe61 perf(videos): cache de um dia, com revalidacao em segundo plano, para /videos
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:37:01 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 ec760330d4 feat(og): capa do projeto como fundo da imagem de compartilhamento dos cases
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:37:00 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 540fe9f87a feat(showcase): vitrine animada presa na rolagem, com capitulos e celular sincronizado
Abaixo dos cards, uma cena sticky em que o site de cada projeto rola dentro de um notebook
(com um celular ao lado) conforme a pagina rola. Cada projeto tem um trecho com pausa no
comeco e no fim; capitulos nomeiam o que esta na tela, levam ao ponto do video e mantem
notebook e celular no mesmo trecho do site. So o projeto em cena e os vizinhos baixam video
(nada com economizar dados ligado, e o notebook escondido no celular nao baixa). Um bloco da
mesma altura segura o lugar antes de montar, para saltos como #contact cairem certo.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:37:00 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 a1a1e91135 feat(showcase): moldura de navegador, um video por vez e video ampliado nos cards
- cards com moldura de navegador (endereco real) e brilho na cor do site no card que toca
- um video por vez: o do card sob o mouse/foco, ou o mais perto do centro da tela; nada baixa
  antes de tocar, e um botao pausa os videos que se mexem sozinhos
- janela ampliada em <dialog> nativo: troca computador/celular mantendo o ponto do site,
  capitulos clicaveis, controles proprios, setas entre projetos e foco devolvido ao fechar
- dados e capitulos de cada projeto em lib/project-showcase.ts (substitui project-videos.ts)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:36:53 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 60dc2f1878 feat(videos): versoes para rolagem controlada (quadro-chave a cada 6 quadros) e capas de compartilhamento
scrub-desktop.mp4 e scrub-mobile.mp4 de cada projeto deixam a imagem acompanhar a
rolagem sem travar; og.jpg e a capa 1200x630 das imagens Open Graph. Ate 3 MB cada.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:36:53 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 c0493f1e77 feat(github): usar o ultimo retrato da contagem quando nao ha token ou a API falha
data/github-stats.json guarda 68 repositorios proprios (25 publicos e 43
privados, sem forks) conferidos em 2026-10-08. Com token valido a contagem
ao vivo vale mais; o retrato evita que o hero fique sem o numero.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 15:26:57 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 942f6bb45c feat(home): tirar o selo 'com demonstracao online' do hero
O lugar passa a ser da contagem de projetos desenvolvidos do GitHub.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 15:26:57 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 a67892613b feat(projects): video de rolagem na capa dos cards, carregado so quando o card aparece
video autoplay muted loop playsinline preload=none com poster; as fontes entram
via IntersectionObserver e o video pausa fora da tela. Com prefers-reduced-motion
aparece so o poster.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:17 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 ca9c0604b8 feat(projects): incluir a Lavacao 047 no portfolio
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:17 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 84786cf1d7 feat(videos): rolagem de AS Servicos (desktop 1440x900 e mobile 390x844)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:16 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 f08d4e799c feat(videos): rolagem de SC Plus (desktop 1440x900 e mobile 390x844)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:16 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 2fd16c09e2 feat(videos): rolagem de NutriScan (desktop 1440x900 e mobile 390x844)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:16 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 778227577a feat(videos): rolagem de Lavacao 047 (desktop 1440x900 e mobile 390x844)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:16 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 1f34cfe4f1 feat(videos): rolagem de SEV Moda Intima (desktop 1440x900 e mobile 390x844)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:15 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 b74044b529 chore(videos): script que grava a rolagem de uma pagina e gera MP4, WebM e poster
Playwright headless com GPU, rolagem em velocidade constante (ate 30 s), corte
por quadros-marcador e saida com ate 3 MB por video.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:15 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 168ee9a180 fix(home): arredondar para baixo o 'Mais de N' dos projetos do GitHub
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 10:28:00 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 0350e7e883 feat(home): trocar o texto fixo de projetos publicados pela contagem real do GitHub
O hero mostra 'Mais de X projetos desenvolvidos' com o detalhamento de
publicos e privados; sem a contagem (token ausente ou API fora) o item some.
A home passa a revalidar uma vez por dia. README documenta o token.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 04:12:03 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 4ba955e2c3 feat(github): contar repositorios proprios (publicos e privados) pela API no servidor
Consulta /user/repos com token fine-grained somente leitura de metadados
(GITHUB_STATS_TOKEN), pagina, exclui forks e arquivados e devolve null se
o token faltar ou a API falhar. Renovacao diaria via revalidate de 86400 s.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 04:12:03 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 b2f3899dc2 fix(mobile): sem tremido nas transicoes com rolagem rapida no celular
CI / verify (push) Failing after 5m47s
A secao que sai fica presa com position: sticky (o compositor do navegador a
mantem em sincronia com a rolagem) em vez de ser deslocada pelo JavaScript.
No toque, a secao que entra sobe com o dedo: a trava de posicao pelo GSAP
chegava um quadro atrasada na rolagem nativa e tremia. Cada passagem mantem
um efeito proprio sem disputar a posicao. Folga de visibilidade cobre o
quadro de atraso. Posicoes das cenas somam alturas (sticky altera offsetTop).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 03:08:42 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 4c826e938f fix(ci): lint do Biome, Next 16.3.8 (RCE em next/og) e transicoes que se remedem
CI / verify (push) Failing after 5m43s
- Biome: formatacao, ordem de imports, max-width duplicado no cabecalho de
  secao, supressao justificada para nomes de view-transition, ajustes nos
  scripts de auditoria de SEO
- next 16.3.3 -> 16.3.8: GHSA-vcvr-r3jv-pc5j (execucao remota de codigo em
  next/og ImageResponse, usado nas imagens de compartilhamento)
- overrides de brace-expansion (GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p,
  GHSA-q2hr-2g5m-vwhr), dependencia de desenvolvimento via jest
- visibilidade das cenas le as posicoes a cada quadro e o GSAP remede as
  passagens quando a pagina muda de altura (fontes, imagens, Experiencia)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:38:41 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 6db10ffa67 Merge branch 'design/espaco-e-consistencia'
Hero com cena WebGL legivel (tela, integracoes, dados) e mergulho com legendas,
rolagem suave e transicoes de secao ligadas a rolagem (desktop e celular),
transicao entre paginas, remocao da secao do notebook, otimizacoes de
desempenho, SEO/AEO/GEO e prompts de logo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:28:01 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 6f2008a209 feat(mobile): hero fixo com mergulho e legendas no celular e no tablet
CI / verify (push) Failing after 5m45s
Hero compacto no celular (numeros em uma linha; servicos e redes ficam no menu
e no rodape) para caber em uma tela e ficar fixo a partir de 700px de altura.
A camera calcula o enquadramento pela proporcao da tela: em pe, se afasta
para cada camada caber na largura. Legendas em tamanho de celular.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:25:03 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 def1e22302 perf(home): transicoes de Qualidade so com translate/opacity e titulos por palavra em CSS
Medido com trace do Chromium (rasterizacao no mesmo trecho de rolagem):
IA->Qualidade 840->558 ms, dentro de Qualidade 34->18 ms, Qualidade->Sobre
819->643 ms. Recorte animado, escala de camadas grandes e o desenho do diagrama
a cada quadro saem; o diagrama se desenha uma vez em CSS. TextDiffuseReveal
deixa de animar cada letra com desfoque.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:19:48 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 44efcfcb20 refactor(home): remove a secao animada do notebook
O mergulho do hero termina direto em Projetos. As capturas de tela seguem nos
cards (ProjectCover). Remove componentes, testes e 670 linhas de CSS do notebook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:12:08 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 92c602c0ad feat(hero): camadas reconheciveis (tela, integracoes, tabela de dados), etiquetas e legendas do mergulho
A cena passa de discos abstratos para formas que um leigo reconhece. A camera
enquadra cada camada enquanto a legenda em linguagem simples explica o que e,
e so entao atravessa para a proxima.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:06:40 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 a3a206fb7a fix(home): transicoes estaveis, diagrama desenhado na entrada, rolagem suave nos links e transicao entre paginas
- visibilidade de cada cena calculada pela posicao da rolagem: nada vaza antes
  da passagem nem aparece atras de transparencias depois
- sem scale em ancestrais do canvas do notebook (tela desalinhada)
- IA -> qualidade em 2D (abertura em portas); a rotacao 3D adiava a pintura
  do diagrama, que agora se desenha junto com a entrada
- iris com aceleracao gradual; hero para de desenhar ao fim do mergulho
- links internos rolam suavemente ate o fim da transicao do destino
- navegacao entre paginas com ViewTransition (app/template.tsx)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 00:50:57 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 df1c1888da feat(home): rolagem suave (Lenis) e seis transicoes de secao ligadas a rolagem
Cada passagem segura a secao que sai e a que entra na tela enquanto a
transicao acontece: dissolucao do hero no notebook, cartao, iris, corte
lateral, perspectiva, cortina diagonal e zoom. Medidas pelo layout
(offsetTop) para nao confundir os gatilhos com os transforms.
Remove letreiros e divisores entre secoes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 00:32:32 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 0ec0194441 feat(hero): campo de particulas WebGL com mergulho guiado pela rolagem e transicao de secoes em CSS
Tres camadas de pontos ligadas por colunas de dados, animadas no vertex shader;
o hero fica fixo em telas grandes enquanto a camera atravessa as camadas.
Secoes sobem como cartoes via animation-timeline: view() (sem JS).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 00:22:18 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 3d47fe9cc7 docs(seo): medicao antes e depois, achados fora do codigo e checagens de e-mail ofuscado e links do GitHub no auditor
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-05 00:05:32 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 03c6fc1f0e docs: prompts de logo para os sete projetos do portfolio
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-05 00:03:43 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 85dd326964 feat(seo): og:locale pt_BR em todas as paginas, titulos e descricoes mais curtos, Person com cargo e areas; remove alegacao de LCP do SC Plus
- openGraph.locale pt_BR nas 6 rotas que redefinem openGraph (so a home tinha)
- titulos: cases usam 'Case de Engenharia'; /curriculo, Tauri e Engenharia de Software mais curtos; descricao da home (193 -> 146) e de AS Servicos (184 -> 136)
- Person JSON-LD ganha jobTitle e knowsAbout (dados e texto ja visiveis no site)
- SC Plus: remove 'otimizada para Core Web Vitals' / LCP < 2,5 s; Lighthouse mobile local mediu LCP 3,7 s. Texto passa a afirmar so metadados e dados estruturados (verificados no site)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-05 00:02:35 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 7c03dece9b chore(seo): auditor repetivel de SEO/AEO/GEO (scripts/seo-audit.mjs e seo-compare.mjs)
Le o sitemap, baixa cada URL como um robo (HTML sem JS) e confere titulo, descricao, canonical, Open Graph, JSON-LD, titulos de secao, resposta direta cedo, acesso de robos de busca e de IA e links internos. Gera JSON para comparar antes e depois.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-05 00:02:35 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 02ecba73f8 feat(cases): Reddit Posts Explorer e MinGW GCC Installer com resposta, desafio, implementacao, limites e fontes fixadas em commit
- textos reescritos a partir do codigo e do README dos repositorios publicos (SHA ff76e08 e bab64fc); remove afirmacoes que o codigo nao sustenta (SSG, rate limiting/cache, busca em tempo real, download assincrono, monitoramento)
- public/portfolio-data.json alinhado (descricao, tecnologias, recursos e selos)
- testes: cinco cases detalhados, limite sourced + escopo editorial, guardas contra afirmacoes sem fonte

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-05 00:00:42 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 2c032b68af feat(as-servicos): remove o link do site e cita a participacao no projeto
- demoUrl de AS Servicos passa a null (so o codigo no GitHub); teamSize deixa de ser 'Individual'
- novo campo opcional role: 'Desenvolvimento da primeira landing page do site, com animacoes 3D, e de um painel para controle de projetos'
- role aparece no card de projeto e no topo do case

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:54:43 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 4db4b1f71c fix(a11y): nome acessivel dos botoes do notebook inclui o numero visivel (WCAG 2.5.3)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:51:07 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 14849a4a4d fix(footer): area de toque de 44px no e-mail direto
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:48:19 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 23aeaa2fda feat(hero): hero redesenhada, titulo visivel no HTML (LCP) e cena 3D so depois do idle
- titulo fluido (clamp ate 88px) com destaque em 'sob medida', eyebrow mono, seta no CTA e brilho de fundo
- numeros reais na hero (anos de experiencia, projetos publicados, demos online) calculados em app/page.tsx a partir dos dados do portfolio
- remove o opacity:0 inicial do motion: o texto nasce visivel; a entrada anima so a posicao (CSS)
- HeroSystem monta a cena Three.js via requestIdleCallback (timeout 2,5s), depois de pintar e hidratar o essencial
- capas decorativas (sem captura) escondidas no celular

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:48:19 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 35a950a9b1 refactor(footer): remove o convite de contato duplicado (fica a secao Contato acima) e mantem o e-mail direto
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:44:45 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 2a428ec7a2 feat(mobile): espacamento menor, rotulos legiveis e botao de analytics compacto no celular
- .ds-section usa clamp(52px, 13vw, 64px) em vez de 72px; header de secao com menos margem
- eyebrow/kicker 11px e tags/links 12px no celular (antes 9-11px)
- botao persistente de analytics mostra 'Cookies' abaixo de 640px; o nome acessivel continua 'Preferencias de analytics'

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:44:45 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 2ffe718fac feat(typography): texto corrido em sans 16px; mono so em rotulos, tags e metadados
- --font-body (Space Grotesk) aplicado a p/li/dd de main e footer com :where() (especificidade zero)
- rotulos (.ds-eyebrow, .ds-kicker, .ds-tag, .ds-metric, .quality-*) seguem em Geist Mono
- .ds-copy 16px/1.6, descricoes e listas 15px/1.7 (celular deixa de usar 13px)
- cards de servicos (WorkModalities) em sans

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:44:00 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 2b5b4a2267 feat(experience): skills e experiencia visiveis (aberta em telas largas) e link Experiencia no menu
- ExperienceDisclosure abre a gaveta em >=1024px e quando o link aponta para #experience/#skills; no celular segue recolhida
- novo item Experiencia em NAVIGATION_ITEMS (desktop e menu mobile); testes do Header atualizados

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:42:27 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 d5f04162a8 feat(projects): capa nos cards de projeto (captura real ou painel gerado em codigo)
- ProjectCover usa as capturas ja publicadas (next/image, webp/avif) e, sem captura, um painel com monograma e stack
- .ds-cover mantem proporcao 16:10 e nao encolhe no card flex

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:40:25 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 e008a7c241 feat(design): botoes .ds-link com 44px de altura minima e cantos retos
- .ds-link centraliza o conteudo e garante min-height 44px (alvo de toque), igual em todas as 25 ocorrencias
- remove rounded-sm do CTA do Footer e das abas do HeroSystem para seguir o resto do site (cantos retos)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:28:17 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 d8b59b03f2 feat(design): container e tipografia fluidos, cabecalho de secao em duas colunas e grade de projetos automatica
- tokens --shell, --fs-section-title e --fs-lead: iguais ao desenho atual ate ~1440px e crescem em telas grandes
- .ds-section-header vira container; acima de 880px o titulo fica a esquerda e a descricao a direita (aninhamento nativo + container query)
- .ds-grid-fluid: 2 colunas em notebook e 3 em tela grande, sem breakpoint (usada em Projects)
- Hero acompanha a largura util em telas grandes

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:26:39 -03:00
Evandro Fonseca Junior 229280d519 fix(projects): reveal individual cards on small screens 2026-09-13 13:44:04 -03:00
Evandro Fonseca Junior 673f3c1843 fix(ci): generate route types before typechecking a fresh checkout 2026-09-13 04:37:45 -03:00
Evandro Fonseca Junior b181216a35 fix(nav): hand scroll restoration back to the browser after arrival 2026-09-13 04:35:00 -03:00
Evandro Fonseca Junior 9aff4d1035 feat: rebuild landing experience, content and analytics 2026-09-13 04:30:58 -03:00
Evandro Fonseca Junior d24ee12299 feat(brand): apply logos, icons and social sharing cards 2026-09-13 04:19:02 -03:00
Evandro Fonseca Junior 8b12e51bc5 feat: refine portfolio showcase, services and quality lab
CI / verify (push) Failing after 5m9s
Align the notebook browser with the physical LCD, refresh real project captures and navigation, simplify service content, and stabilize hero hydration.
2026-09-05 14:20:54 -03:00
Evandro Fonseca Junior e2b20bc699 test: align loader with unavailable demo 2026-09-04 22:08:04 -03:00
Evandro Fonseca Junior 0ae5d2f784 fix: remove stale Twins Lab demo link 2026-09-04 22:07:14 -03:00
Evandro Fonseca Junior a2992ca41e fix: hide unavailable Twins Lab demo 2026-09-04 22:04:55 -03:00
Evandro Fonseca Junior bea235d6fd fix: preserve safe motion and consent failures 2026-09-04 22:02:00 -03:00
Evandro Fonseca Junior a4e5e5c048 fix: restore portfolio loader and safe consent revocation 2026-09-04 21:40:35 -03:00
Evandro Fonseca Junior bff3e4d735 docs: add SEO operations runbook 2026-09-04 20:13:22 -03:00
Evandro Fonseca Junior f83c5e1ada feat: publish privacy and consent links 2026-09-04 20:04:27 -03:00
Evandro Fonseca Junior a9a35cf936 feat: add static social image cards 2026-09-04 19:50:23 -03:00
Evandro Fonseca Junior 4a4b41613a fix: complete dynamic social image metadata 2026-09-04 19:43:55 -03:00
Evandro Fonseca Junior 62bcfbd8fa feat: harden portfolio and add SEO foundations 2026-09-04 19:25:30 -03:00
Evandro Fonseca Junior b079afdd73 feat(story): build procedural notebook animation 2026-09-01 04:35:11 -03:00
Evandro Fonseca Junior f3ed9da777 fix(story): harden notebook interaction and fallback 2026-08-31 01:28:43 -03:00
Evandro Fonseca Junior bb4bf8e7e2 fix: descartar geometrias clonadas do notebook 2026-08-31 00:18:48 -03:00
Evandro Fonseca Junior 1c1a6d5c6d refactor: substituir celular e filmstrip por notebook 2026-08-31 00:14:54 -03:00
Evandro Fonseca Junior d768dfa791 docs: adicionar plano do notebook story 2026-08-31 00:14:45 -03:00
Evandro Fonseca Junior b4a2b81d55 feat: adicionar browser e fallback do notebook 2026-08-30 23:42:31 -03:00
Evandro Fonseca Junior c66ff7fde6 style: format notebook contracts 2026-08-30 23:42:26 -03:00
Evandro Fonseca Junior 8b858528f8 feat: definir contratos do notebook story 2026-08-30 23:35:44 -03:00
Evandro Fonseca Junior d89dde21ee feat: adicionar asset laptop cc0 para notebook story 2026-08-30 23:31:58 -03:00
Evandro Fonseca Junior 370cc34e16 docs: plano de implementacao do notebook story 2026-08-30 23:24:48 -03:00
Evandro Fonseca Junior 7bbdfd9daf docs: spec notebook story interativo 2026-08-30 23:19:09 -03:00
Evandro Fonseca Junior a3d080d4d0 fix(story): filmstrip de prints com sticky+scrub dedado e ReelDirector pos-hidratacao sem mutation no DOM 2026-08-30 21:04:11 -03:00
Evandro Fonseca Junior ecadb7c540 feat(story): celular CC0 com site vivo na tela, ReelDirector scrub em todas as seções e filmstrip pinado de prints reais 2026-08-30 20:00:56 -03:00
Evandro Fonseca Junior 8be2ba572d feat(story): reel scrollytelling scrubbed por scroll com palco WebGL 3D 2026-08-30 18:15:02 -03:00
Evandro Fonseca Junior 52960313f0 fix(hero): starfield uniforme com grade estratificada + tags mobile sem quebra interna 2026-08-30 18:15:02 -03:00
Evandro Fonseca Junior 957c58b69a fix: starfield visibility on load and WebGL fail-soft guard
- ParticleBuildText: self-scheduling rAF loop (previous one-shot tick left the canvas blank until scroll), deterministic sampleUntilBuilt warm-up, ambient star layer spread across the whole card with twinkle, random drift and a birth/death lifecycle (4-10s TTL with 800ms fades and respawn)
- ControlRoomGlobe: fail-soft WebGL guard (webgl2/webgl check + try/catch around createGlobe) so an unavailable or poisoned context renders the fallback instead of crashing the hero tree
2026-08-27 19:29:38 -03:00
Evandro Fonseca Junior b791b7de9e feat: control room hero, SEO/AEO optimization and design system fixes
Hero: replace particle field with cobe globe (Capricorn view, multi-axis drag, reduced-motion safe), telemetry HUD with pointer parallax, particle text build effect, Space Grotesk display font. SEO: short titles with template, PT-BR accents, twitter cards on dynamic cases, escaped JSON-LD via lib/jsonld.ts, CSP report-only, 25 service FAQs answer-first. Design: fix TW4 layered reset killing spacing utilities, literal @theme tokens, mobile hero overflow, header overlap, empty case cards. Remove framer-motion (unified on motion/react), header version badge, phone number from hero CTA. Production build verified (24 static pages).
2026-08-27 10:55:21 -03:00
Evandro Fonseca Junior 0920370f30 chore: ignore local worktrees 2026-08-08 20:00:19 -03:00
Evandro Fonseca Junior cdde81eb6e fix: stabilize reveals and update contact email 2026-08-08 19:42:40 -03:00
Evandro Fonseca Junior aad56ad885 feat: add AS Servicos site (asservicos.com) to portfolio 2026-08-05 22:38:27 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5 d76bbf5974 feat: revamp portfolio with case studies, curriculo page, and local SEO
Adds dynamic case study and curriculo routes, project health monitoring
for live demo status, and strengthens local SEO (structured data,
service-area targeting for Garuva/Joinville/Curitiba, a dedicated
"criação de sites" service page) so search results better match the
professional title (Engenheiro da Computação) and experience (5 anos).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-15 01:31:46 -03:00
Evandro Fonseca Junior 9236fdd685 feat: add randomized portfolio hero variants 2026-07-10 02:46:42 -03:00
Evandro Fonseca Junior 812499b318 merge: develop → main 2026-03-18 07:19:10 -03:00
Evandro Fonseca Junior a3ffc2a25e feat: Introduce new AI skills, core application layout, and cinematic UI components for the portfolio. 2026-03-18 07:15:39 -03:00
Evandro Fonseca Junior 04923ccb2f feat: Add initial portfolio UI components and skill definitions. 2026-03-18 07:00:46 -03:00
Evandro Fonseca Junior e8d9ef4697 feat: Introduce new security reference documentation, a cinematic home component, global styles, and portfolio data. 2026-03-18 06:54:27 -03:00
Evandro Fonseca Junior 11ba5000f3 changes 2026-03-18 06:45:44 -03:00
Evandro Fonseca Junior 2ede7e98f9 feat: Implement initial cinematic portfolio UI with animated components and page structure. 2026-03-18 06:45:42 -03:00
Evandro Fonseca Junior 9d779390e5 feat: add LighthouseMetrics component to display quality and performance scores on the homepage. 2026-03-17 20:12:26 -03:00
Evandro Fonseca Junior d00258dcd1 feat: initialize portfolio site with core components, pages, and content structure, and update Next.js configuration. 2026-03-17 20:08:00 -03:00
Evandro Fonseca Junior 223746b898 feat: Add Nixpacks configuration for Node.js and pnpm build and start commands. 2026-03-16 00:53:00 -03:00
Evandro Fonseca Junior 122786e74d deps: Upgrade Next.js dependency version. 2026-03-16 00:48:48 -03:00
Evandro Fonseca Junior f5b0f8ac35 Merge pull request #2 from evandrodevbr/novoDesign
Implement initial portfolio structure with core components and animations
2026-03-16 00:34:22 -03:00
Evandro Fonseca Junior 08dd046887 feat: add animated components for enhanced UI/UX
- Introduced DrawLine component for drawing animations.
- Added LazyRevealImage component for lazy loading images with reveal animations.
- Implemented TextDiffuseReveal for animated text reveals with staggered effects.
- Created TopProgressBar for a dynamic loading indicator.
- Added PointCloud mock for testing animations.
- Developed useReducedMotion hook to handle user preferences for reduced motion.
- Centralized animation configurations in config.ts for easier management.
- Defined animation variants in variants.ts for consistent animation effects.
- Established AnimationProvider to supply global animation settings.
- Created tests for AnimationProvider and useReducedMotion hook.
- Added SVG assets for branding and UI elements.
- Documented frontend review plan and hero circuit board animation plan.
2026-03-15 21:06:43 -03:00
Evandro Fonseca Junior 0ab6930f92 feat: implement Preloader, Footer, and Section components and integrate them into the home page layout. 2026-03-07 15:45:21 -03:00
Evandro Fonseca Junior d54462c687 feat: Initialize portfolio website with core sections, smooth scrolling, scroll reveal animations, and comprehensive SEO. 2026-03-07 15:39:38 -03:00
Evandro Fonseca Junior 6299ae040a feat: Implement initial portfolio website structure and core components including main page, header, hero, contact, and work modalities. 2026-03-07 15:17:05 -03:00
Evandro Fonseca Junior 0523052864 feat: Initialize portfolio project with core pages, UI components, and configuration files. 2026-03-07 10:27:04 -03:00
Evandro Fonseca Junior aa07d6faeb feat: Implement initial portfolio website structure with core components and a new post creator tool. 2026-03-07 06:50:56 -03:00
Evandro Fonseca Junior 036363b521 Merge pull request #1 from evandrodevbr/feat-marketing
feat: Adiciona a estrutura inicial do portfólio com componentes para …
2026-02-14 02:26:50 -03:00
614 changed files with 76940 additions and 6263 deletions

No files matched your search

+11
View File
@@ -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
+108
View File
@@ -0,0 +1,108 @@
# CI e deploy num arquivo só: o Gitea lê .gitea/workflows e ignora .github/workflows quando esta pasta existe.
# - verify: lint, tipos, testes e build. O deploy só roda se ele passar.
# - audit: alertas de segurança das dependências (fica vermelho sozinho, sem travar o deploy).
# - e2e: testes de ponta a ponta na build de produção (informativo).
# - deploy: Vercel CLI. Branch padrão -> produção; demais branches -> preview; pull requests não publicam.
# Token: secret VERCEL_TOKEN de nível de usuário no Gitea. Projeto na Vercel = nome do repo
# (ou a variável VERCEL_PROJECT do repo). O build roda na Vercel; o runner só envia o código.
name: CI
"on":
push:
pull_request:
workflow_dispatch:
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 test:jogo
- run: pnpm build
audit:
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 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
deploy:
needs: verify
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_SCOPE: asemlk54-1928s-projects
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
# versão fixa do CLI: um pacote sem versão num pipeline de produção é risco de cadeia de suprimentos
- run: npm install --global [email protected]
- name: Deploy
run: |
PROJECT=$(echo "${{ vars.VERCEL_PROJECT || github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
if [ "${{ github.ref_name }}" = "${{ github.event.repository.default_branch }}" ]; then
PROD=--prod
else
PROD=
fi
vercel link --yes --project "$PROJECT" --scope "$VERCEL_SCOPE" --token "$VERCEL_TOKEN"
vercel deploy $PROD --token "$VERCEL_TOKEN"
+27
View File
@@ -12,6 +12,8 @@
# testing
/coverage
/test-results/
/playwright-report/
# next.js
/.next/
@@ -39,3 +41,28 @@ 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/
/3d/out/
/3d/__pycache__/
/3d/fontes/
/3d/ferramentas/
+21
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{}
+30
View File
@@ -0,0 +1,30 @@
# Ferramentas do jogo
Programas baixados ficam em `3d/ferramentas/`, fora do git (`/3d/ferramentas/` no `.gitignore`), instalados sem sudo e sem login.
| programa | versão | licença (frase lida e arquivo) | como baixar | comando sem janela |
| --- | --- | --- | --- | --- |
| Tiled (com `tmxrasterizer`) | 1.12.2 | GPL-2.0 (editor) e BSD-2-Clause (`libtiled`, `tmxrasterizer`). Em `COPYING` do pacote: "Tiled src/tiled GPL … tmxrasterizer src/tmxrasterizer BSD 2-clause license … The full text of each license is provided in the files LICENSE.GPL and LICENSE.BSD." Arquivo: `/app/share/licenses/org.mapeditor.Tiled/tiled/COPYING` no Flatpak (o mesmo texto em `3d/ferramentas/tiled/squashfs-root/COPYING` no AppImage). | `flatpak install --user -y flathub org.mapeditor.Tiled`. O AppImage (`3d/ferramentas/tiled/`, das [releases](https://github.com/mapeditor/tiled/releases/tag/v1.12.2)) roda, mas só traz o plugin Qt `xcb` e falha com `QT_QPA_PLATFORM=offscreen`; `xvfb-run` não existe aqui. | `env -u DISPLAY -u WAYLAND_DISPLAY flatpak run --env=QT_QPA_PLATFORM=offscreen --command=tiled org.mapeditor.Tiled --export-map json "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.json"` e `env -u DISPLAY -u WAYLAND_DISPLAY flatpak run --env=QT_QPA_PLATFORM=offscreen --command=tmxrasterizer org.mapeditor.Tiled "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.png"` |
| Blender | 5.2.2 LTS (`blender --version`) | GPL. Em `/usr/share/doc/blender/readme.html`: "Blender can be used freely for any purpose, including commercial use and distribution. It's free and open-source software, released under the GNU GPL licence." Bibliotecas de terceiros em `/usr/share/doc/blender/license/license.md`. | Já instalado pelo sistema (pacote `blender` 5.2.2-2 do Manjaro). | `blender -b -P script.py` |
| MPFB (MakeHuman para Blender, extensão) | 2.0.18 (`blender_manifest.toml`; build 20261010) | Código GPL-3.0-or-later, recursos CC0. Em `LICENSE.md` do [repositório](https://github.com/makehumancommunity/mpfb2/blob/master/LICENSE.md): "The MPFB source (as defined per the above) is released under GPLv3." e "These assets have been released under CC0 1.0 Universal."; sobre a saída: "As the assets have been released under CC0, there is no limitation on what you can do with this combined output." O zip da extensão não traz arquivo de licença; o manifesto diz `license = ["SPDX:GPL-3.0-or-later"]`. | `3d/ferramentas/baixados/add-on-mpfb-v2.0.18.zip` de [extensions.blender.org](https://extensions.blender.org/add-ons/mpfb/) (sha256 `c075b3e9…2a2dad`), instalado só na pasta separada: `BLENDER_USER_RESOURCES=$PWD/3d/ferramentas/blender-user blender -b --factory-startup --command extension install-file -r user_default -e 3d/ferramentas/baixados/add-on-mpfb-v2.0.18.zip` | `BLENDER_USER_RESOURCES=$PWD/3d/ferramentas/blender-user blender -b -P 3d/humano.py` (instala os pacotes abaixo na pasta de dados do MPFB se faltarem) |
## Como mudar o mapa
1. Abrir `3d/mapa/escritorio.tmx` no Tiled: `flatpak run org.mapeditor.Tiled 3d/mapa/escritorio.tmx`.
2. Editar as camadas `planta`, `moveis` e `marcas` e salvar.
3. Rodar `bash 3d/mapa.sh`: exporta `3d/mapa/escritorio.json` e `escritorio.png`, escreve `lib/jogo/mapas/escritorio.ts` e monta `public/jogo/mapa.glb` no Blender.
4. Rodar `pnpm test -- lib/jogo/mapas`.
O `.tmx` é a fonte. `bash 3d/mapa.sh --gerar` refaz o `.tmx` pelo `3d/mapa.py` e apaga o que foi editado à mão.
## Pacotes de recursos do MPFB
Baixados de `https://files.makehumancommunity.org/asset_packs/<pacote>/<pacote>_cc0.zip` (o link `files2` da [página](https://static.makehumancommunity.org/assets/assetpacks/index.html) dá o mesmo arquivo, mas a ~10 KB/s) para `3d/ferramentas/baixados/` e extraídos em `3d/ferramentas/blender-user/extensions/.user/user_default/mpfb/data/`. Nenhum zip traz arquivo de licença: a licença lida é o campo `"license"` de cada recurso em `packs/<pacote>.json` (dentro do zip) e o cabeçalho dos arquivos usados. Há cabeçalhos AGPL em alguns arquivos de pacotes marcados CC0 (ex.: `cortu_cargo_pants/cargo_pants.obj`, `blindsaypatten_uniform_skin_texture.mhmat`); esses ficaram de fora.
| pacote | sha256 | recursos usados em `3d/humano.py` | licença lida |
| --- | --- | --- | --- |
| makehuman_system_assets | `b542127a…0a0107` | pele `young_caucasian_male`, olhos `low-poly`, sobrancelha `eyebrow001`, cabelo `short02` (a malha base e o esqueleto `mixamo` vêm dentro da extensão, recursos CC0 pelo `LICENSE.md` acima) | CC0 nos 94 itens de `packs/makehuman_system_assets.json`; cabeçalho dos `.mhmat`/`.mhclo`: "This asset was explicitly released as CC0 in september 2020." |
| shirts01 | `a5a723b0…3bcd3` | `namuhekam_male_polo_shirt` (autor namuhekam) | `"license": "CC0"` em `packs/shirts01.json`; CC0 no cabeçalho do `.mhclo`, `.obj` e `.mhmat` |
| pants01 | `e4e0ec60…ad9` | `toigo_wool_pants` (autora MargaretToigo) | `"license": "CC0"` em `packs/pants01.json`; CC0 no cabeçalho do `.mhclo` e `.obj` |
| shoes01 | `ded3f704…5dba9` | `toigo_ankle_boots_male` (autora MargaretToigo) | `"license": "CC0"` em `packs/shoes01.json`; CC0 no cabeçalho do `.mhclo` e `.obj` |
| skins01, hair01 | `7495ab99…4ad9`, `49445d69…40c9` | baixados, não usados | CC0 no `packs/*.json`, mas com cabeçalhos AGPL em 2 e 10 arquivos |
+619
View File
@@ -0,0 +1,619 @@
"""Kit de blocos do hero: um bloco chanfrado e quatro estados com o mesmo N.
Rodar da raiz do repositório: blender -b --python 3d/blocos.py
Gera public/3d/bloco.glb, public/3d/blocos.json, os dois matcaps, o pôster do hero e os dos estados (Cycles) e, em
3d/out/, o .blend e as prévias de cada estado.
Coordenadas do JSON: Y para cima, peça de frente para +Z, centrada na origem.
"""
import ctypes
import gzip
import math
import os
import sys
import bmesh
import bpy
import numpy as np
from bpy_extras.object_utils import world_to_camera_view
from mathutils import Vector
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUBLICO = os.path.join(RAIZ, "public", "3d")
SAIDA = os.path.join(RAIZ, "3d", "out")
GLB = os.path.join(PUBLICO, "bloco.glb")
JSON = os.path.join(PUBLICO, "blocos.json")
ESTADOS = ["marca", "tela", "integracoes", "dados"]
PASSO = 7 # números por bloco: x, y, z, sx, sy, sz, tom
CAIXA = (16, 11, 4)
# ---------------------------------------------------------------- marca
# public/brand/mark-dark.svg (viewBox 210 220 830 800)
POLIGONO = [
(245, 757), (321, 757), (321, 681), (399, 681), (399, 606), (476, 606),
(476, 757), (399, 757), (399, 878), (523, 878), (523, 757), (654, 757),
(654, 913), (594, 913), (594, 982), (245, 982),
]
# a forma branca em retângulos exatos (u0, v0, u1, v1), sem sobreposição
BRANCOS = [
(399, 606, 476, 757),
(321, 681, 399, 757),
(245, 757, 399, 982),
(523, 757, 594, 878),
(594, 757, 654, 913),
(399, 878, 594, 982),
]
LIMAS = [
(511, 512, 667, 688),
(667, 358, 821, 512),
(844, 255, 947, 358),
(906, 432, 1007, 533),
]
LADO_SVG = 38
ESCALA = 10 / 727 # altura da marca (255..982) vira 10
CU, CV = (245 + 1007) / 2, (255 + 982) / 2 # centro da marca no SVG
def svg_para_mundo(u, v):
return (u - CU) * ESCALA, -(v - CV) * ESCALA
def estado_marca():
sz = LADO_SVG * ESCALA
blocos = []
for tom, retangulos in ((0, BRANCOS), (1, LIMAS)):
for u0, v0, u1, v1 in retangulos:
nx = max(1, round((u1 - u0) / LADO_SVG))
ny = max(1, round((v1 - v0) / LADO_SVG))
bu, bv = (u1 - u0) / nx, (v1 - v0) / ny
for i in range(nx):
for j in range(ny):
x, y = svg_para_mundo(u0 + (i + 0.5) * bu, v0 + (j + 0.5) * bv)
for z in (-sz / 2, sz / 2):
blocos.append((x, y, z, bu * ESCALA, bv * ESCALA, sz, tom))
return blocos
# ------------------------------------------- tela, integrações, dados
# Desenhados numa grade inteira (coluna i, linha j com j = 0 no topo, camada k
# para +Z). O passo em mundo sai do tamanho da grade; a densidade de cada
# desenho foi escolhida para ficar entre 85% e 100% de N.
class Grade:
def __init__(self):
self.celulas = {}
def bloco(self, i, j, k=0, tom=0):
# lima prevalece quando dois elementos caem na mesma célula
chave = (round(i), round(j), k)
self.celulas[chave] = max(tom, self.celulas.get(chave, 0))
def cheio(self, i0, j0, i1, j1, k=0, tom=0):
for i in range(i0, i1 + 1):
for j in range(j0, j1 + 1):
self.bloco(i, j, k, tom)
def contorno(self, i0, j0, i1, j1, k=0, tom=0):
for i in range(i0, i1 + 1):
self.bloco(i, j0, k, tom)
self.bloco(i, j1, k, tom)
for j in range(j0 + 1, j1):
self.bloco(i0, j, k, tom)
self.bloco(i1, j, k, tom)
def linha(self, a, b, k=0, tom=0):
n = math.ceil(max(abs(b[0] - a[0]), abs(b[1] - a[1]))) * 2 + 1
for t in range(n + 1):
f = t / n
self.bloco(
math.floor(a[0] + (b[0] - a[0]) * f + 0.5),
math.floor(a[1] + (b[1] - a[1]) * f + 0.5),
k,
tom,
)
def disco(self, ci, cj, r, k=0, tom=0):
n = math.floor(r)
for di in range(-n, n + 1):
for dj in range(-n, n + 1):
if di * di + dj * dj <= r * r:
self.bloco(ci + di, cj + dj, k, tom)
def blocos(self):
cel = self.celulas
ii = [c[0] for c in cel]
jj = [c[1] for c in cel]
kk = [c[2] for c in cel]
largura = max(ii) - min(ii) + 1
altura = max(jj) - min(jj) + 1
camadas = max(kk) - min(kk) + 1
# maior passo que cabe na caixa, com folga
p = min(14.5 / largura, 9.8 / altura, 3.8 / camadas)
mi, mj, mk = (max(ii) + min(ii)) / 2, (max(jj) + min(jj)) / 2, (max(kk) + min(kk)) / 2
return [
((i - mi) * p, -(j - mj) * p, (k - mk) * p, p, p, p, tom)
for (i, j, k), tom in sorted(cel.items())
]
def estado_tela():
# referência: drawScreen em app/components/cinematic/HeroFieldScene.tsx
g = Grade()
C, R = 26, 18
g.contorno(0, 0, C - 1, R - 1) # moldura
for i in (2, 4, 6):
g.bloco(i, 1) # três pontos
g.cheio(1, 2, C - 2, 2) # barra do navegador
g.cheio(2, 4, 9, 5) # título
g.cheio(2, 7, 12, 7) # texto
g.cheio(2, 9, 9, 9)
for k in (0, 1):
g.cheio(2, 11, 7, 11, k, tom=1) # botão, saltado para a frente
g.contorno(15, 4, C - 3, 11) # imagem
g.linha((16, 10), (19, 7))
g.linha((19, 7), (21, 9))
g.bloco(21, 6)
for i0 in (2, 10, 18):
g.contorno(i0, 13, i0 + 5, 15) # cartões
return g.blocos()
def estado_integracoes():
# referência: drawIntegrations em HeroFieldScene.tsx
g = Grade()
A, B = 11, 7.5 # semieixos da elipse dos nós
r_nucleo, r_no = 2.6, 1.6
for k in (0, 1):
g.disco(0, 0, r_nucleo, k, tom=1)
nos = []
for n in range(6):
a = n / 6 * math.tau + math.pi / 6
nos.append((round(math.cos(a) * A), round(math.sin(a) * B)))
for n, (x, y) in enumerate(nos):
for k in (0, 1):
g.disco(x, y, r_no, k)
g.bloco(x, y, 1, tom=1)
d = math.hypot(x, y)
ux, uy = x / d, y / d
g.linha((ux * (r_nucleo + 0.5), uy * (r_nucleo + 0.5)), (x - ux * (r_no + 0.5), y - uy * (r_no + 0.5)))
nx, ny = nos[(n + 1) % 6]
dx, dy = nx - x, ny - y
d = math.hypot(dx, dy)
g.linha((x + dx / d * (r_no + 0.5), y + dy / d * (r_no + 0.5)), (nx - dx / d * (r_no + 0.5), ny - dy / d * (r_no + 0.5)))
return g.blocos()
def estado_dados():
# referência: drawData em HeroFieldScene.tsx; a coluna da direita vira barras em +Z
g = Grade()
C, R = 24, 15
g.contorno(0, 0, C - 1, R - 1)
g.cheio(1, 1, C - 2, 2, tom=1) # cabeçalho
colunas = (7, 13, 18)
for c in colunas:
g.cheio(c, 3, c, R - 2)
valores = ((5, 3, 2), (3, 4, 3), (4, 2, 1), (2, 4, 3), (5, 3, 2))
alturas = (2, 4, 1, 5, 3)
for linha, (v, h) in enumerate(zip(valores, alturas)):
j = 4 + linha * 2
for inicio, comprimento in zip((2, 9, 15), v):
g.cheio(inicio, j, inicio + comprimento - 1, j)
for k in range(h + 1):
g.cheio(20, j, 21, j, k, tom=int(k == h)) # topo da barra em lima
return g.blocos()
# ------------------------------------------------ montagem dos estados
def chave(b):
return (round(b[0], 3), round(b[1], 3), round(b[2], 3))
def completar(blocos, n):
"""Ordena por (x, y, z) e põe os que faltam para n ocultos sobre visíveis espalhados."""
visiveis = sorted(blocos, key=chave)
v, h = len(visiveis), n - len(visiveis)
if h < 0:
falhar(f"estado com {v} blocos, mais que N={n}")
ocultos = []
for m in range(h):
x, y, z, _, _, _, tom = visiveis[(m * 2 + 1) * v // (h * 2)]
ocultos.append((x, y, z, 0, 0, 0, tom))
# oculto fica logo depois do visível em que está, mesma chave espacial
return sorted(visiveis + ocultos, key=lambda b: (chave(b), b[3] == 0))
def fmt(v):
s = f"{v:.3f}"
return "0.000" if s == "-0.000" else s
# ------------------------------------------------------------- Blender
def srgb(h):
def lin(c):
c /= 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
return (*(lin(int(h[i : i + 2], 16)) for i in (0, 2, 4)), 1.0)
def criar_bloco():
malha = bpy.data.meshes.new("bloco")
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1)
bm.to_mesh(malha)
bm.free()
obj = bpy.data.objects.new("bloco", malha)
bpy.context.scene.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
chanfro = obj.modifiers.new("chanfro", "BEVEL")
chanfro.width = 0.08
chanfro.segments = 2
malha.shade_smooth()
pesos = obj.modifiers.new("normais", "WEIGHTED_NORMAL")
pesos.keep_sharp = True
for m in ("chanfro", "normais"):
bpy.ops.object.modifier_apply(modifier=m)
return obj
def exportar_glb(obj):
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.ops.export_scene.gltf(
filepath=GLB,
export_format="GLB",
use_selection=True,
export_yup=True,
export_apply=True,
export_normals=True,
export_texcoords=False,
export_tangents=False,
export_materials="NONE",
export_vertex_color="NONE",
export_attributes=False,
export_extras=False,
export_animations=False,
export_draco_mesh_compression_enable=False,
)
def montar_cena(estados, malha):
cena = bpy.context.scene
materiais = criar_materiais()
malha.materials.append(materiais[0])
colecoes = {}
for nome, blocos in zip(ESTADOS, estados):
col = bpy.data.collections.new(nome)
cena.collection.children.link(col)
colecoes[nome] = col
for n, (x, y, z, sx, sy, sz, tom) in enumerate(blocos):
obj = bpy.data.objects.new(f"{nome}.{n:03d}", malha)
col.objects.link(obj)
# arquivo Y-up/+Z frente -> Blender Z-up/-Y frente
obj.location = (x, -z, y)
obj.scale = (sx, sz, sy)
obj.material_slots[0].link = "OBJECT"
obj.material_slots[0].material = materiais[tom]
obj.hide_render = obj.hide_viewport = sx == 0
return colecoes, materiais
def renderizar(colecoes):
cena = bpy.context.scene
cena.render.engine = "BLENDER_WORKBENCH"
cena.render.resolution_x, cena.render.resolution_y = 800, 600
cena.render.resolution_percentage = 100
cena.render.image_settings.file_format = "PNG"
cena.view_settings.view_transform = "Standard"
cena.display.shading.light = "STUDIO"
cena.display.shading.color_type = "MATERIAL"
cena.display.shading.show_cavity = True
cena.world = bpy.data.worlds.new("fundo")
cena.world.color = (0.008, 0.008, 0.01)
cam = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
cena.collection.objects.link(cam)
cam.location = (-6.0, -25.0, 3.0) # pela esquerda: barras de dados saem para fora da tabela
cam.rotation_euler = (-cam.location.normalized()).to_track_quat("-Z", "Y").to_euler()
cena.camera = cam
for nome in ESTADOS:
for outro, col in colecoes.items():
col.hide_render = col.hide_viewport = outro != nome
cena.render.filepath = os.path.join(SAIDA, f"previa-{nome}.png")
bpy.ops.render.render(write_still=True)
for outro, col in colecoes.items():
col.hide_render = col.hide_viewport = outro != "marca"
# ------------------------------------------------------- render final
# Porcelana fosca com um verniz fino e esmalte lima com emissão leve, numa luz de estúdio de três pontos.
# Os matcaps (uma esfera de cada material, vista de frente) levam essa mesma luz para o tempo real.
def criar_materiais():
def material(nome, cor, **entradas):
mat = bpy.data.materials.new(nome)
mat.diffuse_color = srgb(cor) # cor da prévia (Workbench)
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = srgb(cor)
for entrada, valor in entradas.items():
bsdf.inputs[entrada].default_value = valor
return mat
return {
0: material("porcelana", "f4f3ef", **{"Roughness": 0.5, "Coat Weight": 0.12, "Coat Roughness": 0.3}),
1: material(
"lima",
"d5fa00",
**{
"Roughness": 0.28,
"Coat Weight": 0.3,
"Coat Roughness": 0.12,
"Emission Color": srgb("d5fa00"),
"Emission Strength": 0.06,
},
),
}
def olhar_para_a_origem(obj):
obj.rotation_euler = (-Vector(obj.location).normalized()).to_track_quat("-Z", "Y").to_euler()
def enquadrar(camera, objetos, direcao, ocupa):
"""Põe a câmera na direção dada, olhando a origem, na distância em que os objetos ocupam essa fração do quadro."""
cena = bpy.context.scene
cantos = [obj.matrix_world @ Vector(c) for obj in objetos for c in obj.bound_box]
distancia = 40
for _ in range(3): # com lente longa o tamanho na tela é quase 1 / distância: três passos convergem
camera.location = Vector(direcao).normalized() * distancia
olhar_para_a_origem(camera)
bpy.context.view_layer.update()
vistos = [world_to_camera_view(cena, camera, p) for p in cantos]
distancia *= 2 * max(max(abs(p.x - 0.5), abs(p.y - 0.5)) for p in vistos) / ocupa
def luz(nome, posicao, lado, potencia, cor=(1, 1, 1)):
dados = bpy.data.lights.new(nome, "AREA")
dados.shape = "SQUARE"
dados.size = lado
dados.energy = potencia
dados.color = cor
obj = bpy.data.objects.new(nome, dados)
bpy.context.scene.collection.objects.link(obj)
obj.location = posicao
olhar_para_a_origem(obj)
def ligar_cycles(cena):
"""Cycles na GPU com OptiX quando a máquina tem; senão segue na CPU."""
cena.render.engine = "CYCLES"
cena.cycles.use_denoising = True
prefs = bpy.context.preferences.addons["cycles"].preferences
try:
prefs.compute_device_type = "OPTIX"
except TypeError:
return
prefs.get_devices_for_type("OPTIX")
for aparelho in prefs.devices:
aparelho.use = aparelho.type == "OPTIX"
if any(aparelho.use for aparelho in prefs.devices):
cena.cycles.device = "GPU"
cena.cycles.denoiser = "OPTIX"
def sair(cena, caminho, lado, formato, modo, amostras, transparente):
cena.render.resolution_x, cena.render.resolution_y = lado
cena.render.film_transparent = transparente
cena.render.image_settings.file_format = formato
cena.render.image_settings.color_mode = modo
cena.render.image_settings.quality = 84
cena.cycles.samples = amostras
cena.render.filepath = caminho
bpy.ops.render.render(write_still=True)
def luz_de_estudio(cena):
fundo = cena.world.node_tree.nodes["Background"]
fundo.inputs["Color"].default_value = (0.02, 0.022, 0.02, 1)
fundo.inputs["Strength"].default_value = 1.0
# três tons por bloco: topo claro (principal, quase a pino), frente média (preenchimento baixo e frontal) e a lateral
# que a câmera vê na sombra; o contraluz recorta a silhueta por trás
luz("principal", (6, -10, 20), 14, 7000)
luz("preenchimento", (-10, -18, 3), 18, 2000, (0.92, 0.96, 1.0))
luz("contraluz", (-8, 12, 10), 10, 2000, (0.85, 1.0, 0.75))
def render_final(colecoes, materiais):
cena = bpy.context.scene
ligar_cycles(cena)
luz_de_estudio(cena)
def so(nome):
for outro, col in colecoes.items():
col.hide_render = col.hide_viewport = outro != nome
# prévias finais dos quatro estados, na câmera das prévias rápidas
for nome in ESTADOS:
so(nome)
sair(cena, os.path.join(SAIDA, f"final-{nome}.png"), (800, 600), "PNG", "RGB", 64, False)
camera = bpy.data.objects.new("camera-poster", bpy.data.cameras.new("camera-poster"))
cena.collection.objects.link(camera)
camera.data.lens = 85
cena.camera = camera
# pôsteres dos estados: quase de frente como as prévias, mas de lente longa e com a peça em uns 85% do quadro
cena.render.resolution_x, cena.render.resolution_y = 1200, 900 # o enquadramento usa a proporção do quadro
for nome in ESTADOS[1:]:
so(nome)
enquadrar(camera, [obj for obj in colecoes[nome].objects if not obj.hide_render], (-6, -25, 3), 0.85)
sair(cena, os.path.join(PUBLICO, f"estado-{nome}.webp"), (1200, 900), "WEBP", "RGBA", 256, True)
# pôster: a marca em três quartos, fundo transparente
camera.location = Vector((-0.42, -0.86, 0.2)).normalized() * 36
olhar_para_a_origem(camera)
so("marca")
sair(cena, os.path.join(PUBLICO, "hero-poster.webp"), (1200, 1200), "WEBP", "RGBA", 256, True)
# matcaps: uma esfera de cada material, de frente, em vista ortográfica
so(None)
malha = bpy.data.meshes.new("esfera")
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=96, v_segments=48, radius=1)
bm.to_mesh(malha)
bm.free()
malha.shade_smooth()
esfera = bpy.data.objects.new("esfera", malha)
cena.collection.objects.link(esfera)
frente = bpy.data.objects.new("camera-matcap", bpy.data.cameras.new("camera-matcap"))
cena.collection.objects.link(frente)
frente.data.type = "ORTHO"
frente.data.ortho_scale = 2.0
frente.location = (0, -12, 0)
olhar_para_a_origem(frente)
cena.camera = frente
malha.materials.append(None)
# a esfera de frente pega mais luz de preenchimento que as faces do pôster: a porcelana sai 0,3 ponto de exposição
# abaixo, para a cena em tempo real ter o mesmo branco do pôster (a lima já bate)
for tom, nome, exposicao in ((0, "porcelana", -0.3), (1, "lima", 0.0)):
malha.materials[0] = materiais[tom]
cena.view_settings.exposure = exposicao
sair(cena, os.path.join(PUBLICO, f"matcap-{nome}.webp"), (256, 256), "WEBP", "RGB", 128, False)
cena.view_settings.exposure = 0.0
bpy.data.objects.remove(esfera)
bpy.data.objects.remove(frente)
cena.camera = camera
so("marca")
# ------------------------------------------------------- autoconferência
def falhar(msg):
print(f"FALHOU: {msg}", flush=True)
sys.exit(1)
def conferir(tris):
with open(JSON, "rb") as f:
bruto = f.read()
import json
doc = json.loads(bruto)
n = doc["n"]
dados = np.array(doc["dados"], dtype=float)
if dados.size != len(ESTADOS) * n * PASSO:
falhar(f"dados com {dados.size} números, esperado {len(ESTADOS) * n * PASSO} (mesmo N em todos os estados)")
dados = dados.reshape(len(ESTADOS), n, PASSO)
if n > 320:
falhar(f"N={n} > 320")
if n * tris > 60000:
falhar(f"N x triângulos = {n * tris} > 60000")
if os.path.getsize(GLB) > 40 * 1024:
falhar(f"bloco.glb com {os.path.getsize(GLB)} bytes > 40 KB")
estados = [(f"estado-{nome}.webp", 90) for nome in ESTADOS[1:]]
for nome, limite in (("hero-poster.webp", 90), *estados, ("matcap-porcelana.webp", 30), ("matcap-lima.webp", 30)):
tamanho = os.path.getsize(os.path.join(PUBLICO, nome))
if tamanho > limite * 1024:
falhar(f"{nome} com {tamanho} bytes > {limite} KB")
if len(gzip.compress(bruto)) > 40 * 1024:
falhar("blocos.json em gzip > 40 KB")
meia = np.array(CAIXA) / 2 + 1e-3
for nome, e in zip(ESTADOS, dados):
vis = e[e[:, 3] > 0]
ocultos = n - len(vis)
if ocultos > 0.15 * n:
falhar(f"{nome}: {ocultos} ocultos > 15% de {n}")
if np.any(np.abs(vis[:, :3]) + vis[:, 3:6] / 2 > meia):
falhar(f"{nome}: sai da caixa {CAIXA}")
if nome != "marca":
if np.any(np.abs(vis[:, 3] - vis[:, 4]) > 1e-3) or np.any(np.abs(vis[:, 3] - vis[:, 5]) > 1e-3):
falhar(f"{nome}: blocos não cúbicos")
if vis[:, 6].sum() > 0.25 * len(vis):
falhar(f"{nome}: lima em {int(vis[:, 6].sum())} de {len(vis)} visíveis > 25%")
# marca: em cada camada, os blocos lima ladrilham os quatro retângulos com tolerância
# de 0.5 unidade do SVG: cada bloco dentro de um retângulo, sem sobreposição e com a
# área de cada retângulo preenchida (folga de 0.5 em todo o perímetro).
tol = 0.5
marca = dados[0]
area_poligono = abs(sum(
a[0] * b[1] - b[0] * a[1] for a, b in zip(POLIGONO, POLIGONO[1:] + POLIGONO[:1])
)) / 2
for z in np.unique(marca[:, 2]):
camada = marca[marca[:, 2] == z]
u = camada[:, 0] / ESCALA + CU
v = -camada[:, 1] / ESCALA + CV
lu = camada[:, 3] / ESCALA
lv = camada[:, 4] / ESCALA
caixas = [(u[b] - lu[b] / 2, v[b] - lv[b] / 2, u[b] + lu[b] / 2, v[b] + lv[b] / 2)
for b in np.flatnonzero(camada[:, 6] == 1)]
areas = [0.0] * len(LIMAS)
for c in caixas:
dentro = [r for r, (u0, v0, u1, v1) in enumerate(LIMAS)
if c[0] >= u0 - tol and c[1] >= v0 - tol and c[2] <= u1 + tol and c[3] <= v1 + tol]
if not dentro:
falhar(f"marca z={z}: bloco lima {c} fora dos retângulos lima")
areas[dentro[0]] += (c[2] - c[0]) * (c[3] - c[1])
for (u0, v0, u1, v1), area in zip(LIMAS, areas):
if abs(area - (u1 - u0) * (v1 - v0)) > tol * 2 * (u1 - u0 + v1 - v0):
falhar(f"marca z={z}: retângulo lima {(u0, v0, u1, v1)} coberto com área {area:.0f}")
for a in range(len(caixas)):
for b in range(a):
p, q = caixas[a], caixas[b]
if min(p[2], q[2]) - max(p[0], q[0]) > tol and min(p[3], q[3]) - max(p[1], q[1]) > tol:
falhar(f"marca z={z}: blocos lima sobrepostos {p} {q}")
brancos = camada[:, 6] == 0
area = float((lu[brancos] * lv[brancos]).sum())
if abs(area - area_poligono) > 0.01 * area_poligono:
falhar(f"marca z={z}: área branca {area:.0f} != polígono {area_poligono:.0f}")
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
os.makedirs(PUBLICO, exist_ok=True)
os.makedirs(SAIDA, exist_ok=True)
marca = estado_marca()
n = len(marca)
estados = [completar(b, n) for b in (marca, estado_tela(), estado_integracoes(), estado_dados())]
numeros = ",".join(fmt(v) for e in estados for b in e for v in b)
lista = ",".join(f'"{e}"' for e in ESTADOS)
with open(JSON, "w") as f:
f.write(f'{{"n":{n},"passo":{PASSO},"estados":[{lista}],"dados":[{numeros}]}}')
bloco = criar_bloco()
bloco.data.calc_loop_triangles()
tris = len(bloco.data.loop_triangles)
exportar_glb(bloco)
bpy.context.scene.collection.objects.unlink(bloco) # a malha fica só nas instâncias
colecoes, materiais = montar_cena(estados, bloco.data)
bpy.data.objects.remove(bloco)
renderizar(colecoes)
render_final(colecoes, materiais)
bpy.context.preferences.filepaths.save_version = 0 # sem blocos.blend1
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(SAIDA, "blocos.blend"))
conferir(tris)
for nome, e in zip(ESTADOS, estados):
vis = [b for b in e if b[3] > 0]
print(f"{nome}: {len(vis)} visíveis, {sum(b[6] for b in vis)} lima", flush=True)
ctypes.CDLL(None).fflush(None) # despeja o buffer do C (banner do Blender) antes do OK
print(f"OK blocos={n} tris={n * tris}", flush=True)
sys.exit(0) # sai já, para o OK ser a última linha
if __name__ == "__main__":
main()
+333
View File
@@ -0,0 +1,333 @@
"""Prova do MPFB2 (MakeHuman para Blender) por linha de comando: homem adulto magro, vestido, com esqueleto "mixamo".
Mede triângulos e ossos, passa os 6 clipes do zumbi atual (3d/fontes/zumbi, ossos mixamorig:*) para o esqueleto
novo, exporta 3d/out/jogo/humano.glb e fotografa com a luz de zumbi-frente.png e zumbi-perto.png (3d/jogo.py).
Rodar SEMPRE com a pasta de usuário separada (a extensão e os pacotes ficam lá, fora do Blender do Evandro):
BLENDER_USER_RESOURCES=$PWD/3d/ferramentas/blender-user blender -b -P 3d/humano.py
A extensão entra uma vez com (ver 3d/FERRAMENTAS.md):
BLENDER_USER_RESOURCES=… blender -b --factory-startup --command extension install-file -r user_default -e <zip>
"""
import hashlib
import os
import sys
import urllib.request
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from blocos import RAIZ, SAIDA, falhar, ligar_cycles, sair # noqa: E402
try:
from bl_ext.user_default.mpfb.services import AssetService, HumanService, LocationService
except ImportError:
falhar("MPFB não carregou: rode com BLENDER_USER_RESOURCES=3d/ferramentas/blender-user e instale a extensão")
BAIXADOS = os.path.join(RAIZ, "3d", "ferramentas", "baixados")
CLIPES_FBX = os.path.join(RAIZ, "3d", "fontes", "zumbi") # baixados e extraídos por 3d/jogo.py
RENDERS = os.path.join(SAIDA, "jogo")
GLB = os.path.join(RENDERS, "humano.glb")
# pacote -> sha256 do zip (só pacotes CC0; https://static.makehumancommunity.org/assets/assetpacks/index.html)
PACOTES = {
"makehuman_system_assets": "b542127a8e25547c7c29c19f2d1d2adb9a664c80396ecd694095dbc8028a0107",
"shirts01": "a5a723b0e84a109bb190fcfeac7f1de4138d875da3e30fe5b3340eac9f38bcd3",
"pants01": "e4e0ec60db34f279be291a83cfd7b342a7c5cf09bb7676682a5f39f4f6ac4ad9",
"shoes01": "ded3f70428505eabbf1f6d7b5f61196a7366ef20757103d276ad0ed336c35ada",
}
# files2 (o link da página) entrega a ~10 KB/s; files é o mesmo conteúdo (mesmo tamanho e sha256)
URL_PACOTE = "https://files.makehumancommunity.org/asset_packs/{0}/{0}_cc0.zip"
# homem adulto (25 anos), magro, altura média; a raça fica no padrão do MPFB (1/3 de cada)
CORPO = {"gender": 1.0, "age": 0.5, "muscle": 0.5, "weight": 0.3, "proportions": 0.5, "height": 0.5,
"cupsize": 0.5, "firmness": 0.5, "race": {"asian": 0.33, "caucasian": 0.33, "african": 0.33}}
PELE = "skins/young_caucasian_male/young_caucasian_male.mhmat"
# (tipo do MPFB, arquivo na pasta de dados); escolhidos com cabeçalho CC0 nos .mhclo e .obj (cargo_pants.obj é AGPL)
PECAS = [
("Eyes", "eyes/low-poly/low-poly.mhclo"),
("Eyebrows", "eyebrows/eyebrow001/eyebrow001.mhclo"),
("Hair", "hair/short02/short02.mhclo"),
("Clothes", "clothes/namuhekam_male_polo_shirt/namuhekam_male_polo_shirt.mhclo"),
("Clothes", "clothes/toigo_wool_pants/toigo_wool_pants.mhclo"),
("Clothes", "clothes/toigo_ankle_boots_male/toigo_ankle_boots_male.mhclo"), # o mj_cloth_shoes é sapatilha
]
ESQUELETO = "mixamo"
MAX_TRIS = 12_000
QUADRIL = "mixamorig:Hips"
PERNA = ["UpLeg", "Leg", "Foot"]
CLIPES = {
"parado": "Zombie Idle.fbx",
"andar": "Zombie Walk.fbx",
"atacar": "Zombie Attack.fbx",
"morrer": "Zombie Dying.fbx",
"correr": "Zombie Running.fbx",
"reacao": "Zombie Reaction Hit.fbx",
}
def sha256(caminho):
h = hashlib.sha256()
with open(caminho, "rb") as f:
for bloco in iter(lambda: f.read(1 << 20), b""):
h.update(bloco)
return h.hexdigest()
def instalar_pacotes():
dados = LocationService.get_user_data()
for nome, soma in PACOTES.items():
if os.path.exists(os.path.join(dados, "packs", nome + ".json")):
continue
zip_ = os.path.join(BAIXADOS, nome + "_cc0.zip")
if not os.path.exists(zip_):
os.makedirs(BAIXADOS, exist_ok=True)
urllib.request.urlretrieve(URL_PACOTE.format(nome), zip_)
if sha256(zip_) != soma:
falhar(f"{zip_}: sha256 não confere")
erro = AssetService.fix_and_extract_asset_pack_zip(zip_, dados)
if erro:
falhar(f"{nome}: {erro}")
print(f"pacote {nome} instalado em {dados}", flush=True)
AssetService.update_all_asset_lists()
return dados
def triangulos(obj):
malha = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh()
malha.calc_loop_triangles()
n = len(malha.loop_triangles)
obj.to_mesh_clear()
return n
def criar(dados):
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj)
corpo = HumanService.create_human(macro_detail_dict=CORPO)
esqueleto = HumanService.add_builtin_rig(corpo, ESQUELETO)
HumanService.set_character_skin(os.path.join(dados, PELE), corpo, skin_type="MAKESKIN", material_instances=False)
pecas = []
for tipo, arquivo in PECAS:
caminho = os.path.join(dados, arquivo)
if not os.path.exists(caminho):
falhar(f"falta {caminho}")
pecas.append(HumanService.add_mhclo_asset(caminho, corpo, asset_type=tipo, subdiv_levels=0))
return esqueleto, corpo, pecas
def aplicar_modificadores(obj):
"""Grava na malha as máscaras (ajudantes e faces do corpo sob a roupa) e tira subdivisões; só fica o Armature."""
bpy.context.view_layer.objects.active = obj
if obj.data.shape_keys:
obj.shape_key_add(name="_mistura", from_mix=True)
for chave in list(obj.data.shape_keys.key_blocks)[:-1]:
obj.shape_key_remove(chave)
obj.shape_key_remove(obj.data.shape_keys.key_blocks[0])
for mod in list(obj.modifiers):
if mod.type == "ARMATURE":
continue
if mod.type == "SUBSURF":
obj.modifiers.remove(mod)
continue
with bpy.context.temp_override(object=obj, active_object=obj):
bpy.ops.object.modifier_apply(modifier=mod.name)
def reduzir(malhas, alvo):
"""Colapso de arestas com a mesma razão em todas as malhas (antes do Armature: pesos interpolados)."""
total = sum(triangulos(m) for m in malhas)
razao = alvo / total
for _ in range(4):
for m in malhas:
mod = m.modifiers.new("reduzir", "DECIMATE")
mod.ratio = min(1.0, razao)
mod.use_collapse_triangulate = True
m.modifiers.move(len(m.modifiers) - 1, 0)
with bpy.context.temp_override(object=m, active_object=m):
bpy.ops.object.modifier_apply(modifier=mod.name)
total = sum(triangulos(m) for m in malhas)
if total <= MAX_TRIS:
return total
razao = alvo / total
falhar(f"redução parou em {total} triângulos")
def comprimento_perna(esqueleto):
ossos = esqueleto.pose.bones
juntas = [esqueleto.matrix_world @ ossos["mixamorig:Left" + n].head for n in PERNA]
return sum((b - a).length for a, b in zip(juntas, juntas[1:]))
def contato(esqueleto):
ossos = esqueleto.pose.bones
pes = [f"mixamorig:{lado}{osso}" for lado in ("Left", "Right") for osso in ("Foot", "ToeBase")]
return min((esqueleto.matrix_world @ ossos[n].head).z - ossos[n].bone.head_local.z for n in pes)
def quadro_final(acao):
fim = int(acao.frame_range[0])
for curva in acao.layers[0].strips[0].channelbags[0].fcurves:
pontos = curva.keyframe_points
for anterior, ponto in zip(pontos, pontos[1:]):
if abs(ponto.co[1] - anterior.co[1]) > 1e-4:
fim = max(fim, round(ponto.co[0]))
return fim
def alinhar_rolagem(esqueleto, fonte):
"""Gira cada osso do MPFB no próprio eixo até o Z dele apontar como o do osso do Mixamo no repouso.
A cópia de rotação é em espaço de mundo: com a rolagem igual, o osso novo fica com a direção e a torção do
osso do clipe, mesmo com o repouso diferente (MPFB em A, Mixamo em T). A malha não muda: só o eixo do osso gira.
"""
bpy.context.view_layer.objects.active = esqueleto
bpy.ops.object.mode_set(mode="EDIT")
para_local = esqueleto.matrix_world.inverted().to_3x3()
for osso in esqueleto.data.edit_bones:
if osso.name in fonte.data.bones:
z = (fonte.matrix_world.to_3x3() @ fonte.data.bones[osso.name].matrix_local.to_3x3()).col[2]
osso.align_roll(para_local @ z)
bpy.ops.object.mode_set(mode="OBJECT")
def transferir(esqueleto, nome_clipe, arquivo, alinhar):
"""Como em 3d/jogo.py: rotação de mundo de todos os ossos e posição só do quadril, escalada pela perna e com o pé
de apoio no chão no primeiro quadro; assa a ação."""
antes = set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=os.path.join(CLIPES_FBX, arquivo))
novos = [o for o in bpy.data.objects if o not in antes]
fonte = next(o for o in novos if o.type == "ARMATURE")
if alinhar:
alinhar_rolagem(esqueleto, fonte)
acao_fonte = fonte.animation_data.action
inicio, fim = int(acao_fonte.frame_range[0]), quadro_final(acao_fonte)
bpy.context.scene.frame_set(inicio)
fonte.scale *= comprimento_perna(esqueleto) / comprimento_perna(fonte)
sem_par = []
for pb in esqueleto.pose.bones:
if pb.name not in fonte.pose.bones:
sem_par.append(pb.name)
continue
c = pb.constraints.new("COPY_ROTATION")
c.target, c.subtarget = fonte, pb.name
if pb.name == QUADRIL:
c = pb.constraints.new("COPY_LOCATION")
c.target, c.subtarget = fonte, pb.name
if sem_par:
falhar(f"ossos do MPFB sem par no clipe: {sem_par}")
bpy.context.scene.frame_set(inicio)
fonte.location.z -= contato(esqueleto)
bpy.context.view_layer.objects.active = esqueleto
bpy.ops.object.mode_set(mode="POSE")
bpy.ops.pose.select_all(action="SELECT")
bpy.ops.nla.bake(frame_start=inicio, frame_end=fim, only_selected=True, visual_keying=True,
clear_constraints=True, use_current_action=False, bake_types={"POSE"},
channel_types={"LOCATION", "ROTATION"})
bpy.ops.object.mode_set(mode="OBJECT")
acao = esqueleto.animation_data.action
acao.name = nome_clipe
acao.use_fake_user = True
curvas = acao.layers[0].strips[0].channelbags[0].fcurves
for c in [c for c in curvas if c.data_path.endswith(".location") and f'["{QUADRIL}"]' not in c.data_path]:
curvas.remove(c)
ossos_fonte = fonte.data.bones.keys()
for o in novos:
bpy.data.objects.remove(o)
bpy.data.actions.remove(acao_fonte)
return ossos_fonte, inicio, fim
def exportar(esqueleto, malhas):
os.makedirs(RENDERS, exist_ok=True)
bpy.ops.object.select_all(action="DESELECT")
for o in [esqueleto, *malhas]:
o.select_set(True)
esqueleto.animation_data.action = None
for acao in [a for a in bpy.data.actions if a.name not in CLIPES]:
bpy.data.actions.remove(acao)
bpy.context.scene.frame_set(1)
bpy.ops.export_scene.gltf(filepath=GLB, export_format="GLB", use_selection=True, export_yup=True,
export_apply=False, export_cameras=False, export_lights=False, export_morph=False,
export_tangents=False, export_image_format="JPEG", export_jpeg_quality=80,
export_animations=True, export_animation_mode="ACTIONS",
export_anim_slide_to_zero=True, export_force_sampling=False,
export_optimize_animation_size=True)
def renderizar():
"""Reabre o GLB e fotografa com a luz de renderizar() em 3d/jogo.py: parado no quadro 30 (frente e perto) e
andando no meio do clipe."""
bpy.ops.wm.read_homefile(use_empty=True)
cena = bpy.context.scene
bpy.ops.import_scene.gltf(filepath=GLB)
esqueleto = next(o for o in cena.objects if o.type == "ARMATURE")
malhas = [o for o in cena.objects if o.type == "MESH" and o.parent == esqueleto] # sem a esfera do importador
pontos = [o.matrix_world @ v.co for o in malhas for v in o.data.vertices]
pe, topo = min(p.z for p in pontos), max(p.z for p in pontos)
mundo = bpy.data.worlds.new("neutro")
mundo.use_nodes = True
mundo.node_tree.nodes["Background"].inputs["Color"].default_value = (0.5, 0.5, 0.5, 1)
mundo.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.6
cena.world = mundo
luz = bpy.data.objects.new("principal", bpy.data.lights.new("principal", "AREA"))
luz.data.energy, luz.data.size = 300, 2
luz.location = (1.5, -3, 3)
luz.rotation_euler = (Vector((0, 0, 1.2)) - luz.location).to_track_quat("-Z", "Y").to_euler()
cena.collection.objects.link(luz)
camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
cena.collection.objects.link(camera)
cena.camera = camera
ligar_cycles(cena)
acoes = {a.name.split("_")[0]: a for a in bpy.data.actions}
for nome, clipe, quadro, alvo, distancia in (("frente", "parado", 30, 0.9, 3.2), ("perto", "parado", 30, 1.4, 1.5),
("andando", "andar", None, 0.9, 3.2)):
acao = next(a for n, a in acoes.items() if n.startswith(clipe))
esqueleto.animation_data.action = acao
cena.frame_set(quadro or int(sum(acao.frame_range) / 2))
camera.location = (0.3 * distancia / 4, -distancia, alvo + 0.1)
camera.rotation_euler = (Vector((0, 0, alvo)) - camera.location).to_track_quat("-Z", "Y").to_euler()
sair(cena, os.path.join(RENDERS, f"humano-{nome}.png"), (800, 800), "PNG", "RGB", 64, False)
return pe, topo
def main():
dados = instalar_pacotes()
esqueleto, corpo, pecas = criar(dados)
roupas = [p for p, (tipo, _) in zip(pecas, PECAS) if tipo == "Clothes"]
t_corpo_visivel = triangulos(corpo)
for mod in corpo.modifiers: # mede o corpo sem as máscaras de roupa ("Delete" do MPFB)
if mod.type == "MASK" and mod.vertex_group != "body":
mod.show_viewport = False
t_corpo = triangulos(corpo)
for mod in corpo.modifiers:
mod.show_viewport = True
t_roupa = sum(triangulos(r) for r in roupas)
t_resto = sum(triangulos(p) for p in pecas if p not in roupas)
malhas = [corpo, *pecas]
t_total = t_corpo + t_roupa + t_resto
print(f"triângulos: corpo {t_corpo} (sem as faces sob a roupa: {t_corpo_visivel}), roupa {t_roupa}, "
f"olhos+sobrancelha+cabelo {t_resto}, total {t_total}", flush=True)
for m in malhas:
aplicar_modificadores(m)
t_escondido = sum(triangulos(m) for m in malhas)
tris = reduzir(malhas, MAX_TRIS * 0.97)
print(f"triângulos: total sem as faces escondidas {t_escondido}, reduzido {tris}", flush=True)
for m in malhas:
print(f" {m.name}: {triangulos(m)}", flush=True)
ossos = [b.name for b in esqueleto.data.bones]
print(f"ossos ({len(ossos)}): {' '.join(ossos)}", flush=True)
for i, (nome, arquivo) in enumerate(CLIPES.items()):
ossos_clipe, inicio, fim = transferir(esqueleto, nome, arquivo, alinhar=i == 0)
print(f"clipe {nome} <- {arquivo} quadros {inicio}-{fim}; ossos do clipe sem par no MPFB: "
f"{sorted(set(ossos_clipe) - set(ossos))}", flush=True)
exportar(esqueleto, malhas)
print(f"humano.glb {os.path.getsize(GLB)} bytes", flush=True)
pe, topo = renderizar()
print(f"repouso: pés em {pe:.3f} m, altura {topo - pe:.3f} m", flush=True)
print(f"OK humano tris={tris} ossos={len(ossos)}", flush=True)
if __name__ == "__main__":
main()
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Refaz public/jogo/itens.webp (atlas 2x2 de 512x512) a partir de 3d/itens/*.png.
set -euo pipefail
cd "$(dirname "$0")/.."
FUZZ="${FUZZ:-6%}"
Q="${Q:-90}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
OUT=public/jogo/itens.webp
CONF=/home/evandro/tmp/jogo
mkdir -p "$CONF"
for n in municao kit cafe bateria; do
# fundo por preenchimento a partir dos 4 cantos (sem limiar global)
magick "3d/itens/$n.png" -alpha set -fuzz "$FUZZ" -fill none \
-draw "alpha 0,0 floodfill" -draw "alpha 1023,0 floodfill" \
-draw "alpha 0,1023 floodfill" -draw "alpha 1023,1023 floodfill" \
-channel A -morphology Erode Disk:1 -blur 0x0.7 +channel \
-trim +repage \
-bordercolor none -background none -gravity center \
-resize 215x215 \
-extent 256x256 "$TMP/$n.png"
done
magick -size 512x512 xc:none \
"$TMP/municao.png" -geometry +0+0 -composite \
"$TMP/kit.png" -geometry +256+0 -composite \
"$TMP/cafe.png" -geometry +0+256 -composite \
"$TMP/bateria.png" -geometry +256+256 -composite \
-define webp:alpha-quality=100 -define webp:method=6 -quality "$Q" "$OUT"
magick -size 512x512 xc:"#808080" "$OUT" -composite "$CONF/itens-atlas-cinza.png"
magick -size 512x512 xc:"#d00000" "$OUT" -composite "$CONF/itens-atlas-vermelho.png"
ls -l "$OUT"
Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+1094
View File
File diff suppressed because it is too large. Load diff
+355
View File
@@ -0,0 +1,355 @@
"""Mapa do escritório do jogo em /jogo: gera a primeira versão do .tmx do Tiled e converte o JSON exportado para a grade da lógica.
A fonte do mapa é o 3d/mapa/escritorio.tmx, editado à mão no Tiled. Rodar da raiz do repositório (o 3d/mapa.sh faz a cadeia toda):
python3 3d/mapa.py gerar sobrescreve 3d/mapa/escritorio.tmx e 3d/mapa/ladrilhos.png pela semente, apagando as edições
feitas no Tiled; só roda sob `bash 3d/mapa.sh --gerar`
python3 3d/mapa.py converter lê 3d/mapa/escritorio.json (exportado pelo Tiled) e escreve lib/jogo/mapas/escritorio.ts
Coordenadas em células de 1 m: x cresce para a direita (leste), z para baixo (sul) no desenho; retângulos inclusivos.
"""
import json
import random
import struct
import sys
import zlib
from pathlib import Path
from xml.sax.saxutils import escape, quoteattr
SEMENTE = 20261110
LARGURA, ALTURA, LADO = 44, 32, 32
RAIZ = Path(__file__).resolve().parent.parent
PASTA = RAIZ / "3d" / "mapa"
SAIDA_TS = RAIZ / "lib" / "jogo" / "mapas" / "escritorio.ts"
# nome, cor RGB, grade da lógica ("#" bloqueia passo e visão, "M" só o passo, "" é enfeite), cobre a célula inteira
LADRILHOS = [
("parede", (55, 58, 70), "#", True),
("chão", (214, 206, 188), ".", True),
("mesa", (176, 120, 64), "M", True),
("cadeira", (40, 110, 200), "", False),
("armário", (110, 140, 110), "#", True),
("estante de servidor", (0, 150, 140), "#", True),
("sofá", (190, 50, 60), "M", True),
("bancada", (230, 180, 40), "M", True),
("planta de vaso", (60, 160, 60), "", False),
("bebedouro", (120, 200, 240), "", False),
]
GID = {nome: i + 1 for i, (nome, *_resto) in enumerate(LADRILHOS)}
PAREDES = [
# borda
(0, 0, 43, 0), (0, 31, 43, 31), (0, 0, 0, 31), (43, 0, 43, 31),
# faixas de salas e anel do corredor
(0, 7, 43, 7), (0, 24, 43, 24), (9, 7, 9, 24), (34, 7, 34, 24),
(12, 10, 31, 10), (12, 21, 31, 21), (12, 10, 12, 21), (31, 10, 31, 21),
# divisórias
(15, 0, 15, 7), (28, 0, 28, 7), (0, 16, 9, 16), (34, 16, 43, 16), (17, 24, 17, 31), (24, 24, 24, 31),
# poços dos elevadores, caixa da escada, cabines dos banheiros
(1, 29, 3, 30), (6, 29, 8, 30), (21, 27, 23, 30), (1, 17, 2, 21),
]
AMBIENTES = [
("recepção", 1, 1, 14, 6),
("sala de reunião", 16, 1, 27, 6),
("diretoria", 29, 1, 42, 6),
("copa", 1, 8, 8, 15),
("banheiros", 1, 17, 8, 23),
("área de mesas", 13, 11, 30, 20),
("sala do servidor", 35, 8, 42, 15),
("almoxarifado", 35, 17, 42, 23),
("hall dos elevadores", 1, 25, 16, 30),
("escada", 18, 25, 23, 30),
("área de serviço", 25, 25, 42, 30),
]
# trechos do anel, só para espalhar luminárias
CORREDOR = [(10, 8, 33, 9), (10, 22, 33, 23), (10, 10, 11, 21), (32, 10, 33, 21)]
PORTAS = [
("recepção – copa", 4, 7, 5, 7),
("recepção – corredor", 12, 7, 13, 7),
("sala de reunião – corredor", 18, 7, 19, 7),
("sala de reunião – corredor", 24, 7, 25, 7),
("sala de reunião – diretoria", 28, 3, 28, 4),
("diretoria – corredor", 30, 7, 31, 7),
("copa – corredor", 9, 11, 9, 12),
("banheiros – corredor", 9, 19, 9, 20),
("banheiros – hall dos elevadores", 4, 24, 5, 24),
("hall dos elevadores – corredor", 13, 24, 14, 24),
("hall dos elevadores – escada", 17, 27, 17, 28),
("escada – corredor", 20, 24, 21, 24),
("área de serviço – corredor", 28, 24, 29, 24),
("área de serviço – almoxarifado", 38, 24, 39, 24),
("almoxarifado – corredor", 34, 19, 34, 20),
("sala do servidor – almoxarifado", 38, 16, 39, 16),
("sala do servidor – corredor", 34, 11, 34, 12),
("área de mesas – corredor norte", 21, 10, 22, 10),
("área de mesas – corredor norte", 28, 10, 29, 10),
("área de mesas – corredor sul", 21, 21, 22, 21),
("área de mesas – corredor oeste", 12, 15, 12, 16),
("área de mesas – corredor leste", 31, 15, 31, 16),
]
ILHAS = [(15, 13, 19, 14), (23, 13, 27, 14), (15, 17, 19, 18), (23, 17, 27, 18)]
MOVEIS = [
# recepção
("sofá", 3, 1, 5, 1), ("sofá", 1, 3, 1, 6), ("bancada", 8, 3, 11, 3),
("cadeira", 9, 2, 9, 2), ("cadeira", 10, 2, 10, 2), ("planta de vaso", 1, 1, 1, 1), ("bebedouro", 14, 1, 14, 1),
# sala de reunião
("mesa", 18, 3, 25, 4), ("cadeira", 17, 3, 17, 3), ("cadeira", 26, 4, 26, 4),
("planta de vaso", 16, 1, 16, 1), ("planta de vaso", 27, 1, 27, 1),
# diretoria
("mesa", 35, 3, 38, 4), ("cadeira", 37, 2, 37, 2), ("cadeira", 36, 5, 36, 5), ("cadeira", 38, 5, 38, 5),
("sofá", 29, 1, 31, 1), ("armário", 42, 1, 42, 4), ("planta de vaso", 41, 6, 41, 6),
# copa
("bancada", 1, 11, 1, 14), ("bancada", 1, 15, 4, 15), ("armário", 7, 15, 8, 15), ("mesa", 4, 11, 5, 12),
("cadeira", 3, 11, 3, 11), ("cadeira", 6, 12, 6, 12), ("cadeira", 4, 10, 4, 10), ("cadeira", 5, 13, 5, 13),
("bebedouro", 1, 8, 1, 8),
# banheiros
("bancada", 5, 17, 8, 17), ("planta de vaso", 8, 23, 8, 23),
# hall dos elevadores
("sofá", 11, 30, 14, 30), ("planta de vaso", 1, 25, 1, 25), ("bebedouro", 16, 25, 16, 25),
# área de serviço
("armário", 27, 30, 32, 30), ("bancada", 40, 27, 42, 28), ("bebedouro", 33, 25, 33, 25),
# almoxarifado
("armário", 42, 17, 42, 19), ("armário", 37, 20, 39, 20), ("armário", 35, 23, 36, 23),
# sala do servidor
("estante de servidor", 37, 10, 42, 10), ("estante de servidor", 37, 13, 42, 13),
# área de mesas
*[("mesa", *ilha) for ilha in ILHAS],
("armário", 30, 11, 30, 13), ("planta de vaso", 13, 11, 13, 11), ("planta de vaso", 13, 20, 13, 20),
("planta de vaso", 30, 20, 30, 20), ("bebedouro", 30, 14, 30, 14),
# corredor
("planta de vaso", 10, 8, 10, 8), ("planta de vaso", 33, 8, 33, 8),
("planta de vaso", 10, 23, 10, 23), ("planta de vaso", 33, 23, 33, 23),
]
INICIO = (7, 5)
NASCEDOUROS = [
("elevador 1", 2, 27), ("elevador 2", 7, 27), ("escada", 19, 29), ("banheiros", 6, 21),
("fundos, área de serviço", 26, 29), ("fundos, área de serviço", 41, 30), ("fundos, almoxarifado", 41, 22),
]
# célula de chão e para onde o cartaz olha; a parede fica do lado oposto
CARTAZES = [
(7, 1, "S"), (12, 1, "S"), (21, 1, "S"), (16, 4, "L"), (36, 1, "S"), (8, 14, "O"), (20, 9, "N"),
(10, 18, "L"), (33, 14, "O"), (25, 22, "S"), (20, 11, "S"), (13, 18, "L"), (11, 25, "S"),
]
FACES = {"N": (0, 1), "S": (0, -1), "L": (-1, 0), "O": (1, 0)} # deslocamento até a parede
NOTAS = """Escritório do jogo em /jogo, 1 célula = 1 m. Este arquivo é a fonte do mapa: abra no Tiled (`flatpak run org.mapeditor.Tiled`), edite, salve e rode `bash 3d/mapa.sh`, que exporta o JSON, converte a grade da lógica e monta o 3D. `bash 3d/mapa.sh --gerar` refaz este arquivo pelo 3d/mapa.py e apaga as edições feitas aqui.
Camadas: planta (parede e chão), moveis (a propriedade grade de cada ladrilho diz como entra na lógica: # bloqueia passo e visão, M bloqueia o passo e deixa o tiro passar, vazio é só enfeite), marcas (objetos pelo tipo).
Tipos em marcas: inicio (ponto do jogador), nascedouro (ponto; o nome diz o lugar), ambiente (retângulo com o nome da sala), porta (retângulo sobre as células abertas na parede), luminaria (ponto no teto), cartaz (ponto na face da parede; face = para onde o cartaz olha: N para cima no desenho, S para baixo, L para a direita, O para a esquerda; imagem de 1 a 4)."""
def celulas(x0, z0, x1, z1):
return [(x, z) for z in range(z0, z1 + 1) for x in range(x0, x1 + 1)]
def png(largura, altura, rgba):
"""PNG RGBA 8 bits sem metadados (os mesmos pixels dão os mesmos bytes)."""
linhas = b"".join(b"\0" + rgba[y * largura * 4 : (y + 1) * largura * 4] for y in range(altura))
def bloco(tipo, dados):
return struct.pack(">I", len(dados)) + tipo + dados + struct.pack(">I", zlib.crc32(tipo + dados))
cabecalho = struct.pack(">IIBBBBB", largura, altura, 8, 6, 0, 0, 0)
return b"\x89PNG\r\n\x1a\n" + bloco(b"IHDR", cabecalho) + bloco(b"IDAT", zlib.compress(linhas, 9)) + bloco(b"IEND", b"")
def imagem_ladrilhos():
"""Uma linha de ladrilhos de 32 px: cheios com borda escura; enfeites são um quadrado menor sobre fundo transparente."""
largura = LADO * len(LADRILHOS)
pixels = bytearray(largura * LADO * 4)
for i, (_nome, cor, _grade, cheio) in enumerate(LADRILHOS):
escura = tuple(c // 2 for c in cor)
margem = 0 if cheio else 8
for y in range(margem, LADO - margem):
for x in range(margem, LADO - margem):
borda = min(x - margem, y - margem, LADO - 1 - margem - x, LADO - 1 - margem - y) < 2
p = (y * largura + i * LADO + x) * 4
pixels[p : p + 4] = bytes((*(escura if borda else cor), 255))
return png(largura, LADO, bytes(pixels))
def montar_planta():
planta = [["chão"] * LARGURA for _ in range(ALTURA)]
for parede in PAREDES:
for x, z in celulas(*parede):
planta[z][x] = "parede"
for _nome, *porta in PORTAS:
for x, z in celulas(*porta):
planta[z][x] = "chão"
return planta
def montar_moveis(rng):
moveis = [[None] * LARGURA for _ in range(ALTURA)]
for tipo, *retangulo in MOVEIS:
for x, z in celulas(*retangulo):
moveis[z][x] = tipo
# cadeiras dos dois lados de cada ilha; algumas vagas ficam sem cadeira
for x0, z0, x1, z1 in ILHAS:
for x in range(x0, x1 + 1):
for z in (z0 - 1, z1 + 1):
if rng.random() < 0.8:
moveis[z][x] = "cadeira"
return moveis
def objeto(id_, tipo, nome, x, y, largura=None, altura=None, propriedades=()):
attrs = f'id="{id_}" name={quoteattr(nome)} type="{tipo}" x="{x:g}" y="{y:g}"'
if largura is not None:
attrs += f' width="{largura:g}" height="{altura:g}"'
corpo = []
if propriedades:
corpo.append(" <properties>")
corpo += [f' <property name="{n}" {t}value={quoteattr(str(v))}/>' for n, t, v in propriedades]
corpo.append(" </properties>")
if largura is None:
corpo.append(" <point/>")
return [f" <object {attrs}>", *corpo, " </object>"]
def luminarias(x0, z0, x1, z1):
"""Pontos a cada ~5 m dentro do retângulo, em pixels, arredondados à meia célula."""
largura, altura = x1 - x0 + 1, z1 - z0 + 1
nx, nz = max(1, round(largura / 5)), max(1, round(altura / 5))
return [
(round((x0 + largura * (i + 0.5) / nx) * 2) * LADO // 2, round((z0 + altura * (j + 0.5) / nz) * 2) * LADO // 2)
for j in range(nz)
for i in range(nx)
]
def gerar():
rng = random.Random(SEMENTE)
planta = montar_planta()
moveis = montar_moveis(rng)
marcas = []
total = 0
def novo(*args, **kwargs):
nonlocal total
total += 1
marcas.extend(objeto(total, *args, **kwargs))
novo("inicio", "inicio", (INICIO[0] + 0.5) * LADO, (INICIO[1] + 0.5) * LADO)
for nome, x, z in NASCEDOUROS:
novo("nascedouro", nome, (x + 0.5) * LADO, (z + 0.5) * LADO)
for nome, x0, z0, x1, z1 in AMBIENTES:
novo("ambiente", nome, x0 * LADO, z0 * LADO, (x1 - x0 + 1) * LADO, (z1 - z0 + 1) * LADO)
for nome, x0, z0, x1, z1 in PORTAS:
novo("porta", nome, x0 * LADO, z0 * LADO, (x1 - x0 + 1) * LADO, (z1 - z0 + 1) * LADO)
for retangulo in [r[1:] for r in AMBIENTES] + CORREDOR:
for x, y in luminarias(*retangulo):
novo("luminaria", "luminaria", x, y)
for x, z, face in CARTAZES:
dx, dz = FACES[face]
assert planta[z][x] == "chão" and planta[z + dz][x + dx] == "parede", (x, z, face)
px = (x + 0.5 + dx / 2) * LADO
pz = (z + 0.5 + dz / 2) * LADO
propriedades = [("face", "", face), ("imagem", 'type="int" ', rng.randint(1, 4))]
novo("cartaz", "cartaz", px, pz, propriedades=propriedades)
def csv(grade):
return ",\n".join(",".join(str(GID[c] if c else 0) for c in linha) for linha in grade)
tiles = []
for i, (nome, _cor, grade, _cheio) in enumerate(LADRILHOS):
tiles += [
f' <tile id="{i}">',
" <properties>",
f" <property name=\"nome\" value={quoteattr(nome)}/>",
f' <property name="grade" value="{grade}"/>',
" </properties>",
" </tile>",
]
marcas_txt = "\n".join(marcas)
tmx = "\n".join(
[
'<?xml version="1.0" encoding="UTF-8"?>',
f'<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="{LARGURA}" height="{ALTURA}" tilewidth="{LADO}" tileheight="{LADO}" infinite="0" nextlayerid="4" nextobjectid="{total + 1}">',
" <properties>",
f' <property name="notas">{escape(NOTAS)}</property>',
f' <property name="semente" type="int" value="{SEMENTE}"/>',
" </properties>",
f' <tileset firstgid="1" name="ladrilhos" tilewidth="{LADO}" tileheight="{LADO}" tilecount="{len(LADRILHOS)}" columns="{len(LADRILHOS)}">',
f' <image source="ladrilhos.png" width="{LADO * len(LADRILHOS)}" height="{LADO}"/>',
*tiles,
" </tileset>",
f' <layer id="1" name="planta" width="{LARGURA}" height="{ALTURA}">',
' <data encoding="csv">',
csv(planta),
"</data>",
" </layer>",
f' <layer id="2" name="moveis" width="{LARGURA}" height="{ALTURA}">',
' <data encoding="csv">',
csv(moveis),
"</data>",
" </layer>",
' <objectgroup id="3" name="marcas">',
marcas_txt,
" </objectgroup>",
"</map>",
"",
]
)
PASTA.mkdir(parents=True, exist_ok=True)
(PASTA / "escritorio.tmx").write_text(tmx, encoding="utf-8")
(PASTA / "ladrilhos.png").write_bytes(imagem_ladrilhos())
def converter():
dados = json.loads((PASTA / "escritorio.json").read_text(encoding="utf-8"))
largura, altura, lado = dados["width"], dados["height"], dados["tilewidth"]
grade_por_gid = {}
for tileset in dados["tilesets"]:
for tile in tileset.get("tiles", []):
propriedades = {p["name"]: p["value"] for p in tile.get("properties", [])}
grade_por_gid[tileset["firstgid"] + tile["id"]] = propriedades["grade"]
camadas = {c["name"]: c for c in dados["layers"]}
linhas = [["#"] * largura for _ in range(altura)]
for nome in ("planta", "moveis"):
for i, gid in enumerate(camadas[nome]["data"]):
grade = grade_por_gid.get(gid & 0x1FFFFFFF, "") # bits altos: ladrilho espelhado ou girado
if grade:
linhas[i // largura][i % largura] = grade
ambientes, portas = [], []
for obj in camadas["marcas"]["objects"]:
x, z = int(obj["x"] // lado), int(obj["y"] // lado)
if obj["type"] in ("inicio", "nascedouro"):
linhas[z][x] = "J" if obj["type"] == "inicio" else "N"
elif obj["type"] in ("ambiente", "porta"):
retangulo = {"x": x, "z": z, "largura": round(obj["width"] / lado), "altura": round(obj["height"] / lado)}
if obj["type"] == "ambiente":
ambientes.append({"nome": obj["name"], **retangulo})
else:
portas.append(retangulo)
def lista(itens):
return "[\n" + "".join(f" {json.dumps(i, ensure_ascii=False)},\n" for i in itens) + "]"
retangulo_ts = "x: number; z: number; largura: number; altura: number"
ts = f"""// Gerado por `bash 3d/mapa.sh` (`python3 3d/mapa.py converter`) a partir de 3d/mapa/escritorio.tmx: não edite à mão.
/** Planta do escritório para `lerMapa`: `#` parede ou móvel alto, `M` móvel baixo, `.` chão, `J` início, `N` nascedouro. */
export const ESCRITORIO: readonly string[] = {lista("".join(linha) for linha in linhas)};
/** Salas com nome, em células (x para a direita, z para baixo). */
export const AMBIENTES: readonly {{ nome: string; {retangulo_ts} }}[] = {lista(ambientes)};
/** Células abertas nas paredes, em células. */
export const PORTAS: readonly {{ {retangulo_ts} }}[] = {lista(portas)};
"""
SAIDA_TS.parent.mkdir(parents=True, exist_ok=True)
SAIDA_TS.write_text(ts, encoding="utf-8")
if __name__ == "__main__":
comandos = {"gerar": gerar, "converter": converter}
if len(sys.argv) != 2 or sys.argv[1] not in comandos:
sys.exit("uso: python3 3d/mapa.py gerar|converter")
comandos[sys.argv[1]]()
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Refaz o mapa do escritório a partir de 3d/mapa/escritorio.tmx, a fonte editada no Tiled: exporta JSON e PNG pelo
# Tiled (sem janela), converte para a grade da lógica e monta o 3D no Blender. Rodar de qualquer lugar:
# bash 3d/mapa.sh só exporta, converte e monta
# bash 3d/mapa.sh --gerar antes, refaz o .tmx pelo 3d/mapa.py (apaga o que foi editado à mão no Tiled)
set -euo pipefail
cd "$(dirname "$0")/.."
tiled() {
env -u DISPLAY -u WAYLAND_DISPLAY flatpak run --env=QT_QPA_PLATFORM=offscreen --command="$1" org.mapeditor.Tiled "${@:2}"
}
case "${1:-}" in
"") ;;
--gerar)
echo "aviso: --gerar sobrescreve 3d/mapa/escritorio.tmx pelo 3d/mapa.py; o que foi editado no Tiled se perde" >&2
python3 3d/mapa.py gerar
;;
*)
echo "uso: bash 3d/mapa.sh [--gerar]" >&2
exit 2
;;
esac
tiled tiled --export-map json "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.json"
tiled tmxrasterizer "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.png"
python3 3d/mapa.py converter
./node_modules/.bin/biome format --write lib/jogo/mapas/escritorio.ts 3d/mapa/escritorio.json
blender -b -P 3d/mapa_3d.py
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+477
View File
@@ -0,0 +1,477 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="44" height="32" tilewidth="32" tileheight="32" infinite="0" nextlayerid="4" nextobjectid="105">
<properties>
<property name="notas">Escritório do jogo em /jogo, 1 célula = 1 m. Este arquivo é a fonte do mapa: abra no Tiled (`flatpak run org.mapeditor.Tiled`), edite, salve e rode `bash 3d/mapa.sh`, que exporta o JSON, converte a grade da lógica e monta o 3D. `bash 3d/mapa.sh --gerar` refaz este arquivo pelo 3d/mapa.py e apaga as edições feitas aqui.
Camadas: planta (parede e chão), moveis (a propriedade grade de cada ladrilho diz como entra na lógica: # bloqueia passo e visão, M bloqueia o passo e deixa o tiro passar, vazio é só enfeite), marcas (objetos pelo tipo).
Tipos em marcas: inicio (ponto do jogador), nascedouro (ponto; o nome diz o lugar), ambiente (retângulo com o nome da sala), porta (retângulo sobre as células abertas na parede), luminaria (ponto no teto), cartaz (ponto na face da parede; face = para onde o cartaz olha: N para cima no desenho, S para baixo, L para a direita, O para a esquerda; imagem de 1 a 4).</property>
<property name="semente" type="int" value="20261110"/>
</properties>
<tileset firstgid="1" name="ladrilhos" tilewidth="32" tileheight="32" tilecount="10" columns="10">
<image source="ladrilhos.png" width="320" height="32"/>
<tile id="0">
<properties>
<property name="nome" value="parede"/>
<property name="grade" value="#"/>
</properties>
</tile>
<tile id="1">
<properties>
<property name="nome" value="chão"/>
<property name="grade" value="."/>
</properties>
</tile>
<tile id="2">
<properties>
<property name="nome" value="mesa"/>
<property name="grade" value="M"/>
</properties>
</tile>
<tile id="3">
<properties>
<property name="nome" value="cadeira"/>
<property name="grade" value=""/>
</properties>
</tile>
<tile id="4">
<properties>
<property name="nome" value="armário"/>
<property name="grade" value="#"/>
</properties>
</tile>
<tile id="5">
<properties>
<property name="nome" value="estante de servidor"/>
<property name="grade" value="#"/>
</properties>
</tile>
<tile id="6">
<properties>
<property name="nome" value="sofá"/>
<property name="grade" value="M"/>
</properties>
</tile>
<tile id="7">
<properties>
<property name="nome" value="bancada"/>
<property name="grade" value="M"/>
</properties>
</tile>
<tile id="8">
<properties>
<property name="nome" value="planta de vaso"/>
<property name="grade" value=""/>
</properties>
</tile>
<tile id="9">
<properties>
<property name="nome" value="bebedouro"/>
<property name="grade" value=""/>
</properties>
</tile>
</tileset>
<layer id="1" name="planta" width="44" height="32">
<data encoding="csv">
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,1,2,2,1,1,1,1,1,1,2,2,1,1,1,1,2,2,1,1,1,1,2,2,1,1,1,1,2,2,1,1,1,1,1,1,1,1,1,1,1,1,
1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,1,1,1,1,1,1,1,1,1,2,2,1,1,1,1,1,2,2,1,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1,
1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,2,2,1,1,1,1,
1,1,1,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,1,1,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,1,1,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,2,2,2,2,2,2,1,2,2,1,1,1,1,1,1,1,1,1,2,2,1,1,1,1,1,1,1,1,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1,
1,1,1,1,2,2,1,1,1,1,1,1,1,2,2,1,1,1,1,1,2,2,1,1,1,1,1,1,2,2,1,1,1,1,1,1,1,1,2,2,1,1,1,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,1,2,2,1,1,1,2,2,2,2,2,2,2,2,1,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,1,2,2,1,1,1,2,2,2,2,2,2,2,2,1,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
</data>
</layer>
<layer id="2" name="moveis" width="44" height="32">
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,9,0,7,7,7,0,0,0,0,0,0,0,0,10,0,9,0,0,0,0,0,0,0,0,0,0,9,0,7,7,7,0,0,0,0,0,0,0,0,0,0,5,0,
0,0,0,0,0,0,0,0,0,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,0,0,0,5,0,
0,7,0,0,0,0,0,0,8,8,8,8,0,0,0,0,0,4,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,3,3,3,3,0,0,0,5,0,
0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,4,0,0,0,0,0,0,0,0,3,3,3,3,0,0,0,5,0,
0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,4,0,0,0,0,0,
0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,10,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,6,6,6,6,0,
0,8,0,4,3,3,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,8,0,0,3,3,4,0,0,0,0,0,0,0,0,4,0,4,4,4,0,0,0,4,0,4,0,4,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,8,0,0,0,4,0,0,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,5,0,0,0,0,0,0,6,6,6,6,6,6,0,
0,8,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,8,8,8,8,0,0,5,5,0,0,0,0,0,0,4,4,4,4,4,0,0,0,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,4,4,0,4,0,0,0,0,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,8,8,8,8,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,4,4,0,0,0,4,0,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,5,5,5,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,9,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,5,5,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,8,8,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,8,8,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,7,7,7,7,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
</layer>
<objectgroup id="3" name="marcas">
<object id="1" name="inicio" type="inicio" x="240" y="176">
<point/>
</object>
<object id="2" name="elevador 1" type="nascedouro" x="80" y="880">
<point/>
</object>
<object id="3" name="elevador 2" type="nascedouro" x="240" y="880">
<point/>
</object>
<object id="4" name="escada" type="nascedouro" x="624" y="944">
<point/>
</object>
<object id="5" name="banheiros" type="nascedouro" x="208" y="688">
<point/>
</object>
<object id="6" name="fundos, área de serviço" type="nascedouro" x="848" y="944">
<point/>
</object>
<object id="7" name="fundos, área de serviço" type="nascedouro" x="1328" y="976">
<point/>
</object>
<object id="8" name="fundos, almoxarifado" type="nascedouro" x="1328" y="720">
<point/>
</object>
<object id="9" name="recepção" type="ambiente" x="32" y="32" width="448" height="192">
</object>
<object id="10" name="sala de reunião" type="ambiente" x="512" y="32" width="384" height="192">
</object>
<object id="11" name="diretoria" type="ambiente" x="928" y="32" width="448" height="192">
</object>
<object id="12" name="copa" type="ambiente" x="32" y="256" width="256" height="256">
</object>
<object id="13" name="banheiros" type="ambiente" x="32" y="544" width="256" height="224">
</object>
<object id="14" name="área de mesas" type="ambiente" x="416" y="352" width="576" height="320">
</object>
<object id="15" name="sala do servidor" type="ambiente" x="1120" y="256" width="256" height="256">
</object>
<object id="16" name="almoxarifado" type="ambiente" x="1120" y="544" width="256" height="224">
</object>
<object id="17" name="hall dos elevadores" type="ambiente" x="32" y="800" width="512" height="192">
</object>
<object id="18" name="escada" type="ambiente" x="576" y="800" width="192" height="192">
</object>
<object id="19" name="área de serviço" type="ambiente" x="800" y="800" width="576" height="192">
</object>
<object id="20" name="recepção – copa" type="porta" x="128" y="224" width="64" height="32">
</object>
<object id="21" name="recepção – corredor" type="porta" x="384" y="224" width="64" height="32">
</object>
<object id="22" name="sala de reunião – corredor" type="porta" x="576" y="224" width="64" height="32">
</object>
<object id="23" name="sala de reunião – corredor" type="porta" x="768" y="224" width="64" height="32">
</object>
<object id="24" name="sala de reunião – diretoria" type="porta" x="896" y="96" width="32" height="64">
</object>
<object id="25" name="diretoria – corredor" type="porta" x="960" y="224" width="64" height="32">
</object>
<object id="26" name="copa – corredor" type="porta" x="288" y="352" width="32" height="64">
</object>
<object id="27" name="banheiros – corredor" type="porta" x="288" y="608" width="32" height="64">
</object>
<object id="28" name="banheiros – hall dos elevadores" type="porta" x="128" y="768" width="64" height="32">
</object>
<object id="29" name="hall dos elevadores – corredor" type="porta" x="416" y="768" width="64" height="32">
</object>
<object id="30" name="hall dos elevadores – escada" type="porta" x="544" y="864" width="32" height="64">
</object>
<object id="31" name="escada – corredor" type="porta" x="640" y="768" width="64" height="32">
</object>
<object id="32" name="área de serviço – corredor" type="porta" x="896" y="768" width="64" height="32">
</object>
<object id="33" name="área de serviço – almoxarifado" type="porta" x="1216" y="768" width="64" height="32">
</object>
<object id="34" name="almoxarifado – corredor" type="porta" x="1088" y="608" width="32" height="64">
</object>
<object id="35" name="sala do servidor – almoxarifado" type="porta" x="1216" y="512" width="64" height="32">
</object>
<object id="36" name="sala do servidor – corredor" type="porta" x="1088" y="352" width="32" height="64">
</object>
<object id="37" name="área de mesas – corredor norte" type="porta" x="672" y="320" width="64" height="32">
</object>
<object id="38" name="área de mesas – corredor norte" type="porta" x="896" y="320" width="64" height="32">
</object>
<object id="39" name="área de mesas – corredor sul" type="porta" x="672" y="672" width="64" height="32">
</object>
<object id="40" name="área de mesas – corredor oeste" type="porta" x="384" y="480" width="32" height="64">
</object>
<object id="41" name="área de mesas – corredor leste" type="porta" x="992" y="480" width="32" height="64">
</object>
<object id="42" name="luminaria" type="luminaria" x="112" y="128">
<point/>
</object>
<object id="43" name="luminaria" type="luminaria" x="256" y="128">
<point/>
</object>
<object id="44" name="luminaria" type="luminaria" x="400" y="128">
<point/>
</object>
<object id="45" name="luminaria" type="luminaria" x="608" y="128">
<point/>
</object>
<object id="46" name="luminaria" type="luminaria" x="800" y="128">
<point/>
</object>
<object id="47" name="luminaria" type="luminaria" x="1008" y="128">
<point/>
</object>
<object id="48" name="luminaria" type="luminaria" x="1152" y="128">
<point/>
</object>
<object id="49" name="luminaria" type="luminaria" x="1296" y="128">
<point/>
</object>
<object id="50" name="luminaria" type="luminaria" x="96" y="320">
<point/>
</object>
<object id="51" name="luminaria" type="luminaria" x="224" y="320">
<point/>
</object>
<object id="52" name="luminaria" type="luminaria" x="96" y="448">
<point/>
</object>
<object id="53" name="luminaria" type="luminaria" x="224" y="448">
<point/>
</object>
<object id="54" name="luminaria" type="luminaria" x="96" y="656">
<point/>
</object>
<object id="55" name="luminaria" type="luminaria" x="224" y="656">
<point/>
</object>
<object id="56" name="luminaria" type="luminaria" x="480" y="432">
<point/>
</object>
<object id="57" name="luminaria" type="luminaria" x="640" y="432">
<point/>
</object>
<object id="58" name="luminaria" type="luminaria" x="768" y="432">
<point/>
</object>
<object id="59" name="luminaria" type="luminaria" x="928" y="432">
<point/>
</object>
<object id="60" name="luminaria" type="luminaria" x="480" y="592">
<point/>
</object>
<object id="61" name="luminaria" type="luminaria" x="640" y="592">
<point/>
</object>
<object id="62" name="luminaria" type="luminaria" x="768" y="592">
<point/>
</object>
<object id="63" name="luminaria" type="luminaria" x="928" y="592">
<point/>
</object>
<object id="64" name="luminaria" type="luminaria" x="1184" y="320">
<point/>
</object>
<object id="65" name="luminaria" type="luminaria" x="1312" y="320">
<point/>
</object>
<object id="66" name="luminaria" type="luminaria" x="1184" y="448">
<point/>
</object>
<object id="67" name="luminaria" type="luminaria" x="1312" y="448">
<point/>
</object>
<object id="68" name="luminaria" type="luminaria" x="1184" y="656">
<point/>
</object>
<object id="69" name="luminaria" type="luminaria" x="1312" y="656">
<point/>
</object>
<object id="70" name="luminaria" type="luminaria" x="112" y="896">
<point/>
</object>
<object id="71" name="luminaria" type="luminaria" x="288" y="896">
<point/>
</object>
<object id="72" name="luminaria" type="luminaria" x="464" y="896">
<point/>
</object>
<object id="73" name="luminaria" type="luminaria" x="672" y="896">
<point/>
</object>
<object id="74" name="luminaria" type="luminaria" x="864" y="896">
<point/>
</object>
<object id="75" name="luminaria" type="luminaria" x="1024" y="896">
<point/>
</object>
<object id="76" name="luminaria" type="luminaria" x="1152" y="896">
<point/>
</object>
<object id="77" name="luminaria" type="luminaria" x="1312" y="896">
<point/>
</object>
<object id="78" name="luminaria" type="luminaria" x="400" y="288">
<point/>
</object>
<object id="79" name="luminaria" type="luminaria" x="544" y="288">
<point/>
</object>
<object id="80" name="luminaria" type="luminaria" x="704" y="288">
<point/>
</object>
<object id="81" name="luminaria" type="luminaria" x="864" y="288">
<point/>
</object>
<object id="82" name="luminaria" type="luminaria" x="1008" y="288">
<point/>
</object>
<object id="83" name="luminaria" type="luminaria" x="400" y="736">
<point/>
</object>
<object id="84" name="luminaria" type="luminaria" x="544" y="736">
<point/>
</object>
<object id="85" name="luminaria" type="luminaria" x="704" y="736">
<point/>
</object>
<object id="86" name="luminaria" type="luminaria" x="864" y="736">
<point/>
</object>
<object id="87" name="luminaria" type="luminaria" x="1008" y="736">
<point/>
</object>
<object id="88" name="luminaria" type="luminaria" x="352" y="416">
<point/>
</object>
<object id="89" name="luminaria" type="luminaria" x="352" y="608">
<point/>
</object>
<object id="90" name="luminaria" type="luminaria" x="1056" y="416">
<point/>
</object>
<object id="91" name="luminaria" type="luminaria" x="1056" y="608">
<point/>
</object>
<object id="92" name="cartaz" type="cartaz" x="240" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="93" name="cartaz" type="cartaz" x="400" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="94" name="cartaz" type="cartaz" x="688" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
<object id="95" name="cartaz" type="cartaz" x="512" y="144">
<properties>
<property name="face" value="L"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
<object id="96" name="cartaz" type="cartaz" x="1168" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="97" name="cartaz" type="cartaz" x="288" y="464">
<properties>
<property name="face" value="O"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="98" name="cartaz" type="cartaz" x="656" y="320">
<properties>
<property name="face" value="N"/>
<property name="imagem" type="int" value="4"/>
</properties>
<point/>
</object>
<object id="99" name="cartaz" type="cartaz" x="320" y="592">
<properties>
<property name="face" value="L"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
<object id="100" name="cartaz" type="cartaz" x="1088" y="464">
<properties>
<property name="face" value="O"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="101" name="cartaz" type="cartaz" x="816" y="704">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="102" name="cartaz" type="cartaz" x="656" y="352">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="103" name="cartaz" type="cartaz" x="416" y="592">
<properties>
<property name="face" value="L"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="104" name="cartaz" type="cartaz" x="368" y="800">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
</objectgroup>
</map>
Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

+341
View File
@@ -0,0 +1,341 @@
"""Peças pré-renderizadas dos cartões e do 404: o kit de blocos.py animado em loop de 3 s.
Rodar da raiz do repositório: blender -b --python 3d/pecas.py
Renderiza os quadros em 3d/out/pecas/<nome>/ e o ffmpeg do sistema gera em public/3d/
peca-<nome>.webm (VP9), .mp4 (H.264) e .webp (pôster). Mesmos bloco, materiais e luz do pôster do hero.
"""
import ctypes
import math
import os
import subprocess
import sys
import bpy
from mathutils import Matrix, Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from blocos import ( # noqa: E402
LIMAS,
PUBLICO,
SAIDA,
criar_bloco,
criar_materiais,
estado_marca,
falhar,
ligar_cycles,
luz_de_estudio,
sair,
svg_para_mundo,
)
QUADROS = 72 # 3 s a 24 por segundo; o quadro 72 seria de novo o 0
LADO = (640, 400)
TINTA = "0x0a0a0a" # --ink
AMOSTRAS = 64
LIMITE_VIDEO, LIMITE_POSTER = 250_000, 40_000
def encaixe(f, inicio, duracao):
"""0 antes, 1 assentado: chega rápido e assenta com ease-out cúbico, sem passar do ponto."""
t = min(max((f - inicio) / duracao, 0), 1)
return 1 - (1 - t) ** 3
# ---------------------------------------------------------------- peças
# Cada peça recebe novo(posição, escala, tom) e devolve pose(f), que põe os blocos no quadro f.
# Coordenadas do Blender: X para a direita, -Y para a câmera, Z para cima; bloco de lado 1.
def sistema(novo):
# planilha deitada de 7 x 5; a linha do fundo, em lima, é o cabeçalho
for c in range(7):
for r in range(5):
novo((c - 3, 2 - r, 0), 1, int(r == 0))
# as barras moram dentro das células das colunas 1 a 5, logo à frente do cabeçalho, e sobem telescópicas
barras = [[novo((k - 2, 1, 0), 0.98, int(j == h - 1)) for j in range(h)] for k, h in enumerate((2, 3, 5, 4, 6))]
def pose(f):
for k, pilha in enumerate(barras):
sobe = encaixe(f, 6 + 4 * k, 8) - encaixe(f, 42 + 4 * k, 8)
for j, obj in enumerate(pilha):
obj.location.z = (1 + j) * sobe
return pose
def whatsapp(novo):
# celular em pé de 9 x 16 (x = c - 4, z = 7.5 - r), de cantos arredondados; o miolo vazio é a tela
for c in range(9):
for r in range(16):
borda = (c in (0, 8) or r in (0, 15)) and not (c in (0, 8) and r in (0, 15))
canto_de_dentro = c in (1, 7) and r in (1, 14)
if borda or canto_de_dentro or r == 2: # r = 2 fecha a barra de cima
novo((c - 4, 0, 7.5 - r), 1, 0)
novo((-2, 0, 6.5), 1, 1) # foto do contato
for i in range(4):
novo((-0.8 + 0.7 * i, 0, 6.5), 0.7, 0) # nome, nos blocos menores dos balões, para ler como texto
# balões de 6 x 3 blocos menores, alternando os lados; o canto de baixo do lado do balão fica reto (é o rabo)
p = 0.7
def balao(k, esquerda, tom):
borda = -3.15 if esquerda else 3.15 # parede da tela menos meio bloco de folga
sentido = 1 if esquerda else -1
topo = 2.95 - 3.15 * k
blocos = []
for i in range(6):
for j in range(3):
if i in (0, 5) and j in (0, 2) and (i, j) != (0, 2):
continue
alvo = Vector((borda + sentido * (i + 0.5) * p, 0, topo - j * p))
blocos.append((novo(alvo, p, tom), alvo))
return blocos, Vector((borda, 0, topo - 2.5 * p))
baloes = [balao(0, True, 0), balao(1, False, 1), balao(2, True, 0)]
pontos = [novo((-2.25 + d, 0, 2.95 - 6.3 - p), 0.55, 0) for d in range(3)] # "digitando", no lugar do terceiro
def pose(f):
some = min(max((f - 60) / 8, 0), 1) ** 3 # somem juntos, encolhendo para o rabo
for (blocos, rabo), chega in zip(baloes, (2, 14, 44)):
s = encaixe(f, chega, 8) * (1 - some)
for obj, alvo in blocos:
obj.location = rabo + (alvo - rabo) * s
obj.scale = (p * max(s, 0.001),) * 3
obj.hide_render = s <= 0
aparece = encaixe(f, 26, 4) * (1 - encaixe(f, 41, 3))
for d, obj in enumerate(pontos):
pulso = max(0.0, math.sin(math.tau * (f - 28) / 8 - d * math.tau / 6)) # um depois do outro
s = aparece * 0.55 * (1 + 0.45 * pulso)
obj.scale = (max(s, 0.001),) * 3
obj.hide_render = s <= 0
return pose
def quatrocentos(novo):
# o quadrado lima mais alto se solta para a frente (passa diante do outro) e cai; volta de cima direto no lugar
x0, y1 = svg_para_mundo(*LIMAS[2][:2])
x1, y0 = svg_para_mundo(*LIMAS[2][2:])
solto = []
for x, y, z, sx, sy, sz, tom in estado_marca():
obj = novo((x, -z, y), (sx, sz, sy), tom)
if tom and x0 < x < x1 and y0 < y < y1:
solto.append((obj, Vector((x, -z, y))))
centro = sum((alvo for _, alvo in solto), Vector()) / len(solto)
def pose(f):
if f < 32:
queda = min(max((f - 12) / 12, 0), 1) ** 2 # ease-in: a gravidade acelera
d = Vector((0, -1.4 * encaixe(f, 8, 4), -16 * queda))
giro = Matrix.Rotation(0.6 * queda, 3, "Y") # tomba de lado enquanto cai
else:
d = Vector((0, 0, 12 * (1 - encaixe(f, 40, 8))))
giro = Matrix.Identity(3)
for obj, alvo in solto:
obj.location = centro + giro @ (alvo - centro) + d
obj.rotation_euler = giro.to_euler()
return pose
def selo_conferido(novo, x0, z0, y, passo):
"""Selo lima de 7 x 7 com o V de conferido vazado; (x0, z0) é o centro do bloco de cima à esquerda."""
desenho = ("....#", "...##", "#.##.", "###..", ".#...")
vazado = {(1 + i, 1 + j) for j, linha in enumerate(desenho) for i, x in enumerate(linha) if x == "#"}
blocos = []
for c in range(7):
for r in range(7):
if (c, r) not in vazado:
alvo = Vector((x0 + c * passo, y, z0 - r * passo))
blocos.append((novo(alvo, passo, 1), alvo))
return blocos
def fiscal(novo):
# nota em pé de 11 x 16; as quatro linhas de texto são vãos na folha, a partir da coluna 1
linhas = {2: 7, 4: 9, 6: 6, 8: 8}
for c in range(11):
for r in range(16):
if not 1 <= c <= linhas.get(r, 0):
novo((c - 5, 0, 7.5 - r), 1, 0)
selo = selo_conferido(novo, -1, -1.5, -1, 1) # à frente do canto de baixo à direita; a folha aparece pelo V
de_fora = Vector((22, -6, 4)) # chega pela direita, vindo da frente
para_cima = Vector((6, -4, 20))
def pose(f):
sai = min(max((f - 46) / 10, 0), 1) ** 3 # solta devagar e sai acelerando
d = de_fora * (1 - encaixe(f, 6, 10)) + para_cima * sai
for obj, alvo in selo:
obj.location = alvo + d
return pose
REVISAO_CAMERA = (-0.25, -0.95, 0.15)
def revisao(novo):
# janela de sistema de 13 x 13 (x = c - 6, z = 6 - r): moldura, três pontos, barra e quatro linhas de conteúdo
linhas = {4: 8, 6: 10, 8: 6, 10: 9}
# três blocos das linhas, da esquerda para a direita, saltados para a frente e girados: (deslocamento, giro)
fora = {
(3, 6): ((-0.4, -2.4, 1.1), (0.6, 0.3, 0.7)),
(5, 4): ((0.5, -2.0, -0.9), (-0.5, 0.4, -0.6)),
(7, 10): ((-0.3, -2.6, 0.9), (0.4, -0.6, 0.5)),
}
soltos = []
for c in range(13):
for r in range(13):
if c in (0, 12) or r in (0, 2, 12) or (r == 1 and c in (2, 4, 6)) or 2 <= c <= linhas.get(r, 0):
obj = novo(Vector((c - 6, 0, 6 - r)), 1, 0)
if (c, r) in fora:
desloc, giro = fora[(c, r)]
soltos.append((obj, obj.location.copy(), Vector(desloc), Vector(giro)))
# lupa: aro octogonal em volta de um vão de 3 x 3 e cabo na diagonal, para baixo e à direita
lupa = []
for dx in range(-2, 3):
for dz in range(-2, 3):
if 1.5 < math.hypot(dx, dz) <= 2.6:
lupa.append((novo((0, 0, 0), 1, 1), Vector((dx, 0, dz))))
for k in range(3):
d = (3.0 + 0.95 * k) / math.sqrt(2)
cabo = novo((0, 0, 0), 1, 1)
cabo.rotation_euler = (0, math.pi / 4, 0)
lupa.append((cabo, Vector((d, 0, -d))))
lima = lupa[0][0].material_slots[0].material
porcelana = soltos[0][0].material_slots[0].material
# a lupa anda 3,5 à frente da janela, na linha de visão de cada bloco solto: o bloco aparece no meio do aro
rumo = Vector(REVISAO_CAMERA).normalized()
def na_frente(p):
return Vector(p) + rumo * (3.5 / -rumo.y)
a, b, c = (alvo for _, alvo, _, _ in soltos)
caminho = [(0, na_frente((-18, 0, 1))), (9, na_frente(a)), (13, na_frente(a)), (21, na_frente(b)),
(25, na_frente(b)), (33, na_frente(c)), (37, na_frente(c)), (47, na_frente((28, 0, -3)))] # fmt: skip
chegadas = (9, 21, 33)
selo = selo_conferido(novo, 2.3, -3.2, -1, 0.6) # menor, por dentro do canto de baixo à direita
de_fora = Vector((0, 0, -11)) # chega e sai reto por baixo, sem invadir a direita do quadro
def lupa_em(f):
for (f0, p0), (f1, p1) in zip(caminho, caminho[1:]):
if f <= f1:
t = min(max((f - f0) / (f1 - f0), 0), 1)
return p0.lerp(p1, t * t * (3 - 2 * t))
return caminho[-1][1]
def pose(f):
centro = lupa_em(f)
for obj, desvio in lupa:
obj.location = centro + desvio
for i, ((obj, alvo, desloc, giro), chega) in enumerate(zip(soltos, chegadas)):
s = encaixe(f, chega, 6) - encaixe(f, 62 + 2 * i, 5) # no fim voltam a saltar, para o loop emendar
obj.location = alvo + desloc * (1 - s)
obj.rotation_euler = giro * (1 - s)
obj.material_slots[0].material = lima if chega <= f < chega + 10 else porcelana
d = de_fora * (1 - encaixe(f, 44, 8) + min(max((f - 60) / 6, 0), 1) ** 3)
for obj, alvo in selo:
obj.location = alvo + d
return pose
# nome, peça, direção da câmera, distância, alvo, quadro do pôster (peça montada)
PECAS = [
("sistema", sistema, (-0.5, -0.8, 0.6), 44, (0, 0.5, 2.3), 36),
("whatsapp", whatsapp, (-0.3, -0.93, 0.18), 64, (-3.8, 0, 0), 56),
("404", quatrocentos, (-0.42, -0.86, 0.2), 46, (0, 0, 0), 0),
("fiscal", fiscal, (-0.42, -0.86, 0.2), 72, (0, 0, 0), 36),
("revisao", revisao, REVISAO_CAMERA, 68, (6.3, 0, -0.6), 20),
]
# ------------------------------------------------------------- ffmpeg
def ffmpeg(*args):
fundo = ["-f", "lavfi", "-i", f"color=c={TINTA}:s={LADO[0]}x{LADO[1]}:r=24"]
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *fundo, *args], check=True)
def codificar(pasta, nome, poster):
destino = os.path.join(PUBLICO, f"peca-{nome}")
# quadros com alfa sobre a tinta opaca, em yuv420p BT.709 (o padrão dos navegadores)
compor = "[0][1]overlay=shortest=1:format=rgb,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p"
entrada = ["-framerate", "24", "-i", os.path.join(pasta, "%04d.png"), "-filter_complex", compor, "-an"]
cor = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
ffmpeg(*entrada, *cor, "-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-row-mt", "1", "-cpu-used", "1",
f"{destino}.webm") # fmt: skip
ffmpeg(*entrada, *cor, "-c:v", "libx264", "-preset", "slow", "-crf", "22", "-movflags", "+faststart",
f"{destino}.mp4") # fmt: skip
ffmpeg("-i", poster, "-filter_complex", "[0][1]overlay", "-frames:v", "1", "-c:v", "libwebp", "-quality", "80",
f"{destino}.webp") # fmt: skip
for ext, limite in (("webm", LIMITE_VIDEO), ("mp4", LIMITE_VIDEO), ("webp", LIMITE_POSTER)):
tamanho = os.path.getsize(f"{destino}.{ext}")
print(f"peca-{nome}.{ext}: {tamanho} bytes", flush=True)
if tamanho > limite:
falhar(f"peca-{nome}.{ext} tem {tamanho} bytes, limite {limite}")
# ------------------------------------------------------------- Blender
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
cena = bpy.context.scene
cena.view_settings.view_transform = "Standard"
cena.world = bpy.data.worlds.new("fundo")
cena.render.use_persistent_data = True
ligar_cycles(cena)
luz_de_estudio(cena)
bloco = criar_bloco()
cena.collection.objects.unlink(bloco) # a malha fica só nas instâncias
malha = bloco.data
bpy.data.objects.remove(bloco)
materiais = criar_materiais()
malha.materials.append(materiais[0])
camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
cena.collection.objects.link(camera)
camera.data.lens = 85
cena.camera = camera
colecoes = {}
for nome, peca, direcao, distancia, alvo, quadro_poster in PECAS:
col = bpy.data.collections.new(nome)
cena.collection.children.link(col)
colecoes[nome] = col
def novo(posicao, escala, tom, col=col):
obj = bpy.data.objects.new(f"{col.name}.{len(col.objects):03d}", malha)
col.objects.link(obj)
obj.location = posicao
obj.scale = escala if isinstance(escala, tuple) else (escala,) * 3
obj.material_slots[0].link = "OBJECT"
obj.material_slots[0].material = materiais[tom]
return obj
pose = peca(novo)
for outro, c in colecoes.items():
c.hide_render = outro != nome
direcao = Vector(direcao).normalized()
camera.location = Vector(alvo) + direcao * distancia
camera.rotation_euler = (-direcao).to_track_quat("-Z", "Y").to_euler()
pasta = os.path.join(SAIDA, "pecas", nome)
os.makedirs(pasta, exist_ok=True)
for f in range(QUADROS):
pose(f)
sair(cena, os.path.join(pasta, f"{f:04d}.png"), LADO, "PNG", "RGBA", AMOSTRAS, True)
codificar(pasta, nome, os.path.join(pasta, f"{quadro_poster:04d}.png"))
ctypes.CDLL(None).fflush(None) # despeja o buffer do C (banner do Blender) antes do OK
print(f"OK pecas={len(PECAS)}", flush=True)
sys.exit(0) # sai já, para o OK ser a última linha
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

+9
View File
@@ -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 -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+11
View File
@@ -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.
+83
View File
@@ -0,0 +1,83 @@
# DESIGN.md
Registro das decisões de desenho do evandro.dev.br. O código (`app/globals.css`, componentes) é a fonte dos valores; este arquivo diz o porquê.
## Conceito
```
Ideia: os blocos da marca são as peças com que se monta o site ou o sistema de cada cliente
Metáfora: kit de blocos de encaixe, como uma maquete
Assinatura: no hero, os blocos da marca se soltam e se remontam com a rolagem em tela, integrações e dados
Emoção: nos três primeiros segundos, "isto foi montado peça por peça"
Verbo: encaixar: chega rápido, assenta curto, sem quique
Quadro: a marca em porcelana com os quatro cubos verdes ao lado do título
```
Âncora: o símbolo é feito de blocos (`public/brand/mark-dark.svg`); o conceito não serve para outra marca.
## Paleta
| Papel | Token | Valor |
|---|---|---|
| Fundo escuro, tinta | `--ink` | `#0a0a0a` |
| Fundo claro | `--paper` | `#f4f2ed` |
| Fundo claro, segundo plano | `--paper-dark` | `#eceae4` |
| Acento | `--slash` | `#ccff00` |
| Texto secundário | `--meta` | `#4a4a4a` |
| Controles que abrem o WhatsApp | `--whatsapp` | `#25d366` |
O acento aparece em pouca área: um trecho do título, um botão, os cubos da marca. Verde sobre preto é a identidade da marca desde 2026-09, não uma escolha de tendência.
## Tipografia
- Display e corpo: Space Grotesk (`--font-display`).
- Rótulos, números e código: Geist Mono (`--font-mono`), 13 px, sem caixa alta, `letter-spacing` de 0,02em.
## Forma
Cantos retos por padrão (blocos, molduras, botões do hero). Raio aparece em poucos lugares: formulário de contato, botão do WhatsApp e indicadores circulares.
## Movimento
Só CSS e Three.js direto. Sem biblioteca de animação (GSAP, Motion e React Three Fiber saíram em 2026-10-14 por desempenho).
| Uso | Duração | Curva |
|---|---|---|
| Entrada de componente | 280 ms | `--ease-out` |
| Saída de componente | 180 ms | `--ease-in` |
| Entrada do título e do hero | 850 a 1100 ms | `--ease-out` |
| Troca de página | 600 ms; a capa compartilhada viaja em 500 ms | cortina em degraus de 8 colunas; `--ease-out` na capa |
| Detalhes de bloco (cursor, cortina) | conforme o caso | `steps()` |
Regras:
- Animam só `transform` (e `translate`, `scale`), `opacity`, `clip-path` e `mask`. Única exceção: a altura das perguntas frequentes ao abrir e fechar.
- O texto está no HTML desde o primeiro quadro; a animação nunca esconde conteúdo de quem não a recebe.
- Coisa grande se move devagar, detalhe se move rápido; escalonamento entre 20 e 80 ms.
- Com `prefers-reduced-motion: reduce` não há cena, rolagem suave nem animação; o conteúdo aparece inteiro.
## Resposta ao ponteiro e ao foco
Um tratamento por papel, sempre entrando pela esquerda em 280 ms com `--ease-out`; com movimento reduzido a troca é instantânea.
| Papel | Tratamento |
|---|---|
| Botão verde | um bloco cor de papel cobre o botão e o texto continua escuro; sobre fundo claro o bloco é escuro e o texto inverte |
| Botão de contorno | o mesmo bloco: translúcido no fundo escuro, escuro no fundo claro (o texto inverte) |
| Link de texto e item do menu | uma barra de 2 px cresce sob o texto |
| Item da vitrine | uma barra verde entra pela esquerda |
| Tocar vídeo | o círculo cresce e fica verde |
| Logo | os quatro quadrados verdes sobem em sequência e assentam |
"Falar sobre meu projeto" sempre leva ao formulário. Controle que abre o WhatsApp se chama "Chamar no WhatsApp".
## Cena 3D
Fonte em `3d/blocos.py` (Blender), saída em `public/3d/`. Um bloco chanfrado, repetido, com quatro estados: `marca`, `tela`, `integracoes`, `dados`.
| Nível (`sceneTier()` em `lib/device.ts`) | Quem recebe | O que recebe |
|---|---|---|
| `full` | computador com GPU, mais de 4 núcleos e mais de 4 GB | cena em tempo real guiada pela rolagem |
| `lite` | celular com 6 núcleos ou mais e 4 GB ou mais | cena em tempo real leve, que troca de estado sozinha |
| `static` | o resto, movimento reduzido, economia de dados, ou quem não segura 28 ms por quadro | pôster renderizado no Blender |
O pôster é o primeiro quadro de todos os níveis e o LCP continua sendo o título.
+53
View File
@@ -0,0 +1,53 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
- **Principal:** dono ou gerente de pequena ou média empresa de Joinville e região (comércio, serviços, indústria pequena). Não é técnico, chega pelo Google ou por indicação, decide sozinho e prefere falar pelo WhatsApp. Quer um site que traga pedidos ou um sistema para a operação.
- **Secundário:** recrutadores e empresas de tecnologia avaliando o Evandro como desenvolvedor. O site também precisa mostrar a eles o nível técnico do trabalho.
## Product Purpose
Site de Evandro Fonseca Junior, engenheiro da computação de Garuva, SC, que vende sites e sistemas sob medida. O site existe para levar o visitante a uma conversa pelo WhatsApp sobre o projeto dele. Sucesso é conversa iniciada com o tipo de projeto já identificado.
## Positioning
"Você fala com quem desenvolve." A mesma pessoa entende o negócio, decide a estrutura e programa. Cuida de tela, dados e equipamentos (integração com hardware e IoT, experiência na Apolo Energia desde 2022). Usa IA como ferramenta e responde pelo resultado.
## Operating Context
- A conversa começa pelo WhatsApp, com a mensagem já montada pelo formulário; nada é enviado automaticamente.
- Ofertas atuais: site que traz pedidos pelo WhatsApp, sistema para tirar a operação da planilha, revisão de sistema feito com IA (`/ia`).
- Páginas de serviço em `/servicos` e casos em `/cases`.
## Capabilities and Constraints
- Sem preço e sem prazo no site: isso se combina na conversa.
- O título é o primeiro conteúdo a pintar (LCP); o site funciona sem JavaScript e com movimento reduzido.
- 3D e vídeo só onde o aparelho aguenta; quem não aguenta recebe a versão estática.
- Métricas de navegação só com consentimento.
## Brand Commitments
- Nome: Evandro.dev.br. Símbolo de blocos (`public/brand/mark-dark.svg`), verde `#ccff00` sobre preto.
- Voz: português direto, frase curta, sem jargão, sem promessa que não dá para provar.
## Evidence on Hand
- Clientes reais: SEV Moda Íntima, Lavação 047, SC Plus Aquicultura, AS Serviços, Julia Beauty, com vídeos e capturas em `public/videos/`.
- 5 anos de experiência, calculados a partir de `public/portfolio-data.json`.
- Casos de falha em sistemas feitos com IA, com fonte, em `app/ia/page.tsx`.
- Não há depoimento com número, prêmio nem métrica de resultado de cliente: não inventar.
## Product Principles
1. Cada afirmação tem fonte ou prova à vista; o que não foi provado é dito como não provado.
2. Mostrar trabalho real antes de falar de tecnologia.
3. O visitante leigo entende em três segundos o que é oferecido e como falar.
4. Desempenho e acessibilidade vêm antes do efeito.
5. A IA aparece como ferramenta sob responsabilidade de um engenheiro, nunca como inimiga de quem contrata.
+155 -22
View File
@@ -1,36 +1,169 @@
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
<img src="assets/readme/hero.svg" width="100%" alt="evandro.dev.br: o portfólio de Evandro Fonseca Junior, com o índice dos nove cases publicados e o estado de cada um">
First, run the development server:
![Next.js 16.3.8](https://img.shields.io/badge/Next.js-16.3.8-ccff00?style=flat-square&labelColor=0a0a0a)
![React 19.1.0](https://img.shields.io/badge/React-19.1.0-ccff00?style=flat-square&labelColor=0a0a0a)
![Node 22.x](https://img.shields.io/badge/Node-22.x-ccff00?style=flat-square&labelColor=0a0a0a)
![Status: em produção](https://img.shields.io/badge/status-em_produ%C3%A7%C3%A3o-ccff00?style=flat-square&labelColor=0a0a0a)
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
[Site](https://evandro.dev.br) · [Cases](https://evandro.dev.br/cases) · [Currículo](https://evandro.dev.br/curriculo) · [LinkedIn](https://www.linkedin.com/in/evandrodevbr/) · [Issues](https://git.evandro.dev.br/evandro/portifolio-evandrodevbr/issues)
</div>
| SEV Moda Íntima | NutriScan |
|---|---|
| <img src="./public/assets/prints/print-sev.png" width="380" alt="Site da SEV Moda Íntima"> | <img src="./public/assets/prints/print-nutriscan.png" width="380" alt="NutriScan"> |
| **SC Plus Aquicultura** | **AS Serviços** |
| <img src="./public/assets/prints/print-scplus.png" width="380" alt="Site da SC Plus Aquicultura"> | <img src="./public/assets/prints/print-asservicos.png" width="380" alt="Site da AS Serviços"> |
Capturas feitas com Chrome headless a partir de deploys de produção do próprio autor. Origem e créditos (inclusive do ícone do WhatsApp) em [`CREDITS.md`](CREDITS.md).
## O que é
O código do site [evandro.dev.br](https://evandro.dev.br), o portfólio de Evandro Fonseca Junior, engenheiro da computação em Garuva-SC. Pequenos negócios costumam vender e atender por mensagem: o cliente pergunta o preço um a um, o estoque vive em planilha e qualquer mudança no site depende de um desenvolvedor. O site mostra projetos entregues, serviços, currículo e um botão de WhatsApp para começar a conversa.
- Apresenta três ofertas: criação de sites, sistemas web sob medida e revisão de apps feitos com IA (`/ia`).
- Publica nove cases com stack, entregas e decisões técnicas, cada um em `/cases/<id>`.
- Detalha seis serviços em `/servicos/*`, com escopo e etapas visíveis e a parte técnica em `<details>`.
- Serve o currículo em HTML e o PDF em `/Profile.pdf`.
- Gera `sitemap.xml`, `robots.txt`, manifest, imagens Open Graph e dados estruturados JSON-LD.
### Cases publicados
Fonte: `public/portfolio-data.json` e `lib/site-content.ts` (os mesmos nove ids).
| Case | O que é | Estado | Demo |
|---|---|---|---|
| [`julia-beauty`](https://evandro.dev.br/cases/julia-beauty) | Julia Beauty: site de estúdio de extensão de cílios em Camboriú, com simulador de efeito e SEO local | Em produção | [juliabeauty.com.br](https://juliabeauty.com.br) |
| [`sev-modaintima`](https://evandro.dev.br/cases/sev-modaintima) | SEV Moda Íntima: catálogo digital e loja virtual, pedido pelo WhatsApp e painel de estoque | Em produção | [sevmodaintima.com.br](https://sevmodaintima.com.br) |
| [`lavacao-047`](https://evandro.dev.br/cases/lavacao-047) | Lavação 047: site e painel para lavação automotiva em Garuva-SC, o dono edita preços, textos e fotos | Em produção | [landing-page-lavacao047.vercel.app](https://landing-page-lavacao047.vercel.app) |
| [`nutriscan`](https://evandro.dev.br/cases/nutriscan) | NutriScan: consulta de produtos por nome e código de barras | Em produção | [nutriscan.evandro.dev.br](https://nutriscan.evandro.dev.br) |
| [`twins-lab`](https://evandro.dev.br/cases/twins-lab) | Twins Lab: painel ao vivo de um reator simulado | Protótipo | sem demo |
| [`reddit-explorer`](https://evandro.dev.br/cases/reddit-explorer) | Reddit Posts Explorer: posts de um fórum de IA, filtrados por período | Concluído | sem demo |
| [`sc-plus`](https://evandro.dev.br/cases/sc-plus) | SC Plus Aquicultura: site institucional com áreas, projetos e contato | Concluído | [scplus.evandro.dev.br](https://scplus.evandro.dev.br) |
| [`gcc-installer`](https://evandro.dev.br/cases/gcc-installer) | MinGW GCC Installer: instalador do compilador GCC para Windows | Concluído | sem demo |
| [`as-servicos`](https://evandro.dev.br/cases/as-servicos) | AS Serviços: site institucional com cena 3D | Concluído | sem demo |
## Por que usar
O repositório é a referência de como o site funciona, não um template. As decisões que valem conferir:
- **Sem backend.** O formulário de contato abre o `wa.me` com o briefing já preenchido, e o rodapé mantém o contato direto por e-mail; não há API route ativa nem banco.
- **3D só onde o aparelho aguenta.** O hero e o notebook de projetos em Three.js aparecem só onde o dispositivo comporta; em telas pequenas, com ponteiro `coarse` ou movimento reduzido, o site entrega a versão em HTML estático.
- **Analytics com consentimento.** O Google Analytics só carrega depois de aceite explícito, e o consentimento pode ser revogado depois (detalhes em `/privacidade`).
- **Demos externas não derrubam o render.** O estado de cada demo vem de um snapshot versionado; em produção, o render não consulta as demos.
Quando não usar: se você procura um template de portfólio. O projeto é pessoal (`"private": true` no `package.json`) e não tem licença publicada.
## Como funciona
1. O conteúdo vem de `public/portfolio-data.json` (lido por `lib/portfolio-data.server.ts`) e de `lib/site-content.ts`; a homepage escolhe os projetos pelo campo `featured`.
2. `data/project-health.json` é um snapshot versionado das demos. Ele só controla o CTA de cada card (disponível, indisponível, desconhecido ou sem demo) e nunca remove um card.
3. Em produção, o render só lê o snapshot. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é atualizado em memória. `pnpm check:projects` atualiza o arquivo por rename atômico. Contrato completo em [`docs/project-health.md`](docs/project-health.md).
4. Um único workflow, [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml), roda em `push`, `pull_request` e `workflow_dispatch`, com permissão `contents: read`:
| Job | O que roda | Bloqueia o deploy |
|---|---|---|
| `verify` | Node 22, `pnpm install --frozen-lockfile`, `lint`, `typecheck`, `test -- --runInBand`, `build` | Sim |
| `audit` | `pnpm audit --prod` e `pnpm audit --dev`, ambos com `--audit-level=high` | Não |
| `e2e` | Playwright (Chromium) contra o build de produção na porta 3001; publica `test-results/` | Não |
| `deploy` | Vercel CLI `61.1.0`, só após `verify`: branch padrão vai para produção, as demais para preview; pull request não publica | n/a |
O deploy usa o secret `VERCEL_TOKEN`; o projeto na Vercel é o nome do repositório, ou a variável `VERCEL_PROJECT`. [`nixpacks.toml`](nixpacks.toml) (`nodejs_22`, `pnpm-9_x`) e [`vercel.json`](vercel.json) (`framework: nextjs`) descrevem o build.
<details>
<summary>Rotas e estrutura</summary>
| Rota | Conteúdo |
|---|---|
| `/` | Hero, notebook de projetos, perfil, competências, experiência, projetos, modalidades de trabalho e contato |
| `/cases`, `/cases/<id>` | Índice e página de cada um dos nove cases |
| `/servicos` | Índice dos seis serviços |
| `/servicos/criacao-de-sites` | Criação de sites |
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida |
| `/servicos/consultoria-nextjs` | Consultoria Next.js |
| `/servicos/tauri-rust` | Aplicativos desktop para Windows e Linux |
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT |
| `/servicos/arquitetura-de-software` | Diagnóstico de arquitetura |
| `/ia` | Revisão de sistema e de app feito com IA |
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf` |
| `/privacidade` | Privacidade e consentimento de analytics |
```text
app/ rotas (App Router) e componentes
lib/ conteúdo (site-content.ts), SEO, JSON-LD, WhatsApp, project health
public/ portfolio-data.json, Profile.pdf, logos, capturas e mídias dos cases
data/ project-health.json, snapshot versionado das demos
scripts/ check-projects.mjs e utilitários de SEO e vídeo
e2e/ testes Playwright (9 specs)
docs/ SEO/GEO, project health e planos
.gitea/workflows/ ci.yml (verify, audit, e2e, deploy)
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
</details>
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Início rápido
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.
Requisitos: Node.js `22.x` (`engines`), pnpm `9.15.9` (`packageManager`) e, só para `pnpm test:e2e`, o Chromium do Playwright.
## Learn More
```bash
corepack [email protected] install --frozen-lockfile
corepack [email protected] dev # Next.js com Turbopack
corepack [email protected] build # build de produção
corepack [email protected] start # serve um build já gerado
```
To learn more about Next.js, take a look at the following resources:
A instalação é sempre com `--frozen-lockfile`, igual ao CI e ao Nixpacks.
- [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.
> [!IMPORTANT]
> O Next.js (`16.3.8`) é fixado de propósito: a `16.2.11` mantinha três advisories transitivos de severidade alta, e a `16.3.0` foi a menor versão estável da mesma major que deixou as auditorias sem vulnerabilidades high. Não faça downgrade sem repetir `pnpm audit`.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
Validação completa, na mesma ordem do CI:
## Deploy on Vercel
```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
```
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.
Testes de ponta a ponta: `pnpm build && pnpm start -p 3001` e, em outro terminal, `BASE_URL=http://localhost:3001 pnpm test:e2e`.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Outros scripts: `pnpm format` (Biome), `pnpm test:watch` (Jest em modo watch) e `pnpm check:projects` (atualiza `data/project-health.json`).
## Configuração
Não há `.env.example`, e nenhuma variável é necessária para rodar localmente. O código lê:
| Variável | Descrição |
|---|---|
| `NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL` | Rótulo de conversão do Google Ads; sem ele, nenhum evento de conversão é enviado |
| `BASE_URL` | Endereço do site testado por `pnpm test:e2e` (padrão `http://localhost:3001`) |
| `PW_CHROMIUM` | Caminho de um Chromium local para o Playwright, para não baixar o navegador |
## Estado do projeto
Em produção em [evandro.dev.br](https://evandro.dev.br). Stack: Next.js 16.3.8, React 19.1.0, TypeScript 5.9.2, Tailwind CSS 4, Three.js, Lenis e Lucide React. Qualidade: Biome, Jest 30 com Testing Library (150 arquivos de teste), Playwright e axe-core. Todas as dependências diretas estão fixadas em versões exatas, exceto `lenis` (`^1.3.26`).
Limites e próximos passos:
- **Demos externas podem aparecer como indisponíveis.** O CTA reflete o último snapshot versionado, não uma consulta em tempo real. No snapshot de 2026-07-11, só a SEV respondeu 200; `nutriscan` falhou na consulta, `twins-lab` respondeu 404 e `sc-plus`, 502. Rode `pnpm check:projects` para renová-lo. Em 2026-10-24, `nutriscan.evandro.dev.br` continuava sem responder com o app.
- **Ações externas de SEO/GEO pendentes.** Search Console, Bing, Cloudflare e CrUX estão só no runbook.
Documentação: [`docs/project-health.md`](docs/project-health.md) (estados do CTA das demos e atualização do snapshot), [`docs/seo-geo-operations.md`](docs/seo-geo-operations.md) (runbook de SEO e GEO, descrito e **não executado**), [`docs/seo-geo-validation.md`](docs/seo-geo-validation.md) (validação de SEO e GEO) e [`CREDITS.md`](CREDITS.md).
O CI roda em `push` e `pull_request`; antes de abrir um PR, rode `pnpm lint && pnpm typecheck && pnpm test -- --runInBand && pnpm build`.
## Licença
Sem licença publicada: todos os direitos reservados.
<div align="center">
<sub>Feito por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
</div>
+109
View File
@@ -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;`);
},
);
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
/**
* O React estável do Jest não exporta <ViewTransition> (só o canary do Next). Para conferir as props dele:
* `jest.mock("react", () => jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe());`
* O dublê põe `name`, `share` e `default` num <div> em volta dos filhos (data-vt-name, data-vt-share, data-vt-default).
*/
export function reactWithProbe() {
const actual = jest.requireActual("react");
function ViewTransition({
children,
name,
share,
default: fallback,
}: {
children?: ReactNode;
name?: string;
share?: string;
default?: string;
}) {
return actual.createElement(
"div",
{
"data-vt-name": name,
"data-vt-share": share,
"data-vt-default": fallback,
},
children,
);
}
return { ...actual, ViewTransition };
}
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(6);
});
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();
});
});
+73
View File
@@ -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;
}
});
});
+146
View File
@@ -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: "Chamar no WhatsApp",
});
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" });
});
});
+795
View File
@@ -0,0 +1,795 @@
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",
"julia-beauty",
];
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
* ("Chamar no WhatsApp") 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 "Chamar no WhatsApp" 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.whatsapp });
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 nine public cases, the new client cases among them", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(9);
expect(new Set(staticSlugs).size).toBe(9);
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
expect(staticSlugs).toContain("sev-modaintima");
expect(staticSlugs).toContain("lavacao-047");
expect(staticSlugs).toContain("julia-beauty");
for (const id of [
"sev-modaintima",
"lavacao-047",
"as-servicos",
"julia-beauty",
]) {
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.whatsapp,
}),
).toBeNull();
const closingSection = sectionHeadings[5].closest("section");
expect(closingSection).toContainElement(
within(closingSection as HTMLElement).getByRole("link", {
name: buttonLabels.whatsapp,
}),
);
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 "Chamar no WhatsApp" seguidos
expect(
within(capabilitiesSection as HTMLElement).queryByRole("link", {
name: buttonLabels.whatsapp,
}),
).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.whatsapp,
}),
).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.whatsapp });
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",
"julia-beauty",
]);
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.whatsapp });
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);
});
+148
View File
@@ -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 "Chamar no WhatsApp"
* 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 "Chamar no WhatsApp" é 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
* "Chamar no WhatsApp" 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: "Chamar no WhatsApp" });
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: "Chamar no WhatsApp" });
// 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 abre o WhatsApp e tem o mesmo rótulo nos dois (`buttonLabels.whatsapp`), e o assunto da conversa também:
* `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.whatsapp}
</WhatsAppLink>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver outros cases
</HoverPrefetchLink>
</div>
<CaseRelatedServices caseId={caseStudy.id} />
</div>
</section>
);
}
@@ -0,0 +1,99 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies } from "@/lib/site-content";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { coverTransitionProps } from "./cover-transition";
/**
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster),
* os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`)
* e o pôster como elemento compartilhado da troca de página.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
jest.mock("react", () =>
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
);
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: capture found -> the poster is the shared element of the page change (case-<id>, only the pair animates)", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
const transition = image.parentElement;
expect(transition).toHaveAttribute("data-vt-name", `case-${props.id}`);
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
expect(transition).toHaveAttribute("data-vt-default", "none");
});
it("coverTransitionProps: every case gets its own name, valid as a CSS identifier", () => {
const names = caseStudies.map(({ id }) => coverTransitionProps(id).name);
for (const name of names) expect(name).toMatch(/^[a-z][a-z0-9-]*$/);
expect(new Set(names).size).toBe(names.length);
});
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\)$/);
}
});
});
+72
View File
@@ -0,0 +1,72 @@
import Image from "next/image";
import { ViewTransition } from "react";
import {
BrowserFrame,
displayUrl,
} from "@/app/components/showcase/BrowserFrame";
import { getCaseMedia } from "./case-media";
import { coverTransitionProps } from "./cover-transition";
/**
* 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]">
<ViewTransition {...coverTransitionProps(id)}>
<Image
src={media.poster}
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
{...priorityProps}
className="object-cover object-top"
/>
</ViewTransition>
</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,
);
});
});
+41
View File
@@ -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();
});
});
+91
View File
@@ -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}` },
]}
/>
{/* o tipo do projeto, sem o nome: a trilha logo acima já o diz */}
<p className="ds-eyebrow">
{isClient ? "Case de cliente" : "Projeto de laboratório"}
</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,
);
});
});
+53
View File
@@ -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="noopener noreferrer"
>
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a>
</div>
) : null}
</div>
</section>
);
}
+94
View File
@@ -0,0 +1,94 @@
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");
// em repouso o play é escuro, com contorno branco; o limão só vem sob o ponteiro ou no foco, com o círculo maior
const circle = button.querySelector("[aria-hidden='true']");
const restClasses = (circle?.className ?? "")
.split(" ")
.filter((name) => !name.startsWith("group-"));
expect(restClasses.join(" ")).not.toContain("--slash");
expect(circle).toHaveClass(
"group-hover:scale-108",
"group-hover:bg-[var(--slash)]",
"group-focus-visible:scale-108",
"group-focus-visible:bg-[var(--slash)]",
);
});
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");
});
});
+91
View File
@@ -0,0 +1,91 @@
"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"
>
{/* sob o ponteiro ou no foco o círculo cresce e fica verde (só o `scale` anima; a cor troca na hora) */}
<span
aria-hidden="true"
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-[scale] duration-[280ms] ease-[var(--ease-out)] group-hover:scale-108 group-hover:border-[var(--slash)] group-hover:bg-[var(--slash)] group-hover:text-[var(--ink)] group-focus-visible:scale-108 group-focus-visible:border-[var(--slash)] group-focus-visible:bg-[var(--slash)] group-focus-visible:text-[var(--ink)] 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>
);
}
+108
View File
@@ -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);
});
});
+68
View File
@@ -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);
});
});
+24
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
/**
* A capa como elemento compartilhado da troca de página: com o mesmo nome na lista (home, /cases) e no topo do case,
* ela viaja de um lugar ao outro (classe case-cover em globals.css). Só a troca entre as duas anima
* (`default="none"`). Duas instâncias com o mesmo nome na mesma página cancelam a transição: cada página mostra
* a capa de um case uma vez só. Fica fora de CaseCover porque a lista da home é componente de cliente, e
* CaseCover lê as capturas só no servidor.
*/
export function coverTransitionProps(id: string) {
return { name: `case-${id}`, share: "case-cover", default: "none" } as const;
}
+15
View File
@@ -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";
+18
View File
@@ -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,
);
}
+77
View File
@@ -0,0 +1,77 @@
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: a única imagem decorativa (alt vazio) é a peça de blocos do topo, preguiçosa e sem prioridade
const decorative = Array.from(
container.querySelectorAll<HTMLImageElement>("img"),
).filter((image) => image.getAttribute("alt") === "");
expect(decorative).toHaveLength(1);
expect(decorative[0].getAttribute("src")).toContain("estado-tela.webp");
expect(decorative[0]).toHaveAttribute("loading", "lazy");
expect(decorative[0]).not.toHaveAttribute("fetchpriority");
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);
}
});
});
+181
View File
@@ -0,0 +1,181 @@
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: an odd number of client cases -> the last card takes the whole row on wide screens, so the grid ends with no empty cell", () => {
const { container } = render(<CasesPage />);
const wide = clientCasesForListing.map((caseStudy) =>
clientCard(container, caseStudy.id).className.includes(
"min-[1025px]:col-span-2",
),
);
const odd = clientCasesForListing.length % 2 === 1;
expect(wide).toEqual(
clientCasesForListing.map(
(_, index) => odd && index === clientCasesForListing.length - 1,
),
);
});
it("path: contact -> 'Chamar no WhatsApp', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus, AS Serviços and Julia Beauty", () => {
const { container } = render(<CasesPage />);
expect(
screen.getByRole("link", { name: buttonLabels.whatsapp }),
).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",
"Julia Beauty",
]);
// um botão de contato só na página: o do fecho
expect(
screen.getAllByRole("link", { name: buttonLabels.whatsapp }),
).toHaveLength(1);
});
});
describe("CasesPage: topo sem mosaico", () => {
it("path: captures found -> no poster mosaic (the cards below already carry the covers), only the block piece beside the text, a lower strip, no 'Veja o que já' twice", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
const images = hero?.querySelectorAll("img") ?? [];
expect(images).toHaveLength(1);
expect(images[0].getAttribute("src")).toContain("estado-tela.webp");
// decorativa e só de 1024 px em diante
expect(images[0].closest('[aria-hidden="true"]')).toHaveClass(
"hidden",
"lg:block",
);
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?.querySelectorAll("img")).toHaveLength(1);
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");
}
});
});
+241
View File
@@ -0,0 +1,241 @@
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 { PagePiece } from "@/app/components/PagePiece";
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, heroWithMediaGrid } 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.
* `wide`: o cartão que sobraria sozinho na última linha ocupa a linha inteira, capa à esquerda e texto à direita.
*/
function ClientCaseCard({
caseStudy,
priority = false,
wide = false,
}: {
caseStudy: CaseStudyContent;
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
priority?: boolean;
wide?: boolean;
}) {
return (
<article
className={`ds-card !h-auto ${
// a partir de 1025 px, onde .ds-grid-2 (globals.css) passa a duas colunas; o "!" vence o display:flex de .ds-card,
// que fica fora das camadas do Tailwind
wide
? "min-[1025px]:col-span-2 min-[1025px]:!grid min-[1025px]:grid-cols-2 min-[1025px]:items-center min-[1025px]:gap-x-sp-8 min-[1025px]:[&>:first-child]:mb-0"
: ""
}`}
>
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
priority={priority}
/>
<div>
<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="noopener noreferrer"
>
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a>
) : null}
</div>
</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 ${heroWithMediaGrid}`}>
<div className="min-w-0 max-w-[860px]">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
]}
/>
<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>
<PagePiece image="/3d/estado-tela.webp" />
</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, all) => (
<ClientCaseCard
key={caseStudy.id}
caseStudy={caseStudy}
priority={index === 0}
wide={index === all.length - 1 && all.length % 2 === 1}
/>
))}
</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.whatsapp}
</WhatsAppLink>
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
{buttonLabels.allServices}
</HoverPrefetchLink>
</div>
</div>
</section>
</main>
);
}
+220
View File
@@ -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
View File
@@ -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>
);
}
}
+57
View File
@@ -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");
});
});
+31
View File
@@ -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\]/);
});
});
+34
View File
@@ -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>
);
}
+51
View File
@@ -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());
});
});
+136
View File
@@ -0,0 +1,136 @@
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 44 px of touch target on the phone (min-h-11) and 24 px from 640 px on, where the sector shares the item", () => {
render(<ClientStrip clients={clients} />);
for (const link of screen.getAllByRole("link")) {
expect(link).toHaveClass("min-h-11", "sm:min-h-6");
}
});
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");
});
it("stacks the sector under each name from 640 px on, outside the link, and shows only the name for a client without one", () => {
render(
<ClientStrip
clients={[
{
id: "sev-modaintima",
name: "SEV Moda Íntima",
sector: "loja virtual",
},
{ id: "lavacao-047", name: "Lavação 047" },
]}
/>,
);
const sector = screen.getByText("loja virtual");
// 13 px e apagado, e só de 640 px em diante (abaixo, só os nomes)
expect(sector).toHaveClass("text-[13px]", "max-sm:hidden");
const link = screen.getByRole("link", { name: "SEV Moda Íntima" });
expect(link).not.toContainElement(sector);
expect(link.parentElement).toContainElement(sector);
expect(link.parentElement).toHaveClass("flex-col");
// o nome continua levando ao case
expect(link).toHaveAttribute("href", "/cases/sev-modaintima");
const plain = screen.getByRole("link", { name: "Lavação 047" });
expect(plain.parentElement?.childElementCount).toBe(1);
});
+100
View File
@@ -0,0 +1,100 @@
import HoverPrefetchLink from "./HoverPrefetchLink";
export type ClientLink = {
id: string;
name: string;
/** Ramo do cliente ("loja virtual"), sob o nome de 640 px em diante (lib/proof.ts). */
sector?: 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.
* De 640 px em diante cada cliente é um par empilhado: o nome (link) sobre o ramo, em 13 px e apagado; abaixo disso,
* só os nomes. Os links têm 44 px de altura de toque no celular e 24 px a partir de 640 px, onde o ramo divide a
* altura do item; a rota só é pré-carregada quando a pessoa chega no link. Os pontos, o rótulo e os anos se alinham
* pela linha dos nomes (`sm:items-start`).
*
* 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: (
<span className="flex flex-col">
<HoverPrefetchLink
href={`/cases/${client.id}`}
className="inline-flex min-h-11 items-center self-start 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)] sm:min-h-6"
>
{client.name}
</HoverPrefetchLink>
{client.sector && (
<span className="text-[13px] leading-[18px] max-sm:hidden">
{client.sector}
</span>
)}
</span>
),
})),
...(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)] sm:items-start ${className}`.trim()}
>
{clients.length > 0 && <p>Já entregue para:</p>}
{/* de 640 px em diante a lista fica ao lado do rótulo enquanto sobram 24rem, e quebra por dentro; menos que isso,
ela desce para baixo do rótulo e usa a largura toda */}
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1 sm:min-w-[min(24rem,100%)] sm:flex-1 sm:basis-0 sm:items-start sm:gap-y-sp-2">
{items.map((item, index) => (
<li
key={item.key}
className={`flex items-center pr-4 sm:items-start${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");
});
});
+247
View File
@@ -0,0 +1,247 @@
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 five 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",
"Atendimento no WhatsApp com IA sob controle",
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
"Revisão de sistema que já existe",
"Nota fiscal pronta para a reforma tributária",
]);
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"],
["whatsapp", "Atendimento no WhatsApp com IA sob controle"],
["revisao", "Revisão de sistema que já existe"],
["fiscal", "Nota fiscal pronta para a reforma tributária"],
])("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"],
["whatsapp", "Atendimento no WhatsApp com IA sob controle"],
["revisao", "Revisão de sistema que já existe"],
["fiscal", "Nota fiscal pronta para a reforma tributária"],
])(
"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.",
);
choose("whatsapp");
expect(directMessage()).toContain(
"Quero conversar sobre um atendimento no WhatsApp com IA sob controle.",
);
choose("fiscal");
expect(directMessage()).toContain(
"Quero conversar sobre a adaptação da nota fiscal à reforma tributária.",
);
// 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();
});
});
+282
View File
@@ -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" }),
);
});
});
+441
View File
@@ -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
View File
@@ -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-[var(--whatsapp)]/10 text-[var(--whatsapp)]">
<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-[var(--whatsapp)]">
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-[var(--whatsapp)]"
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-[var(--whatsapp)] 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 cv-fill flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[var(--whatsapp)] px-sp-4 py-sp-4 text-[#071b10] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--whatsapp)] 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-[var(--whatsapp)] px-sp-4 font-semibold text-[#071b10] no-underline"
>
Chamar 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();
});
});
-925
View File
@@ -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>
);
}
-128
View File
@@ -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);
});
});
+205
View File
@@ -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);
});
});
+84
View File
@@ -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>
);
}
-194
View File
@@ -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>
);
}
+90
View File
@@ -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),
);
}
});
});
+73
View File
@@ -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="ds-faq-item 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>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useSyncExternalStore } from "react";
import { textLink } from "./button-styles";
import { isFiscalNoticeActive } from "./fiscal-notice";
import HoverPrefetchLink from "./HoverPrefetchLink";
/** A data não muda enquanto a página está aberta: nada a observar. */
const subscribe = () => () => {};
/**
* Faixa datada da nota fiscal, acima dos cartões da home. Quem a coloca na página (WorkModalities) só a renderiza dentro
* do prazo; aqui o navegador confere a data de novo, porque a home é gerada no build: depois do prazo ela some sem um
* build novo. Na hidratação vale o valor do servidor (visível), e só depois a data do navegador: sem aviso de hidratação.
*/
export function FiscalNotice({ href }: Readonly<{ href: string }>) {
const active = useSyncExternalStore(
subscribe,
() => isFiscalNoticeActive(new Date()),
() => true,
);
if (!active) return null;
return (
<div className="mb-sp-6 flex flex-col gap-sp-1 border-y border-[var(--border)] py-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
<p className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
<strong className="font-semibold text-[var(--ink)]">
Seu sistema emite nota fiscal?
</strong>{" "}
Os optantes do Simples Nacional entram na reforma tributária em 1º de
janeiro de 2027.
</p>
<HoverPrefetchLink
href={href}
className={`${textLink} shrink-0 self-start text-[var(--ink-80)]`}
>
Ver o que muda
</HoverPrefetchLink>
</div>
);
}
@@ -0,0 +1,263 @@
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: "Chamar 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 marks <html> while open, which hides the button by CSS, and drops the mark 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" });
// a barra publica a altura (o menu do celular a usa) e marca o <html>; o botão flutuante some por CSS com a marca
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("72px");
expect(document.documentElement).toHaveAttribute(
"data-cookie-bar",
"open",
);
expect(floatingLink()?.parentElement).toHaveClass(
"[[data-cookie-bar=open]_&]:hidden",
);
await user.click(refuse);
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("");
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
expect(floatingLink()).toBeInTheDocument();
} finally {
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
}
});
});
Loaded 100 of 614 files, more files were not shown because too many files have changed in this diff. Show more