Compare commits

..
Author SHA1 Message Date
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 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
277 changed files with 21699 additions and 14514 deletions

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
name: CI
"on":
push:
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --runInBand
- run: pnpm build
- run: pnpm audit --prod --audit-level=high
- run: pnpm audit --dev --audit-level=high
+5
View File
@@ -46,6 +46,8 @@ next-env.d.ts
.claude/settings.local.json
.superpowers/
/.worktrees/
/.impeccable/
/.serena/
# generated analysis artifacts
/graphify-out/
@@ -53,3 +55,6 @@ next-env.d.ts
*.analysis.json
*.tmp.json
*.debug.json
.omo/
.playwright-mcp/
+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.
+193 -63
View File
@@ -1,88 +1,218 @@
<div align="center">
# Evandro Dev — Portfolio
# Evandro.dev.br: software sob medida
**A cinematic, interactive developer portfolio built with Next.js, React, TypeScript, and motion-first UI engineering.**
![Next.js](https://img.shields.io/badge/Next.js-15-000?logo=nextdotjs)
![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=111)
![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=fff)
![Biome](https://img.shields.io/badge/Code%20Style-Biome-60A5FA)
![Jest](https://img.shields.io/badge/Tests-Jest-C21325?logo=jest&logoColor=fff)
Site comercial de Evandro Fonseca Junior para projetos de software sob medida: sistemas empresariais, aplicativos e integrações, com cases e contato direto por WhatsApp.
</div>
## Overview
## Requisitos
This is the portfolio site for **Evandro Dev**. It presents professional work, case studies, technical positioning, and interactive visual storytelling through a high-polish Next.js interface.
- Node.js `22.x`
- pnpm `9.15.9`, fixado em `package.json#packageManager`
The project combines a modern App Router codebase with motion design, 3D/visual effects, and tested UI components.
O projeto usa Next.js `16.3.3`, patch de segurança que corrige [GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36) e [GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4). A atualização substitui `16.3.0`, afetada por esses avisos. As auditorias de produção e desenvolvimento continuam sendo verificadas na CI.
## Highlights
| Area | Details |
| --- | --- |
| Framework | Next.js App Router with React and TypeScript. |
| Motion | GSAP, Framer Motion, Lenis, and custom animation components. |
| Visuals | Three.js / React Three Fiber / Drei for interactive and cinematic surfaces. |
| Styling | Tailwind CSS, utility composition, and reusable component primitives. |
| Quality | Jest, Testing Library, property-based tests, and Biome checks. |
| Content | Portfolio sections, case pages, company logos, hero composition, and supporting docs. |
## Project structure
```txt
app/
page.tsx Main portfolio route
layout.tsx Root layout and metadata
components/ Portfolio sections and cinematic UI modules
cases/ Case-study routes/content
api/ Server/API routes
components/ Shared UI primitives
lib/ Utilities and shared logic
docs/ Supporting documentation
graphify-out/ Project analysis output
public/ Static assets
```
## Getting started
## Início rápido
```bash
pnpm install
pnpm dev
corepack [email protected] install --frozen-lockfile
corepack [email protected] dev
```
The development server uses Turbopack through the project script.
O servidor de desenvolvimento usa Turbopack. Para executar o artefato de produção:
```bash
corepack [email protected] build
corepack [email protected] start
```
## Scripts
| Command | Purpose |
| Comando | Comportamento |
| --- | --- |
| `pnpm dev` | Start the Next.js development server. |
| `pnpm build` | Build the production application. |
| `pnpm start` | Start the production server after building. |
| `pnpm lint` | Run Biome checks. |
| `pnpm format` | Format the codebase with Biome. |
| `pnpm test` | Run Jest tests. |
| `pnpm test:watch` | Run Jest in watch mode. |
| `pnpm dev` | Inicia o Next.js em desenvolvimento com Turbopack. |
| `pnpm build` | Gera o build de produção com Turbopack. |
| `pnpm start` | Serve um build já gerado. |
| `pnpm lint` | Executa `biome check`. |
| `pnpm typecheck` | Gera os tipos de rota com `next typegen` e executa TypeScript sem emitir arquivos. |
| `pnpm format` | Formata o repositório com Biome. |
| `pnpm test` | Executa a suíte Jest. |
| `pnpm test:watch` | Executa Jest em modo watch. |
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
## Engineering standards
## Contagem de projetos do GitHub
- Keep visual sections modular and testable.
- Respect reduced-motion and accessibility expectations for animated sections.
- Keep portfolio copy and case-study content easy to update without touching low-level animation code.
- Validate interactive components with tests when changing behavior, layout assumptions, or accessibility states.
O número "Mais de X projetos desenvolvidos" no topo da home vem da API do GitHub (`lib/github-stats.ts`), consultada só no servidor e renovada uma vez por dia (`revalidate = 86400` em `app/page.tsx`). Conta repositórios próprios, públicos e privados, sem forks e sem arquivados.
## Validation
Para incluir os privados, defina `GITHUB_STATS_TOKEN` no ambiente de build e de execução (não use o prefixo `NEXT_PUBLIC_`). O token deve ser um [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) com acesso a todos os repositórios da conta e uma única permissão, **Metadata: Read-only**. Sem o token, ou se a consulta falhar, o número simplesmente não aparece.
Before publishing changes:
## Vitrine de vídeos dos projetos
```bash
pnpm lint
pnpm test
pnpm build
Cada projeto com site gravado aparece de três formas na seção Projetos da home. Tudo mora em `app/components/showcase/`, os dados em `lib/project-showcase.ts` e os estilos em `showcase.css`.
- **Cards.** O vídeo de rolagem fica numa moldura de navegador com o endereço real do site. Só um vídeo toca por vez: o do card sob o mouse (ou com foco) e, sem mouse, o mais perto do centro da tela. Nada é baixado até o card tocar pela primeira vez, e um botão pausa os vídeos que se mexem sozinhos. Clicar na moldura abre o vídeo ampliado (`<dialog>` nativo, com troca computador/celular que mantém o ponto do site, capítulos clicáveis e controles próprios).
- **Vitrine animada.** Logo abaixo dos cards, uma cena presa na rolagem (`position: sticky`) em que o site de cada projeto rola dentro de um notebook, com um celular ao lado, conforme o visitante rola a página. Os capítulos de cada projeto (`chapters` em `lib/project-showcase.ts`, marcados nos quadros dos vídeos) nomeiam o trecho na tela e mantêm notebook e celular no mesmo ponto do site. Só o projeto em cena e os vizinhos baixam vídeo, e nada baixa com "economizar dados" ligado. Com `prefers-reduced-motion` ou sem JavaScript ela some e os cards continuam mostrando os posters.
- **Capa de compartilhamento.** `/cases/<id>` usa o poster do projeto como fundo da imagem Open Graph (`lib/og-card.tsx`), quando existe.
Arquivos de cada projeto em `public/videos/<id>/`:
| Arquivo | Para quê |
| --- | --- |
| `desktop.{webm,mp4,webp}` e `mobile.{webm,mp4,webp}` | cards e vídeo ampliado (até 3 MB cada vídeo) |
| `scrub-desktop.mp4` e `scrub-mobile.mp4` | vitrine animada: quadro-chave a cada 6 quadros, sem B-frames, para o tempo mudar a cada quadro da rolagem |
| `og.jpg` | capa 1200x630 de compartilhamento |
Para gravar ou regravar um projeto, use `scripts/record-project-videos.mjs`; para gerar os `scrub-*.mp4`, `scripts/encode-scrub-video.mjs` (as instruções de instalação do Playwright e do ffmpeg estão no cabeçalho de cada script). Depois de gravar, marque os capítulos do projeto em `lib/project-showcase.ts`.
## Stack
| Área | Pacotes usados |
| --- | --- |
| Aplicação | Next.js 16.3.3, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. |
| Estilos e composição | Tailwind CSS 4, `clsx` e `tailwind-merge`. |
| Movimento | Motion 12 e GSAP 3. |
| 3D e gráficos | Three.js, React Three Fiber e Drei. |
| Interface | Lucide React. |
| Qualidade | Biome, Jest, Testing Library e ambiente JSDOM. |
Todas as dependências diretas estão fixadas em versões exatas no manifesto; o lockfile é instalado sem atualização implícita.
## Rotas
| Rota | Conteúdo |
| --- | --- |
| `/` | Homepage comercial com hero, notebook 3D, projetos, soluções, processo de trabalho, engenharia com IA, critérios de entrega, perfil e contato. |
| `/cases` | Índice dos seis cases. |
| `/cases/nutriscan` | Case NutriScan. |
| `/cases/twins-lab` | Case Twins Lab. |
| `/cases/reddit-explorer` | Case Reddit Posts Explorer. |
| `/cases/sc-plus` | Case SC Plus Aquicultura. |
| `/cases/gcc-installer` | Case MinGW GCC Installer. |
| `/cases/as-servicos` | Case AS Serviços. |
| `/servicos` | Página de destino comercial: sites, sistemas e aplicativos, contato direto no WhatsApp, cases, processo e acesso aos seis serviços. |
| `/servicos/criacao-de-sites` | Criação de sites. |
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida. |
| `/servicos/sob-medida` | Redirecionamento permanente (308) para `/servicos/engenharia-de-software`, preservando query string, UTMs e identificadores de clique. |
| `/servicos/consultoria-nextjs` | Consultoria Next.js. |
| `/servicos/tauri-rust` | Aplicativos desktop para Windows e Linux, com compatibilidade definida por projeto. |
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT. |
| `/servicos/arquitetura-de-software` | Arquitetura de software. |
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. |
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.png`, `/apple-icon.png` e `/favicon.ico`. Caminhos desconhecidos usam a página 404 do projeto. Não há API route ativa.
As páginas de serviços mantêm escopo, exemplos ilustrativos e etapas visíveis; tecnologias e decisões de implementação ficam em um `<details>` nativo. Na homepage, competências e experiência também ficam recolhidas. O header prioriza Soluções (`/#modalities`), Projetos (`/cases`), Como trabalho (`/#process`), Sobre (`/#about`) e Contato (`/#contact`), com ajuste de chegada após a definição do layout do notebook. Currículo e download do PDF são links secundários no final do rodapé.
## Comportamento da aplicação
### Conteúdo e project health
O conteúdo principal vem de `public/portfolio-data.json` e `lib/site-content.ts`. A seleção da homepage usa o campo `featured`; o snapshot de disponibilidade não remove cards. Ele apenas controla o estado do CTA de cada demo: disponível, indisponível, desconhecido ou sem demo.
Em produção, a renderização só lê o snapshot versionado e não consulta demos externas. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é atualizado em memória. A atualização manual opcional é `pnpm check:projects` e grava o arquivo por rename atômico. O contrato completo está em [`docs/project-health.md`](docs/project-health.md).
### Posicionamento comercial e uso de IA
A comunicação apresenta Evandro como responsável direto pelo projeto, sem sugerir equipe, disponibilidade garantida, prazo fixo ou certificação. Os cards de projetos reutilizam os desafios e resumos dos cases quando disponíveis. O antigo painel de telemetria fictícia foi removido; os critérios de qualidade não representam notas de auditoria.
Cada card de projeto aparece ao entrar na tela, observado individualmente e sem recorte por `clip-path`. A revelação não depende de uma porcentagem visível do grid inteiro: em celulares, a lista de uma coluna pode ser várias vezes mais alta que a tela e tornar esse gatilho inalcançável.
`app/components/EngineeringWithAI.tsx` explica arquitetura, permissões, validação e recuperação de dados, com três relatos históricos de 2025 e links junto de cada afirmação:
- Lovable: exposição relatada pela Semafor e CVE-2025-48757, incluindo a contestação do fornecedor no NVD; exposição não é apresentada como roubo comprovado.
- Replit: exclusão de dados e restauração integral, conforme a resposta oficial da empresa.
- Base44: falha de autenticação identificada pela Wiz, corrigida em menos de 24 horas, sem evidência reportada de exploração maliciosa.
As obrigações dos pequenos agentes de tratamento são referenciadas à Resolução CD/ANPD nº 2/2022, arts. 6º, 12 e 13. Ao atualizar esse conteúdo, preservar datas, limites dos relatos e a distinção entre vulnerabilidade, exposição e incidente confirmado.
O formulário valida os campos no navegador e abre um rascunho no WhatsApp para revisão. Ele não envia a mensagem automaticamente e orienta a não informar senhas nem dados pessoais de clientes.
A seção de contato tem título estático único, canais diretos à esquerda e formulário à direita (empilhados no celular). A silhueta do WhatsApp aparece no contato direto, no cabeçalho do formulário e no botão verde. O formulário não se move no hover; campos mantêm rótulos, validação nativa e foco visível. O link direto dispensa preenchimento e o e-mail usa `mailto:`.
Na página `/servicos` e nos seis detalhamentos, `WhatsAppLink` oferece um link nativo com mensagem contextual, sem exigir o formulário da home. Funciona mesmo sem consentimento de analytics ou JavaScript; a medição depende da hidratação e da tag. A página principal prioriza as três intenções dos anúncios antes das especialidades técnicas e usa os cases existentes NutriScan e SC Plus como exemplos de trabalho, não como promessa de retorno comercial.
### 3D progressivo e movimento reduzido
O hero combina a mensagem comercial com três placas 3D: interface, integrações e dados. A interface usa a captura real do NutriScan, recortada acima do rodapé; as outras camadas são ilustrativas, não uma declaração da arquitetura do projeto. Ponteiro, arraste horizontal e quatro botões permitem explorar a composição. O toque vertical continua rolando a página. A renderização para quando os movimentos terminam, fora do viewport e com a aba oculta. Movimento reduzido ou falha gráfica mantêm a captura HTML, os controles e o link do case disponíveis.
O notebook 3D permanece em celulares, tablets e desktop: tampa, tela, abas e cursor seguem o progresso do scroll. No mobile, a câmera adapta o enquadramento e botões externos à tela do notebook permitem selecionar os projetos. A cena renderiza sob demanda e pausa fora do viewport ou com a página oculta. Movimento reduzido, falha de WebGL ou perda de contexto ativam a navegação HTML sem animação.
As quatro capturas desktop reais ficam em `public/assets/notebook/`, em WebP de 1600 × 1000. A troca aguarda a decodificação da próxima imagem, mantendo a anterior visível durante o carregamento ou uma falha.
Uma entrada normal na homepage começa no topo. A recarga também volta ao início, removendo o fragmento deixado por uma navegação anterior. Links diretos como `/#about` continuam chegando à seção após a estabilização do notebook; rolagem deliberada durante a abertura cancela esse ajuste. A política inicial de scroll é aplicada antes da hidratação, sem disputar com a rolagem suave dos links posteriores. Assim que a chegada se resolve, a restauração volta a pertencer ao navegador: Voltar e Avançar restauram a posição do histórico em vez de forçar o topo.
### Identidade visual
O header usa `--site-header-height`: 60px abaixo de 1280px e 68px a partir desse breakpoint. A mesma variável define a posição do menu, o respiro inicial do hero e a compensação das âncoras. O menu mobile tem fundo opaco; a barra fica levemente translúcida ao rolar. Header e hero usam Space Grotesk para leitura comercial, reservando a fonte monoespaçada aos detalhes técnicos das demais seções.
Os quatro arquivos recebidos estão preservados, com nomes descritivos, em `imagens/originais/`. As versões prontas para uso ficam em `public/brand/`:
| Arquivo | Aplicação |
| --- | --- |
| `wordmark-dark.webp` | Logo horizontal transparente, branca e lima, no cabeçalho escuro. |
| `wordmark-light.webp` | Logo horizontal transparente, escura e lima, no rodapé claro. |
| `stacked-light.webp` | Logo empilhada transparente, reservada para composições maiores em fundo claro. |
| `mark-dark.svg` / `mark-light.svg` | Símbolo vetorial para fundos escuros e claros, traçado a partir do original limpo. |
| `mark-dark.png` | Símbolo de 512px incorporado aos cards Open Graph de todas as rotas. |
| `icon-192.png` | Ícone de instalação do manifesto. |
`app/icon.png` (512px), `app/apple-icon.png` (180px) e `app/favicon.ico` (16–256px) usam o símbolo sobre fundo escuro. As variantes mantêm as formas originais; o recorte transparente com bordas danificadas permanece arquivado, sem uso na interface.
### Contato
O formulário da homepage valida os campos nativos de nome, e-mail, tipo e contexto. Ao enviar, ele abre `wa.me` em uma nova aba com o briefing preenchido; não existe backend de formulário. O rodapé mantém contato direto por e-mail.
### Consentimento de analytics
Google Analytics só é carregado depois de aceite explícito. A decisão fica em `evandro.analytics-consent.v1`; recusar não monta o loader. As preferências permanecem acessíveis e a revogação envia consentimento negado, ativa a flag de desabilitação, remove o loader e expira apenas cookies `_ga`/`_ga_*` acessíveis.
O único loader `gtag.js` configura GA4 `G-QTT9QT0T3S` e Google Ads `AW-18432694647`. Os IDs `GT-M3K4CKQT` e `GT-NBBR3PC2` informados no painel são identificadores das tags, não instalações adicionais. A integração segue https://developers.google.com/tag-platform/gtagjs/configure. `ad_storage`, `ad_user_data` e `ad_personalization` permanecem negados, com `ads_data_redaction` ativo. A tag só é montada em produção.
### Medição do contato e Google Ads
`lib/analytics.ts` envia `whatsapp_open` exclusivamente ao GA4 após o acionamento de um link ou a submissão válida do formulário. O evento representa **tentativa de abrir uma conversa**, não mensagem recebida, lead confirmado ou venda. O contato não espera a resposta do Analytics. Eventos recusados ou ocorridos antes do consentimento não são guardados para envio posterior.
Parâmetros enviados: `service` (rótulo estático), `placement` (posição do CTA) e `page_path`. A URL do evento não inclui query, fragmento ou referenciador; rotas dinâmicas são agrupadas como `/servicos/[slug]` e `/cases/[slug]`. Nome, e-mail, mensagem digitada e URL de destino do WhatsApp não entram nesse evento. A distinção do serviço usa o parâmetro `service`.
Posições: `service_hub_hero`, `service_hub_intent`, `service_hub_footer`, `service_detail_hero`, `service_detail_footer`, `contact_direct` e `contact_form`. Isso permite comparar entradas de contato pelo tipo de serviço e pela posição na página. Cliques pelo menu de contexto, bloqueadores e falhas de rede podem não ser medidos.
**Configuração externa ainda necessária:** verificar `whatsapp_open` no GA4, registrá-lo como evento principal, vincular a propriedade à conta Ads correta e importar essa ação de conversão. Conferir a ação existente antes para evitar dupla contagem; não importar simultaneamente um clique automático como a mesma conversão. Para analisar os parâmetros em Explorações, cadastrar dimensões personalizadas de escopo de evento para `service` e `placement`. Nenhum rótulo de conversão Ads foi inventado e nenhuma configuração da conta foi alterada. A medição otimiza intenção de contato, não vendas.
Também revisar as opções de medição otimizada da propriedade, sobretudo eventos automáticos de cliques externos e formulários, pois essa configuração não é controlada por este código. Referência dos eventos: https://developers.google.com/analytics/devguides/collection/ga4/events.
Verificação local: build de produção, navegação responsiva, redirecionamento com parâmetros e requisição real `whatsapp_open` gerada pela tag. Requisições de coleta foram interceptadas antes do envio para não contaminar os relatórios; isso **não comprova recebimento no GA4 nem atribuição no Ads**.
## Estrutura
```text
app/ App Router, rotas e componentes da interface
lib/ Dados derivados, navegação, JSON-LD e project health
public/ Conteúdo JSON, PDF, fontes, imagens e modelo 3D
scripts/check-projects.mjs
Atualização manual do snapshot de demos
data/project-health.json
Última observação versionada das demos
docs/project-health.md Contrato operacional do snapshot
.github/workflows/ci.yml
Gate único de integração contínua
nixpacks.toml Build e start para Nixpacks
```
## License
## CI e deploy
Personal portfolio project unless a repository license says otherwise.
O workflow único em `.github/workflows/ci.yml` roda em cada `push` e `pull_request` com permissão `contents: read`. Ele instala Node 22 e pnpm conforme `packageManager`, restaura o cache do pnpm e executa, nesta ordem: frozen install, lint, typecheck, Jest serial, build e auditorias de produção e desenvolvimento com limite high. O passo de typecheck gera os tipos de rota antes do `tsc`: `next-env.d.ts` não é versionado e, em uma árvore recém-clonada, é ele que declara os módulos de imagem importados pelo código.
O Nixpacks usa `nodejs_22`, `pnpm-9_x`, `pnpm install --frozen-lockfile`, `pnpm run build` e `pnpm run start`. Não há etapa de deploy nem secret de CI neste repositório.
## Validação local
```bash
corepack [email protected] install --frozen-lockfile
corepack [email protected] lint
corepack [email protected] typecheck
corepack [email protected] test -- --runInBand
corepack [email protected] build
corepack [email protected] audit --prod --audit-level=high
corepack [email protected] audit --dev --audit-level=high
```
-8
View File
@@ -1,8 +0,0 @@
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
module.exports = ResizeObserver;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+58
View File
@@ -0,0 +1,58 @@
import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import {
OG_CONTENT_TYPE,
OG_SIZE,
OgCard,
readProjectOgBackground,
} from "@/lib/og-card";
import { caseStudies } from "@/lib/site-content";
function getCaseStudy(slug: string) {
return caseStudies.find(({ id }) => id === slug);
}
function getImageMetadata(caseStudy: (typeof caseStudies)[number]) {
return {
id: caseStudy.id,
alt: `Case ${caseStudy.title} — Evandro Fonseca Junior`,
size: OG_SIZE,
contentType: 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();
return new ImageResponse(
<OgCard
eyebrow="Case de engenharia de software"
title={caseStudy.title}
description={caseStudy.summary}
backgroundImage={readProjectOgBackground(caseStudy.id)}
/>,
OG_SIZE,
);
}
+322
View File
@@ -0,0 +1,322 @@
import { render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
import CasesPage from "../page";
import CasePage, { generateMetadata, generateStaticParams } from "./page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
jest.mock("@/app/components/cinematic/HeroBackground", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("@/app/components/cinematic/MagneticButton", () => ({
__esModule: true,
default: ({
as = "button",
strength: _strength,
children,
...props
}: {
as?: string;
strength?: number;
children?: ReactNode;
} & Record<string, unknown>) => {
const React = require("react");
return React.createElement(as, props, children);
},
}));
const publicCases = [
{ id: "nutriscan", title: "NutriScan" },
{ id: "twins-lab", title: "Twins Lab" },
{ id: "reddit-explorer", title: "Reddit Posts Explorer" },
{ id: "sc-plus", title: "SC Plus Aquicultura" },
{ id: "gcc-installer", title: "MinGW GCC Installer" },
{ id: "as-servicos", title: "AS Serviços" },
] as const;
const publicCaseIds = publicCases.map(({ id }) => id);
function getCase(id: (typeof publicCases)[number]["id"]) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
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];
});
}
function expectCaseCtas() {
const destinations = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href"));
expect(destinations).toContain("/#contact");
expect(destinations).toContain("/servicos");
expect(destinations).not.toContain("/curriculo");
}
it("keeps the six public params and factual case metadata", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(6);
expect(new Set(staticSlugs).size).toBe(6);
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
const caseStudy = getCase("as-servicos");
const url = `${siteUrl}/cases/${caseStudy.id}`;
const metadata = await generateMetadata({
params: Promise.resolve({ slug: caseStudy.id }),
});
expect(metadata).toMatchObject({
title: expect.stringContaining(caseStudy.title),
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
title: caseStudy.title,
description: caseStudy.summary,
url,
},
twitter: {
title: caseStudy.title,
description: caseStudy.summary,
},
});
expect(metadata).not.toHaveProperty("keywords");
});
it("renders the authorized detailed case in order with traceable sources", async () => {
const caseStudy = getCase("as-servicos");
if (!caseStudy.detail) throw new Error("Missing detailed case content");
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
});
const { container } = render(ui);
const heading = screen.getByRole("heading", { level: 1 });
const answer = screen.getByText(caseStudy.detail.answer.text);
expect(heading.nextElementSibling).toBe(answer);
const sectionNames = [
"Desafio verificável",
"Decisões de implementação",
"Limites desta evidência",
"Fontes",
];
const sectionHeadings = sectionNames.map((name) =>
screen.getByRole("heading", { level: 2, name }),
);
for (let index = 1; index < sectionHeadings.length; index += 1) {
expect(
sectionHeadings[index - 1].compareDocumentPosition(
sectionHeadings[index],
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
}
const challengeSection = screen
.getByRole("heading", { level: 2, name: "Desafio verificável" })
.closest("section");
if (!challengeSection) throw new Error("Missing challenge section");
const challengeText = within(challengeSection).getByText(
caseStudy.detail.challenge.text,
);
expect(challengeText).toBeVisible();
expect(screen.getAllByText(caseStudy.impact)).toEqual([challengeText]);
for (const statement of caseStudy.detail.implementation) {
expect(screen.getByText(statement.text)).toBeVisible();
}
for (const boundary of caseStudy.detail.boundaries) {
const boundaryText = screen.getByText(boundary.text);
expect(boundaryText).toBeVisible();
expect(boundaryText.closest("a")).toBeNull();
}
const sourceLinks = screen.getAllByRole("link", { name: /^Fonte:/ });
expect(sourceLinks).toHaveLength(caseStudy.detail.sources.length);
for (const source of caseStudy.detail.sources) {
const link = screen.getByRole("link", { name: `Fonte: ${source.label}` });
expect(link).toHaveAttribute("href", source.url);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", expect.stringMatching(/noreferrer/));
expect(source.url).toMatch(/\/blob\/[0-9a-f]{40}\//);
const sha = source.url.match(/\/blob\/([0-9a-f]{40})\//)?.[1];
if (!sha) throw new Error(`Missing source SHA for ${source.id}`);
expect(within(link.closest("li") ?? link).getByText(sha)).toBeVisible();
}
expectCaseCtas();
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual(["BreadcrumbList"]);
expect(JSON.stringify(graph)).not.toMatch(
/Article|FAQPage|ProfessionalService|LocalBusiness/,
);
const breadcrumb = graph[0] 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: "Breadcrumb",
});
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("keeps the fallback concise and links its declared services", async () => {
const caseStudy = getCase("sc-plus");
expect(caseStudy.detail).toBeUndefined();
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
});
const { container } = render(ui);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary);
expect(screen.getByText(caseStudy.impact)).toBeVisible();
for (const technology of caseStudy.stack) {
expect(screen.getByText(technology)).toBeVisible();
}
expect(
screen.getByRole("link", { name: "Abrir demonstração" }),
).toHaveAttribute("href", caseStudy.demoUrl);
expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute(
"href",
caseStudy.githubUrl,
);
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();
const expectedServices = servicePages
.filter(({ relatedCaseIds }) => relatedCaseIds.includes(caseStudy.id))
.map(({ slug, navLabel }) => ({
name: `Ver serviço relacionado: ${navLabel}`,
href: `/servicos/${slug}`,
}))
.sort((a, b) => a.href.localeCompare(b.href));
const relatedLinks = screen.getAllByRole("link", {
name: /^Ver serviço relacionado:/,
});
expect(
relatedLinks
.map((link) => ({
name: link.textContent,
href: link.getAttribute("href"),
}))
.sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")),
).toEqual(expectedServices);
expectCaseCtas();
expect(readJsonLdEntities(container).map((node) => node["@type"])).toEqual([
"BreadcrumbList",
]);
});
it("keeps the hub cards, schema and distinct intents aligned", () => {
const { container } = render(<CasesPage />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(/seis projetos/i);
const caseLinks = screen.getAllByRole("link", { name: "Ler case" });
const caseHrefs = caseLinks.map((link) => link.getAttribute("href"));
expect(caseHrefs).toHaveLength(6);
expect(new Set(caseHrefs).size).toBe(6);
expect([...caseHrefs].sort()).toEqual(
publicCaseIds.map((id) => `/cases/${id}`).sort(),
);
expectCaseCtas();
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
});
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 = caseLinks.map((link, index) => {
const href = link.getAttribute("href");
const expected = publicCases.find(({ id }) => href === `/cases/${id}`);
if (!expected) throw new Error(`Unexpected visible case link ${href}`);
const card = link.closest("article");
if (!card) throw new Error(`Missing visible card for ${href}`);
expect(within(card).getByText(expected.title)).toBeVisible();
return {
"@type": "ListItem",
position: index + 1,
name: expected.title,
url: `${siteUrl}${href}`,
};
});
expect(itemList.itemListElement).toEqual(visibleItems);
});
+179 -61
View File
@@ -1,12 +1,26 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import {
caseStudies,
getRelatedServices,
intentCtas,
siteName,
siteUrl,
} from "@/lib/site-content";
interface CasePageProps {
params: Promise<{ slug: string }>;
}
const sourceShaPattern = /\/blob\/([0-9a-f]{40})\//;
function getSourceSha(url: string) {
return sourceShaPattern.exec(url)?.[1];
}
export function generateStaticParams() {
return caseStudies.map((caseStudy) => ({ slug: caseStudy.id }));
}
@@ -17,20 +31,26 @@ export async function generateMetadata({
const { slug } = await params;
const caseStudy = caseStudies.find((item) => item.id === slug);
if (!caseStudy) return { title: `Case não encontrado | ${siteName}` };
if (!caseStudy) return { title: "Case não encontrado" };
const url = `${siteUrl}/cases/${caseStudy.id}`;
return {
title: `${caseStudy.title}: ${caseStudy.subtitle} | ${siteName}`,
title: `${caseStudy.title} | Case de Engenharia`,
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
locale: "pt_BR",
title: caseStudy.title,
description: caseStudy.summary,
url,
siteName,
type: "article",
},
twitter: {
card: "summary_large_image",
title: caseStudy.title,
description: caseStudy.summary,
},
};
}
@@ -39,60 +59,79 @@ export default async function CasePage({ params }: CasePageProps) {
const caseStudy = caseStudies.find((item) => item.id === slug);
if (!caseStudy) notFound();
const url = `${siteUrl}/cases/${caseStudy.id}`;
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
const relatedServices = getRelatedServices(caseStudy.id);
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
headline: caseStudy.title,
description: caseStudy.summary,
mainEntityOfPage: url,
author: { "@id": `${siteUrl}/#person` },
about: caseStudy.stack,
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Cases",
item: `${siteUrl}/cases`,
},
{
"@type": "ListItem",
position: 3,
name: caseStudy.title,
item: url,
},
],
},
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Cases", `${siteUrl}/cases`],
[caseStudy.title, pageUrl],
] as const),
],
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<article>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell max-w-[960px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link className="ds-link ds-link-muted" href="/cases">
Cases
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href={`/cases/${caseStudy.id}`}
>
{caseStudy.title}
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Case de engenharia de software</p>
<h1 className="ds-section-title text-white">
{caseStudy.title}: {caseStudy.subtitle}
</h1>
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
{caseStudy.summary}
</p>
<p className="mt-sp-6 text-[15px] leading-8 text-white">
{caseStudy.impact}
{caseStudy.detail?.answer.text ?? caseStudy.summary}
</p>
{caseStudy.role ? (
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
<strong className="font-semibold text-white">
Minha participação:
</strong>{" "}
{caseStudy.role}
</p>
) : null}
{!caseStudy.detail ? (
<p className="mt-sp-6 text-[15px] leading-8 text-white">
{caseStudy.impact}
</p>
) : null}
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
{item}
</li>
))}
</ul>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
{caseStudy.demoUrl && (
{caseStudy.demoUrl ? (
<a
className="ds-link ds-link-accent"
href={caseStudy.demoUrl}
@@ -101,8 +140,8 @@ export default async function CasePage({ params }: CasePageProps) {
>
Abrir demonstração
</a>
)}
{caseStudy.githubUrl && (
) : null}
{caseStudy.githubUrl ? (
<a
className="ds-link ds-link-muted"
href={caseStudy.githubUrl}
@@ -111,31 +150,110 @@ export default async function CasePage({ params }: CasePageProps) {
>
Ver código
</a>
)}
) : null}
</div>
</div>
</section>
<section className="ds-section">
{caseStudy.detail ? (
<section className="ds-section">
<div className="ds-section-shell max-w-[960px] space-y-sp-10">
<section aria-labelledby="case-challenge">
<h2 id="case-challenge" className="ds-section-title">
Desafio verificável
</h2>
<p className="ds-section-description">
{caseStudy.detail.challenge.text}
</p>
</section>
<section aria-labelledby="case-implementation">
<h2 id="case-implementation" className="ds-section-title">
Decisões de implementação
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{caseStudy.detail.implementation.map((statement) => (
<li key={statement.text}>{statement.text}</li>
))}
</ul>
</section>
<section aria-labelledby="case-boundaries">
<h2 id="case-boundaries" className="ds-section-title">
Limites desta evidência
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{caseStudy.detail.boundaries.map((boundary) => (
<li key={boundary.text}>{boundary.text}</li>
))}
</ul>
</section>
<section aria-labelledby="case-sources">
<h2 id="case-sources" className="ds-section-title">
Fontes
</h2>
<ul className="mt-sp-5 space-y-sp-4">
{caseStudy.detail.sources.map((source) => {
const sha = getSourceSha(source.url);
return (
<li key={source.id}>
<a
className="ds-link ds-link-muted"
href={source.url}
target="_blank"
rel="noreferrer"
>
Fonte: {source.label}
</a>
{sha ? (
<span className="mt-sp-2 block text-[12px] text-[var(--meta)]">
Commit: <code className="break-all">{sha}</code>
</span>
) : null}
</li>
);
})}
</ul>
</section>
</div>
</section>
) : null}
{relatedServices.length > 0 ? (
<section className="ds-section">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-section-title">Serviços relacionados</h2>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
{relatedServices.map((service) => (
<Link
key={service.slug}
className="ds-link ds-link-muted"
href={`/servicos/${service.slug}`}
>
Ver serviço relacionado: {service.navLabel}
</Link>
))}
</div>
</div>
</section>
) : null}
<section className="ds-section ds-section-dark pb-sp-6">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-section-title">Tecnologias e contexto técnico</h2>
<p className="ds-section-description">
Este case demonstra como aplico engenharia de software a produtos
com requisitos de performance, integração e operação, para
empresas do Norte de Santa Catarina, Curitiba e atuação remota.
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
{item}
</li>
))}
</ul>
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
<Link className="ds-link ds-link-accent" href="/curriculo">
Ver currículo profissional
<h2 className="ds-card-title text-white">Próximos caminhos</h2>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
className="ds-link ds-link-accent"
href={intentCtas.project.href}
>
{intentCtas.project.label}
</Link>
<Link className="ds-link ds-link-muted" href="/servicos">
Ver serviços de engenharia
<Link
className="ds-link ds-link-muted"
href={intentCtas.consulting.href}
>
{intentCtas.consulting.label}
</Link>
</div>
</div>
+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 =
"Cases de software, web e sistemas conectados — Evandro Fonseca Junior";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export default async function Image() {
return new ImageResponse(
<OgCard
eyebrow="Trabalho verificável"
title="Cases de software, web e sistemas conectados"
description="Decisões, implementação, limites e fontes."
/>,
OG_SIZE,
);
}
+52 -41
View File
@@ -2,20 +2,26 @@ import type { Metadata } from "next";
import Link from "next/link";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import { caseStudies, intentCtas, siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases de Engenharia de Software, Next.js e IoT";
const pageDescription =
"Seis projetos mostram aplicações de engenharia de software em web, desktop, IoT, integrações e experiências orientadas a dados.";
export const metadata: Metadata = {
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
description:
"Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos. Portfolio tecnico com foco em clareza, performance e execucao.",
title: pageTitle,
description: pageDescription,
alternates: {
canonical: `${siteUrl}/cases`,
canonical: pageUrl,
},
openGraph: {
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
description:
"Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos.",
url: `${siteUrl}/cases`,
locale: "pt_BR",
title: pageTitle,
description: pageDescription,
url: pageUrl,
siteName,
type: "website",
},
@@ -27,11 +33,10 @@ export default function CasesPage() {
"@graph": [
{
"@type": "CollectionPage",
"@id": `${siteUrl}/cases#page`,
name: "Cases de engenharia de software",
url: `${siteUrl}/cases`,
description:
"Colecao de cases em Next.js, IoT, integracoes e software para operacoes criticas.",
"@id": `${pageUrl}#page`,
name: pageTitle,
url: pageUrl,
description: pageDescription,
},
{
"@type": "ItemList",
@@ -39,43 +44,49 @@ export default function CasesPage() {
"@type": "ListItem",
position: index + 1,
name: caseStudy.title,
url: `${siteUrl}/cases/${caseStudy.id}`,
url: `${pageUrl}/${caseStudy.id}`,
})),
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Cases",
item: `${siteUrl}/cases`,
},
],
},
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Cases", pageUrl],
] as const),
],
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
<HeroBackground />
<div className="ds-section-shell relative z-10">
<div className="max-w-[860px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/cases"
>
Cases
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Selected work</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
Cases em software, Next.js, integracoes e produtos conectados.
Cases em software, Next.js, integrações e produtos conectados.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
Esta pagina organiza projetos com leitura mais indexavel e
comercial, conectando stack, contexto tecnico e tipo de problema
resolvido.
{pageDescription}
</p>
</div>
</div>
@@ -83,9 +94,9 @@ export default function CasesPage() {
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
<div className="ds-grid-2 items-start">
{caseStudies.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card">
<article key={caseStudy.id} className="ds-card !h-auto">
<div className="ds-kicker">{caseStudy.title}</div>
<h2 className="ds-card-title">{caseStudy.subtitle}</h2>
<p className="ds-copy">{caseStudy.summary}</p>
@@ -126,19 +137,19 @@ export default function CasesPage() {
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
<MagneticButton
as="a"
href="/servicos"
href={intentCtas.project.href}
className="ds-link ds-link-accent"
strength={0.25}
>
Ver servicos
{intentCtas.project.label}
</MagneticButton>
<MagneticButton
as="a"
href="/#contact"
href={intentCtas.consulting.href}
className="ds-link ds-link-muted"
strength={0.2}
>
Ir para contato
{intentCtas.consulting.label}
</MagneticButton>
</div>
</div>
+34 -31
View File
@@ -1,48 +1,48 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import profilePhoto from "@/public/edev.jpg";
import { DrawLine } from "./animations/DrawLine";
import PortraitCyberScene from "./cinematic/PortraitCyberScene";
import { LazyRevealImage } from "./animations/LazyRevealImage";
import { Section } from "./Section";
const STORY_DATA = [
{
label: "Hardware + software",
title: "Do sinal fisico ao produto digital",
title: "Do sinal físico ao produto digital",
content:
"Construo a ponte entre dispositivos, protocolos de baixo nivel, backend e interface para que o sistema inteiro pareca uma unica experiencia coerente.",
"Construo a ponte entre dispositivos, protocolos de baixo nível, backend e interface para que o sistema inteiro pareça uma única experiência coerente.",
},
{
label: "Execution",
title: "Entrega clara mesmo sob restricao",
label: "Decisões técnicas",
title: "Entrega clara mesmo sob restrição",
content:
"Trabalho bem em contextos com legado, integracoes complexas e pouca margem para erro, priorizando estabilidade, leitura tecnica e evolucao sustentavel.",
"Avalio o que já existe, identifico dependências e combino prioridades antes de alterar sistemas em uso. Nem todo problema exige começar novamente.",
},
{
label: "Production mindset",
title: "Do codigo ao sistema no ar",
label: "Operação",
title: "Do código ao sistema no ar",
content:
"Penso alem da feature: deploy, observabilidade, manutencao e riscos de operacao entram na decisao tecnica desde o primeiro corte.",
"Publicação, diagnóstico de falhas, documentação e manutenção entram no planejamento, junto com as funcionalidades.",
},
];
const PROFILE_POINTS = [
{
title: "Arquitetura pragmatica",
title: "Arquitetura pragmática",
description:
"Escolhas guiadas por contexto, operabilidade e impacto real no produto.",
},
{
title: "Integracao profunda",
title: "Integração profunda",
description:
"Capacidade de atravessar camada fisica, servicos e interface sem perder consistencia.",
"Capacidade de atravessar camada física, serviços e interface sem perder consistência.",
},
{
title: "Ownership ponta a ponta",
title: "Responsabilidade técnica",
description:
"Responsabilidade que cobre discovery, implementacao, qualidade e operacao.",
"Contato direto com quem define a arquitetura e desenvolve o sistema.",
},
];
@@ -86,14 +86,14 @@ export default function About() {
return (
<Section
id="about"
eyebrow="Operating context"
title="Engenharia de software, arquitetura e integracoes complexas"
description="Atuo na intersecao entre arquitetura de software, integracoes de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
eyebrow="Quem conduz o projeto"
title="Evandro Fonseca Junior. Engenharia com contato direto."
description="Sou engenheiro da computação e desenvolvedor de software. Você conversa comigo sobre o negócio e acompanha as decisões técnicas ao longo da entrega."
disableChildrenAnimation
aside={
<div className="mt-sp-4 ds-metric">
<div className="mt-sp-4 ds-metric" data-reel="left">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" />
Garuva, SC | Remote worldwide
Base em Garuva, SC | Atendimento remoto
</div>
}
>
@@ -120,7 +120,9 @@ export default function About() {
<div className="ds-kicker">{step.label}</div>
<DrawLine delay={0.03} />
<h3 className="ds-card-title">{step.title}</h3>
<p className="ds-copy">{step.content}</p>
<p className="ds-copy" data-reel="right">
{step.content}
</p>
</motion.article>
))}
</motion.div>
@@ -132,21 +134,22 @@ export default function About() {
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<PortraitCyberScene
<LazyRevealImage
src={profilePhoto}
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC"
className="mb-sp-6"
className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
imageClassName="object-contain"
/>
<div className="ds-kicker">Technical profile</div>
<div className="ds-kicker">Da arquitetura à implementação</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
<p className="ds-copy">
Sou engenheiro da computação e desenvolvedor de software full-stack,
atuando ha 5 anos com foco em integrar camadas que normalmente ficam
separadas: hardware, servicos, operacao e interface. Meu trabalho e
dar forma clara ao que costuma nascer fragmentado.
<h3 className="ds-card-title">Uma visão do sistema inteiro</h3>
<p className="ds-copy" data-reel="top">
Meu trabalho conecta interface, serviços, dados e, quando
necessário, equipamentos. Entender essas partes em conjunto ajuda a
definir o que construir, o que integrar e o que pode ser
aproveitado.
</p>
<div className="mt-sp-6 space-y-sp-4">
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
{PROFILE_POINTS.map((point) => (
<div
key={point.title}
@@ -1,320 +0,0 @@
import { render } from "@testing-library/react";
import CompanyLogos from "./CompanyLogos";
import fc from "fast-check";
// Mock framer-motion with configurable useReducedMotion
let mockReducedMotion = false;
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => mockReducedMotion,
motion: {
div: ({ children, animate, transition, ...props }: any) => {
return <div {...props}>{children}</div>;
},
},
};
});
describe("Property 10: Alt Text for Images", () => {
afterEach(() => {
mockReducedMotion = false;
});
it("should have non-empty alt attributes for all company logo images", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image in the CompanyLogos component,
* it should have a non-empty alt attribute describing the company.
* This ensures accessibility for screen readers and assistive technologies.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll("img");
// Verify we found images
if (logoImages.length === 0) {
return false;
}
// Check that all images have non-empty alt text
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
},
),
{ numRuns: 100 },
);
});
it("should have descriptive alt text that includes company name", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image, the alt text should be descriptive
* and include identifying information about the company. This ensures
* meaningful accessibility information is provided.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll("img");
// Verify we found images
if (logoImages.length === 0) {
return false;
}
// Check that all alt text contains "logo" or is descriptive
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
if (!alt) return false;
const altLower = alt.toLowerCase();
// Alt text should contain "logo" or be at least 5 characters (descriptive)
return altLower.includes("logo") || alt.trim().length >= 5;
});
},
),
{ numRuns: 100 },
);
});
it("should maintain alt text consistency across multiple renders", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any number of component renders, the alt text for each
* company logo should remain consistent. This ensures stable accessibility
* information across re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 2, max: 5 }), // Test 2-5 renders
fc.boolean(), // Random reduced motion preference
(numRenders, shouldReduce) => {
mockReducedMotion = shouldReduce;
let firstRenderAltTexts: string[] = [];
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
const currentAltTexts = Array.from(logoImages).map(
(img) => img.getAttribute("alt") || "",
);
if (i === 0) {
firstRenderAltTexts = currentAltTexts;
} else {
// Compare with first render
if (currentAltTexts.length !== firstRenderAltTexts.length) {
unmount();
return false;
}
// Check that alt texts match (accounting for duplicates in animated mode)
const expectedCount = shouldReduce ? 5 : 10;
if (currentAltTexts.length !== expectedCount) {
unmount();
return false;
}
}
unmount();
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should have unique alt text for each distinct company", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any set of company logos, each distinct company should have
* its own unique alt text. This ensures each company is properly identified
* for accessibility purposes.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true; // Use static mode to get unique logos
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
const altTexts = Array.from(logoImages).map(
(img) => img.getAttribute("alt") || "",
);
// In static mode, we should have 5 unique alt texts
const uniqueAltTexts = new Set(altTexts);
// Should have 5 unique alt texts (one per company)
return uniqueAltTexts.size === 5 && altTexts.length === 5;
}),
{ numRuns: 100 },
);
});
it("should preserve alt text in both animated and static modes", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any reduced motion preference, all company logo images
* should maintain their alt text attributes. The animation mode should
* not affect accessibility attributes.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
// Verify all images have alt attributes
const allHaveAlt = Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
// Verify correct number of images based on mode
const expectedCount = shouldReduce ? 5 : 10;
const hasCorrectCount = logoImages.length === expectedCount;
return allHaveAlt && hasCorrectCount;
},
),
{ numRuns: 100 },
);
});
it("should not have empty or whitespace-only alt text", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image, the alt text should not be empty
* or consist only of whitespace. This ensures meaningful accessibility
* information is always provided.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
// Check that no image has empty or whitespace-only alt text
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
// Alt must exist and not be empty after trimming
if (!alt || alt.trim().length === 0) {
return false;
}
// Alt should not be just whitespace
return alt !== alt.trim() ? false : true;
});
},
),
{ numRuns: 100 },
);
});
it("should have alt text that matches the expected company names", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image, the alt text should correspond to
* one of the expected company names (Apolo Energia, Julia Beauty Lashes,
* Innovo, TeraByteShop, Pichau). This ensures correct identification.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true; // Use static mode for clarity
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
const altTexts = Array.from(logoImages).map(
(img) => img.getAttribute("alt") || "",
);
// Expected company names in alt text
const expectedCompanies = [
"Apolo Energia",
"Julia Beauty Lashes",
"Innovo",
"TeraByteShop",
"Pichau",
];
// Check that each alt text contains one of the expected company names
return altTexts.every((alt) =>
expectedCompanies.some((company) => alt.includes(company)),
);
}),
{ numRuns: 100 },
);
});
it("should maintain alt text when images fail to load", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image that fails to load, the alt text
* should still be present in the DOM. This ensures accessibility is
* maintained even when images are unavailable.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
// Verify all images have alt text before any error handling
const allHaveAltBeforeError = Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
// The alt attribute should be present regardless of load state
return allHaveAltBeforeError;
},
),
{ numRuns: 100 },
);
});
});
@@ -1,344 +0,0 @@
import { render } from "@testing-library/react";
import CompanyLogos from "./CompanyLogos";
import fc from "fast-check";
// Mock framer-motion with configurable useReducedMotion
let mockReducedMotion = false;
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => mockReducedMotion,
motion: {
div: ({ children, animate, transition, ...props }: any) => {
// Capture animation props for testing
const animationProps = {
"data-animate": JSON.stringify(animate),
"data-transition": JSON.stringify(transition),
};
return (
<div {...props} {...animationProps}>
{children}
</div>
);
},
},
};
});
describe("Property 4: Reduced Motion Respect", () => {
afterEach(() => {
mockReducedMotion = false;
});
it("should render static grid layout when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any reduced motion preference, when useReducedMotion() returns true,
* the CompanyLogos component should render a static grid layout without motion
* animations. This ensures accessibility compliance with prefers-reduced-motion.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Check for animated motion.div with animation props
const animatedElements = container.querySelectorAll("[data-animate]");
// Check for static grid layout
const staticGrid = container.querySelector(".grid");
if (shouldReduce) {
// When reduced motion is enabled:
// - Should NOT have animated elements
// - Should have static grid layout
return animatedElements.length === 0 && staticGrid !== null;
} else {
// When reduced motion is disabled:
// - Should have animated elements
// - Should NOT have static grid layout
return animatedElements.length > 0 && staticGrid === null;
}
},
),
{ numRuns: 100 },
);
});
it("should exclude horizontal scroll animation when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any component render with reduced motion enabled, the horizontal
* scroll animation (x: [0, '-50%']) should be completely absent. This verifies
* that motion animations are properly disabled.
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed
() => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Check for animation props
const animatedElements = container.querySelectorAll("[data-animate]");
let hasScrollAnimation = false;
animatedElements.forEach((element) => {
const animateValue = element.getAttribute("data-animate");
if (animateValue && animateValue !== "undefined") {
try {
const props = JSON.parse(animateValue);
// Check for x animation (horizontal scroll)
if (props.x !== undefined) {
hasScrollAnimation = true;
}
} catch (e) {
// Skip invalid JSON
}
}
});
// Should not have scroll animation when reduced motion is enabled
return !hasScrollAnimation;
},
),
{ numRuns: 100 },
);
});
it("should display all company logos in both animated and static modes", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any reduced motion preference, all company logos should be
* displayed. The reduced motion preference should only affect the animation
* style, not the content visibility.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll('img[alt*="logo"]');
// Should have at least 5 logos (the original set)
// In animated mode, we have duplicates, so it will be 10
// In static mode, we have exactly 5
if (shouldReduce) {
return logoImages.length === 5;
} else {
return logoImages.length === 10; // Duplicated for infinite scroll
}
},
),
{ numRuns: 100 },
);
});
it("should maintain accessibility attributes regardless of reduced motion preference", () => {
/**
* **Validates: Requirements 3.6, 6.3, 6.2**
*
* Property: For any reduced motion preference, all company logo images should
* maintain proper alt text attributes. Accessibility should not be compromised
* by the animation mode.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll("img");
// Verify we found images
if (logoImages.length === 0) {
return false;
}
// Check that all images have non-empty alt text
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
},
),
{ numRuns: 100 },
);
});
it("should consistently apply reduced motion across multiple renders", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any number of component renders with reduced motion enabled,
* the static layout should be consistently rendered without animations.
* This verifies that the reduced motion behavior is stable across re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
(numRenders) => {
mockReducedMotion = true;
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<CompanyLogos />);
// Check for animated elements
const animatedElements =
container.querySelectorAll("[data-animate]");
// Check for static grid
const staticGrid = container.querySelector(".grid");
// Should have no animations and should have static grid
if (animatedElements.length > 0 || staticGrid === null) {
allRendersValid = false;
}
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
it("should use grid layout classes when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: When reduced motion is enabled, the component should use CSS grid
* layout classes (grid, grid-cols-*) to display logos in a static arrangement.
* This ensures proper visual presentation without animation.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Check for grid layout classes
const gridContainer = container.querySelector(".grid");
if (!gridContainer) {
return false;
}
// Verify grid has responsive column classes
const hasGridCols = gridContainer.className.includes("grid-cols-");
return hasGridCols;
}),
{ numRuns: 100 },
);
});
it("should not have infinite repeat transition when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: When reduced motion is enabled, the component should not have
* transition configurations with infinite repeat. This ensures no continuous
* motion occurs when users prefer reduced motion.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Check for transition props
const elementsWithTransition =
container.querySelectorAll("[data-transition]");
let hasInfiniteRepeat = false;
elementsWithTransition.forEach((element) => {
const transitionValue = element.getAttribute("data-transition");
if (transitionValue && transitionValue !== "undefined") {
try {
const props = JSON.parse(transitionValue);
// Check for infinite repeat
if (props.repeat === Infinity || props.repeat === "Infinity") {
hasInfiniteRepeat = true;
}
} catch (e) {
// Skip invalid JSON
}
}
});
// Should not have infinite repeat when reduced motion is enabled
return !hasInfiniteRepeat;
}),
{ numRuns: 100 },
);
});
it("should switch between animated and static modes based on reduced motion preference", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: The component should render different layouts based on the reduced
* motion preference. When the preference changes, the component should adapt
* accordingly. This verifies the conditional rendering logic.
*/
fc.assert(
fc.property(
fc.array(fc.boolean(), { minLength: 2, maxLength: 5 }), // Sequence of preference changes
(preferenceSequence) => {
let allTransitionsValid = true;
preferenceSequence.forEach((shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container, unmount } = render(<CompanyLogos />);
const hasAnimations =
container.querySelectorAll("[data-animate]").length > 0;
const hasStaticGrid = container.querySelector(".grid") !== null;
// Verify correct mode is rendered
if (shouldReduce) {
if (hasAnimations || !hasStaticGrid) {
allTransitionsValid = false;
}
} else {
if (!hasAnimations || hasStaticGrid) {
allTransitionsValid = false;
}
}
unmount();
});
return allTransitionsValid;
},
),
{ numRuns: 100 },
);
});
});
-503
View File
@@ -1,503 +0,0 @@
import { render, screen } from "@testing-library/react";
import CompanyLogos from "./CompanyLogos";
// Mock framer-motion with configurable useReducedMotion
let mockReducedMotion = false;
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => mockReducedMotion,
motion: {
div: ({ children, animate, transition, ...props }: any) => {
// Capture animation props for testing
const animationProps = {
"data-animate": JSON.stringify(animate),
"data-transition": JSON.stringify(transition),
};
return (
<div {...props} {...animationProps}>
{children}
</div>
);
},
},
};
});
describe("CompanyLogos Component - Logo Rendering", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should render all five company logos", () => {
const { container } = render(<CompanyLogos />);
// In animated mode, logos are duplicated (10 total)
const logoImages = container.querySelectorAll('img[alt*="logo"]');
expect(logoImages.length).toBe(10); // 5 logos x 2 (duplicated for infinite scroll)
});
it("should render Apolo Energia logo", () => {
render(<CompanyLogos />);
const apoloLogos = screen.getAllByAltText("Apolo Energia logo");
expect(apoloLogos.length).toBe(2); // Duplicated for infinite scroll
expect(apoloLogos[0]).toHaveAttribute("src", "/logos/apolo.svg");
});
it("should render Julia Beauty Lashes logo", () => {
render(<CompanyLogos />);
const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo");
expect(juliaLogos.length).toBe(2);
expect(juliaLogos[0]).toHaveAttribute("src", "/logos/julia.svg");
});
it("should render Innovo logo", () => {
render(<CompanyLogos />);
const innovoLogos = screen.getAllByAltText("Innovo logo");
expect(innovoLogos.length).toBe(2);
expect(innovoLogos[0]).toHaveAttribute("src", "/logos/innovo.svg");
});
it("should render TeraByteShop logo", () => {
render(<CompanyLogos />);
const terabyteLogos = screen.getAllByAltText("TeraByteShop logo");
expect(terabyteLogos.length).toBe(2);
expect(terabyteLogos[0]).toHaveAttribute("src", "/logos/terabyte.svg");
});
it("should render Pichau logo", () => {
render(<CompanyLogos />);
const pichauLogos = screen.getAllByAltText("Pichau logo");
expect(pichauLogos.length).toBe(2);
expect(pichauLogos[0]).toHaveAttribute("src", "/logos/pichau.svg");
});
it("should have alt text for all logo images", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const alt = img.getAttribute("alt");
expect(alt).toBeTruthy();
expect(alt?.length).toBeGreaterThan(0);
});
});
it("should duplicate logo array for seamless infinite scroll", () => {
const { container } = render(<CompanyLogos />);
// Each company should appear exactly twice
const apoloLogos = screen.getAllByAltText("Apolo Energia logo");
const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo");
const innovoLogos = screen.getAllByAltText("Innovo logo");
const terabyteLogos = screen.getAllByAltText("TeraByteShop logo");
const pichauLogos = screen.getAllByAltText("Pichau logo");
expect(apoloLogos.length).toBe(2);
expect(juliaLogos.length).toBe(2);
expect(innovoLogos.length).toBe(2);
expect(terabyteLogos.length).toBe(2);
expect(pichauLogos.length).toBe(2);
});
});
describe("CompanyLogos Component - Animation Configuration", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have horizontal scroll animation configuration", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-animate]");
expect(animatedElement).toBeInTheDocument();
const animateValue = animatedElement?.getAttribute("data-animate");
expect(animateValue).toBeTruthy();
const animateProps = JSON.parse(animateValue!);
expect(animateProps.x).toEqual([0, "-50%"]);
});
it("should have 30-second animation duration", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-transition]");
expect(animatedElement).toBeInTheDocument();
const transitionValue = animatedElement?.getAttribute("data-transition");
expect(transitionValue).toBeTruthy();
const transitionProps = JSON.parse(transitionValue!);
expect(transitionProps.duration).toBe(30);
});
it("should have infinite repeat configuration", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-transition]");
const transitionValue = animatedElement?.getAttribute("data-transition");
const transitionProps = JSON.parse(transitionValue!);
// JSON.stringify converts Infinity to null, so we check for null
expect(transitionProps.repeat).toBe(null);
});
it("should use linear easing for smooth animation", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-transition]");
const transitionValue = animatedElement?.getAttribute("data-transition");
const transitionProps = JSON.parse(transitionValue!);
expect(transitionProps.ease).toBe("linear");
});
it("should have flex layout with gap-sp-12 for logo spacing", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-animate]");
const classes = animatedElement?.className || "";
expect(classes).toContain("flex");
expect(classes).toContain("gap-sp-12");
});
it("should have items-center alignment", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-animate]");
const classes = animatedElement?.className || "";
expect(classes).toContain("items-center");
});
});
describe("CompanyLogos Component - Reduced Motion Fallback", () => {
beforeEach(() => {
mockReducedMotion = true;
});
afterEach(() => {
mockReducedMotion = false;
});
it("should render static grid when reduced motion is enabled", () => {
const { container } = render(<CompanyLogos />);
const staticGrid = container.querySelector(".grid");
expect(staticGrid).toBeInTheDocument();
});
it("should not have animated elements when reduced motion is enabled", () => {
const { container } = render(<CompanyLogos />);
const animatedElements = container.querySelectorAll("[data-animate]");
expect(animatedElements.length).toBe(0);
});
it("should render exactly 5 logos in static mode (no duplication)", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll('img[alt*="logo"]');
expect(logoImages.length).toBe(5);
});
it("should have responsive grid columns in static mode", () => {
const { container } = render(<CompanyLogos />);
const staticGrid = container.querySelector(".grid");
const classes = staticGrid?.className || "";
expect(classes).toContain("grid-cols-2");
expect(classes).toContain("sm:grid-cols-3");
expect(classes).toContain("md:grid-cols-5");
});
it("should have gap-sp-6 spacing in static grid", () => {
const { container } = render(<CompanyLogos />);
const staticGrid = container.querySelector(".grid");
const classes = staticGrid?.className || "";
expect(classes).toContain("gap-sp-6");
});
it("should maintain all logo alt text in static mode", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const alt = img.getAttribute("alt");
expect(alt).toBeTruthy();
expect(alt?.length).toBeGreaterThan(0);
});
});
});
describe("CompanyLogos Component - Missing Logo Image Handling", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have onError handler for logo images", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
// Check that the image element has an onError handler
// In React, this is attached as a prop, so we verify the element exists
expect(img).toBeInTheDocument();
});
});
it("should apply grayscale filter to logo images", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("grayscale");
});
});
it("should have hover effect to remove grayscale", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("hover:grayscale-0");
});
});
it("should have opacity transition on hover", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("opacity-60");
expect(classes).toContain("hover:opacity-100");
});
});
it("should have transition-all for smooth hover effects", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("transition-all");
});
});
});
describe("CompanyLogos Component - Responsive Layout", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have section element as container", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
expect(section).toBeInTheDocument();
});
it("should have full width class", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("w-full");
});
it("should have overflow-hidden for animation containment", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("overflow-hidden");
});
it("should have border-b for visual separation", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("border-b");
});
it("should have background color class", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("bg-[var(--paper)]");
});
it("should have py-sp-8 vertical padding", () => {
const { container } = render(<CompanyLogos />);
// In animated mode, padding is on inner div
const innerDiv = container.querySelector(".py-sp-8");
expect(innerDiv).toBeInTheDocument();
});
it("should have responsive padding in static mode", () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
const innerContainer = container.querySelector(".container");
const classes = innerContainer?.className || "";
expect(classes).toContain("px-sp-6");
expect(classes).toContain("py-sp-8");
expect(classes).toContain("md:px-sp-10");
expect(classes).toContain("lg:px-sp-16");
});
it("should have mx-auto for centering in static mode", () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
const innerContainer = container.querySelector(".container");
const classes = innerContainer?.className || "";
expect(classes).toContain("mx-auto");
});
});
describe("CompanyLogos Component - Logo Sizing and Styling", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have h-12 height for all logos", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("h-12");
});
});
it("should have w-auto width for aspect ratio preservation", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("w-auto");
});
});
it("should have object-contain for proper image scaling", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("object-contain");
});
});
it("should have flex-shrink-0 on logo containers in animated mode", () => {
const { container } = render(<CompanyLogos />);
const logoContainers = container.querySelectorAll("[data-animate] > div");
logoContainers.forEach((div) => {
const classes = div.className;
expect(classes).toContain("flex-shrink-0");
});
});
it("should center logos within their containers", () => {
const { container } = render(<CompanyLogos />);
const logoContainers = container.querySelectorAll("[data-animate] > div");
logoContainers.forEach((div) => {
const classes = div.className;
expect(classes).toContain("flex");
expect(classes).toContain("items-center");
expect(classes).toContain("justify-center");
});
});
});
describe("CompanyLogos Component - Accessibility", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should use semantic section element", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
expect(section).toBeInTheDocument();
});
it("should have descriptive alt text for each logo", () => {
render(<CompanyLogos />);
expect(screen.getAllByAltText("Apolo Energia logo")).toHaveLength(2);
expect(screen.getAllByAltText("Julia Beauty Lashes logo")).toHaveLength(2);
expect(screen.getAllByAltText("Innovo logo")).toHaveLength(2);
expect(screen.getAllByAltText("TeraByteShop logo")).toHaveLength(2);
expect(screen.getAllByAltText("Pichau logo")).toHaveLength(2);
});
it("should respect reduced motion preference", () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Should render static grid instead of animation
const staticGrid = container.querySelector(".grid");
const animatedElements = container.querySelectorAll("[data-animate]");
expect(staticGrid).toBeInTheDocument();
expect(animatedElements.length).toBe(0);
});
it("should maintain content visibility in both modes", () => {
// Test animated mode
mockReducedMotion = false;
const { container: animatedContainer } = render(<CompanyLogos />);
const animatedLogos =
animatedContainer.querySelectorAll('img[alt*="logo"]');
// Test static mode
mockReducedMotion = true;
const { container: staticContainer } = render(<CompanyLogos />);
const staticLogos = staticContainer.querySelectorAll('img[alt*="logo"]');
// Both modes should display logos (different counts due to duplication)
expect(animatedLogos.length).toBeGreaterThan(0);
expect(staticLogos.length).toBeGreaterThan(0);
});
});
-114
View File
@@ -1,114 +0,0 @@
"use client";
/**
* CompanyLogos Component
*
* Displays an infinite-scrolling banner of company logos after the header.
* Features smooth horizontal animation with reduced motion support.
*
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8
*/
import { motion } from "framer-motion";
import Image from "next/image";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
// TypeScript interface for company logos
export interface CompanyLogo {
name: string; // Company name
imagePath: string; // Path to logo image (SVG preferred)
alt: string; // Alt text for accessibility
}
// Company logos data
const companies: CompanyLogo[] = [
{
name: "Apolo Energia",
imagePath: "/logos/apolo.svg",
alt: "Apolo Energia logo",
},
{
name: "Julia Beauty Lashes",
imagePath: "/logos/julia.svg",
alt: "Julia Beauty Lashes logo",
},
{
name: "Innovo",
imagePath: "/logos/innovo.svg",
alt: "Innovo logo",
},
{
name: "TeraByteShop",
imagePath: "/logos/terabyte.svg",
alt: "TeraByteShop logo",
},
{
name: "Pichau",
imagePath: "/logos/pichau.svg",
alt: "Pichau logo",
},
];
export default function CompanyLogos() {
const shouldReduceMotion = useReducedMotion();
// Duplicate logo array for seamless infinite scroll
const duplicatedLogos = [...companies, ...companies];
// Static grid for reduced motion preference
if (shouldReduceMotion) {
return (
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
<div className="container mx-auto px-sp-6 py-sp-8 md:px-sp-10 lg:px-sp-16">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-sp-6 items-center justify-items-center">
{companies.map((logo) => (
<div key={logo.name} className="flex items-center justify-center">
<Image
src={logo.imagePath}
alt={logo.alt}
width={150}
height={48}
className="h-12 w-auto object-contain grayscale opacity-60"
/>
</div>
))}
</div>
</div>
</section>
);
}
// Animated infinite scroll
return (
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
<div className="py-sp-8">
<motion.div
className="flex gap-sp-12 items-center"
animate={{
x: [0, "-50%"],
}}
transition={{
duration: 30,
repeat: Infinity,
ease: "linear",
}}
>
{duplicatedLogos.map((logo, index) => (
<div
key={`${logo.name}-${index}`}
className="flex-shrink-0 flex items-center justify-center"
>
<Image
src={logo.imagePath}
alt={logo.alt}
width={150}
height={48}
className="h-12 w-auto object-contain grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
/>
</div>
))}
</motion.div>
</div>
</section>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Contact from "./Contact";
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();
jest.spyOn(window, "open").mockImplementation(() => null);
});
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 = userEvent.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.type(screen.getByRole("textbox", { name: /contexto/i }), 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, features] = 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");
expect(features).toBe("noopener,noreferrer");
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 = userEvent.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 = userEvent.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");
});
+225 -194
View File
@@ -1,221 +1,252 @@
"use client";
import { motion, type Variants } from "framer-motion";
import type React from "react";
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
import type { FormEvent } from "react";
import { useEffect, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { trackWhatsAppOpen } from "@/lib/analytics";
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { Section } from "./Section";
import WhatsAppLink from "./WhatsAppLink";
// WhatsApp brand silhouette from Simple Icons (simple-icons/simple-icons).
function WhatsAppMark({ className }: { className: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden="true"
focusable="false"
>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
</svg>
);
}
export default function Contact() {
const shouldReduceMotion = useReducedMotion();
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
useEffect(() => {
setWhatsAppDisplay(getWhatsAppDisplayNumber());
}, []);
const fieldClassName =
"mt-sp-2 w-full border border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.06)] px-sp-4 py-[14px] text-[13px] leading-6 text-white outline-none transition-[border-color,box-shadow] focus-visible:border-[var(--slash)] focus-visible:ring-2 focus-visible:ring-[rgba(204,255,0,0.34)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
"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)]";
const labelClassName = "block font-display text-sm font-medium text-white/85";
const [formData, setFormData] = useState({
name: "",
email: "",
subject: "full-stack",
subject: "Sistemas web sob medida",
message: "",
});
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const messageTemplate = `*NOVA SOLICITACAO DE PROJETO*\n\n*Origem:* Portfolio Profissional\n*Prioridade:* Alta\n\n---\n*CLIENTE:* ${formData.name}\n*EMAIL:* ${formData.email}\n*AREA:* ${formData.subject.toUpperCase()}\n\n*DESCRICAO:*\n"${formData.message}"\n---\n_Mensagem gerada via Evandro.dev | Garuva, SC_`;
const whatsappUrl = buildWhatsAppUrl(messageTemplate);
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
};
const gridVariants: Variants = {
hidden: {},
visible: {
transition: {
delayChildren: 0.05,
staggerChildren: 0.08,
},
},
};
const cardVariants: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 18, clipPath: "inset(100% 0 0 0)" },
visible: {
opacity: 1,
y: 0,
clipPath: "inset(0% 0 0 0)",
transition: shouldReduceMotion
? { duration: 0.25 }
: { duration: 0.75, ease: [0.16, 1, 0.3, 1] },
},
if (!e.currentTarget.reportValidity()) return;
const messageTemplate = `Olá, Evandro! Quero conversar sobre um projeto.\n\nNome: ${formData.name}\nE-mail: ${formData.email}\nTipo de projeto: ${formData.subject}\n\nContexto, objetivo e prazo:\n${formData.message}\n\nContato iniciado pelo site evandro.dev.br.`;
window.open(
buildWhatsAppUrl(messageTemplate),
"_blank",
"noopener,noreferrer",
);
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
};
return (
<Section
<section
id="contact"
eyebrow="Contact"
title="Consultoria tecnica para software, arquitetura e integracoes criticas"
description="Fale comigo se voce precisa de engenharia de software, consultoria tecnica, arquitetura de software ou apoio em projetos com Next.js, Tauri, Rust e IoT."
dark
disableChildrenAnimation
aria-labelledby="contact-title"
className="ds-section ds-section-dark"
>
<motion.div
className="ds-grid-2"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={gridVariants}
>
<motion.div
className="ds-card"
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<div className="ds-kicker">Availability</div>
<DrawLine delay={0.04} />
<h3 className="ds-card-title">Resposta inicial em ate 24h</h3>
<p className="ds-copy">
Ideal para discovery tecnico, revisao de arquitetura, rescue de
projeto, performance ou construcao de produto com ownership real.
</p>
<div className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]">
<div>WhatsApp: {whatsAppDisplay}</div>
<div>E-mail: evandrofonsecajunior@gmail.com</div>
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
</div>
<div className="mt-sp-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]">
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
conversa tecnica objetiva.
</div>
</motion.div>
<motion.form
onSubmit={handleSubmit}
className="ds-card space-y-sp-5"
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
}
}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<div>
<label
htmlFor="contact-name"
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
>
Nome completo
</label>
<input
id="contact-name"
name="name"
required
type="text"
autoComplete="name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className={fieldClassName}
/>
</div>
<div>
<label
htmlFor="contact-email"
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
>
E-mail
</label>
<input
id="contact-email"
name="email"
required
type="email"
autoComplete="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className={fieldClassName}
/>
</div>
<div>
<label
htmlFor="contact-subject"
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
>
Tipo de projeto
</label>
<select
id="contact-subject"
name="subject"
value={formData.subject}
onChange={(e) =>
setFormData({ ...formData, subject: e.target.value })
}
className={fieldClassName}
style={{ color: "#fff", backgroundColor: "#1a1a1a" }}
>
<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 Tecnica Senior</option>
</select>
</div>
<div>
<label
htmlFor="contact-message"
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
>
Contexto do projeto
</label>
<textarea
id="contact-message"
name="message"
required
rows={6}
autoComplete="off"
value={formData.message}
onChange={(e) =>
setFormData({ ...formData, message: e.target.value })
}
className={`${fieldClassName} resize-none`}
/>
</div>
<MagneticButton
as="button"
type="submit"
className="ds-link ds-link-accent min-h-12 border-0"
strength={0.2}
<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"
>
Enviar briefing pelo WhatsApp
</MagneticButton>
</motion.form>
</motion.div>
</Section>
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">
Você fala direto com quem desenvolve.
</h3>
<p className="mt-sp-2 max-w-[44ch] font-display text-sm leading-7 text-white/65">
Sou Evandro. A primeira conversa é para entender sua necessidade;
escopo, prazo e proposta vêm depois.
</p>
</div>
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
<WhatsAppLink
service="Contato"
placement="contact_direct"
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
<WhatsAppMark className="h-6 w-6" />
</span>
<span className="min-w-0 flex-1 font-display">
<span className="block text-sm font-semibold text-white group-hover:text-[#25d366]">
Prefere conversar direto?
</span>
<span className="mt-1 block text-sm text-white/65">
WhatsApp · {whatsAppDisplay}
</span>
</span>
<ArrowUpRight
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[#25d366]"
aria-hidden="true"
/>
</WhatsAppLink>
<a
href="mailto:[email protected]"
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-sm">
evandrofonsecajunior@gmail.com
</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-sm 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>
<form
onSubmit={handleSubmit}
aria-labelledby="contact-form-title"
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
>
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#25d366] text-[#071b10]">
<WhatsAppMark className="h-7 w-7" />
</span>
<div>
<h3
id="contact-form-title"
className="font-display text-xl font-semibold leading-7 text-white"
>
Prepare sua mensagem
</h3>
<p className="mt-1 font-display text-sm leading-6 text-white/65">
Você revisa no WhatsApp antes de enviar.
</p>
</div>
</div>
<div className="space-y-sp-5">
<div className="grid gap-sp-5 xl:grid-cols-2">
<div>
<label htmlFor="contact-name" className={labelClassName}>
Nome completo
</label>
<input
id="contact-name"
name="name"
required
type="text"
autoComplete="name"
placeholder="Como posso te chamar?"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className={fieldClassName}
/>
</div>
<div>
<label htmlFor="contact-email" className={labelClassName}>
E-mail
</label>
<input
id="contact-email"
name="email"
required
type="email"
autoComplete="email"
placeholder="[email protected]"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className={fieldClassName}
/>
</div>
</div>
<div>
<label htmlFor="contact-subject" className={labelClassName}>
Tipo de projeto
</label>
<select
id="contact-subject"
name="subject"
value={formData.subject}
onChange={(e) =>
setFormData({ ...formData, subject: e.target.value })
}
className={`${fieldClassName} [color-scheme:dark]`}
>
<option value="Sistemas web sob medida">
Sistemas web sob medida
</option>
<option value="Aplicativos">Aplicativos</option>
<option value="Integrações e automações">
Integrações e automações
</option>
<option value="Evolução de um sistema existente">
Evolução de um sistema existente
</option>
<option value="Ainda preciso definir">
Ainda preciso definir
</option>
</select>
</div>
<div>
<label htmlFor="contact-message" className={labelClassName}>
Contexto do projeto
</label>
<textarea
id="contact-message"
name="message"
required
rows={4}
autoComplete="off"
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
aria-describedby="contact-privacy-note"
value={formData.message}
onChange={(e) =>
setFormData({ ...formData, message: e.target.value })
}
className={`${fieldClassName} min-h-32 resize-y`}
/>
</div>
<button
type="submit"
className="flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 font-display text-base font-semibold leading-6 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
>
<WhatsAppMark className="h-6 w-6 shrink-0" />
Continuar no WhatsApp
<ArrowUpRight className="h-5 w-5 shrink-0" aria-hidden="true" />
</button>
<p
id="contact-privacy-note"
className="font-display text-xs 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>
</form>
</div>
</section>
);
}
-911
View File
@@ -1,911 +0,0 @@
"use client";
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { buildWhatsAppUrl } from "@/lib/whatsapp";
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",
);
// MENTORIA: Agrupamento de estado para reduzir complexidade e re-renders
const [formData, setFormData] = useState({
userName: "",
userEmail: "",
userPhone: "",
userCompany: "",
howFound: "",
projectType: "",
otherProjectType: "",
desiredDeadline: "",
budgetValue: "",
technicalComplexity: "",
hasInternalTeam: "",
customMessage: "",
});
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 é ${formData.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,
${formData.userName}`;
};
const generateAdvancedMessage = () => {
if (formData.customMessage) return formData.customMessage;
// Saudação profissional
let message = `Prezado Evandro,\n\n`;
// Informações do contato
message += `Meu nome é ${formData.userName}`;
if (formData.userCompany) {
message += `, represento a empresa ${formData.userCompany}`;
}
message += `.\n`;
if (formData.howFound) {
message += `Conheci seu trabalho através de ${formData.howFound}.\n`;
}
message += `\n`;
// Detalhes do projeto
message += `=== DETALHES DO PROJETO ===\n`;
message += `Tipo de projeto: `;
if (formData.projectType === "outro" && formData.otherProjectType) {
message += `${formData.otherProjectType}\n`;
} else if (formData.projectType) {
message += `${formData.projectType}\n`;
} else {
message += `Não especificado\n`;
}
if (formData.desiredDeadline) {
message += `Prazo desejado: ${formData.desiredDeadline}\n`;
}
if (formData.budgetValue) {
message += `Orçamento disponível: ${formData.budgetValue}\n`;
}
if (formData.technicalComplexity) {
message += `Complexidade técnica estimada: ${formData.technicalComplexity}\n`;
}
if (formData.hasInternalTeam) {
message += `Equipe técnica interna: ${
formData.hasInternalTeam === "sim"
? "Sim"
: formData.hasInternalTeam === "nao"
? "Não"
: "Parcialmente"
}\n`;
}
message += `\n`;
// Contatos
message += `=== INFORMAÇÕES PARA CONTATO ===\n`;
if (formData.userEmail) {
message += `📧 Email: ${formData.userEmail}\n`;
}
if (formData.userPhone) {
message += `📱 Telefone/WhatsApp: ${formData.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 += `${formData.userName}`;
return message;
};
const handleInputChange =
(field: keyof typeof formData) =>
(
e: ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>,
) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
// Clear specific errors if the user starts typing
if (e.target.value.trim() !== "") {
if (field === "userName") setNameError(null);
if (field === "userEmail") setEmailError(null);
if (field === "userPhone") setPhoneError(null);
if (field === "otherProjectType") setOtherProjectTypeError(null);
}
};
const handleNameFocus = () => setNameFocused(true);
const handleEmailFocus = () => setEmailFocused(true);
const handlePhoneFocus = () => setPhoneFocused(true);
const generateWhatsAppUrl = () =>
buildWhatsAppUrl(
formType === "advanced"
? generateAdvancedMessage()
: formData.customMessage || getGreetingMessageWithUserName(),
);
const generateEmailUrl = () => {
const subject = encodeURIComponent("Proposta de Projeto - Portfólio");
const body = encodeURIComponent(
formType === "advanced"
? generateAdvancedMessage()
: `Nome: ${formData.userName}\nEmail: ${formData.userEmail}\nTelefone: ${formData.userPhone}\nEmpresa/Posição: ${formData.userCompany}\nComo conheceu: ${formData.howFound}\n\nTipo de Projeto: ${formData.projectType}\nPrazo Desejado: ${formData.desiredDeadline}\nOrçamento Disponível: ${formData.budgetValue}\nComplexidade Técnica: ${formData.technicalComplexity}\nTem equipe interna: ${formData.hasInternalTeam}\n\nMensagem: ${
formData.customMessage || getGreetingMessageWithUserName()
}`,
);
return `mailto:${data.contact.email}?subject=${subject}&body=${body}`;
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
let hasError = false;
if (formData.userName.trim() === "") {
setNameError("Por favor, insira seu nome.");
hasError = true;
} else {
setNameError(null);
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.userEmail)) {
setEmailError("Por favor, insira um email válido.");
hasError = true;
} else {
setEmailError(null);
}
if (formData.userPhone.trim() === "") {
setPhoneError("Por favor, insira seu telefone.");
hasError = true;
} else {
setPhoneError(null);
}
if (
formData.projectType === "outro" &&
formData.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
}
// UX FIX: Validação de tempo com feedback ou limite menor
const timeElapsed = Date.now() - startTime;
// Reduzido para 1000ms para evitar falsos positivos com autofill
if (timeElapsed < 1000) {
console.log("Formulário preenchido muito rápido, provável bot.");
// Opcional: Mostrar erro para humano real que digita muito rápido
// alert("Por favor, verifique os dados antes de enviar.");
return; // Ignora a submissão
}
if (hasError) return;
// Verificação de interação mínima
if (!nameFocused || !emailFocused || !phoneFocused) {
// MENTORIA: Isso pode bloquear usuários de Screen Readers ou Autofill agressivo.
// Considere remover ou tornar apenas um aviso no console.
console.warn(
"Campos principais não foram focados, verificando integridade.",
);
}
if (contactMethod === "whatsapp") {
window.open(generateWhatsAppUrl(), "_blank");
} else {
window.open(generateEmailUrl(), "_blank");
}
};
const getCtaMessage = () => {
if (formData.projectType === "consultoria") {
return "Agendar Consultoria Gratuita";
}
if (
formData.budgetValue &&
parseFloat(formData.budgetValue.replace(/\D/g, "")) < 2000
) {
return "Discutir Soluções de Baixo Custo";
}
if (
formData.projectType &&
formData.desiredDeadline &&
formData.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 (formData.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 (formData.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 (formData.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 (formData.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={formData.userName}
onChange={handleInputChange("userName")}
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={formData.userName}
onChange={handleInputChange("userName")}
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={formData.userEmail}
onChange={handleInputChange("userEmail")}
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={formData.userPhone}
onChange={handleInputChange("userPhone")}
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={formData.userCompany}
onChange={handleInputChange("userCompany")}
/>
</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={formData.howFound}
onChange={handleInputChange("howFound")}
>
{" "}
<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={formData.projectType}
onChange={handleInputChange("projectType")}
>
<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>
{formData.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={formData.otherProjectType}
onChange={handleInputChange("otherProjectType")}
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={formData.desiredDeadline}
onChange={handleInputChange("desiredDeadline")}
>
<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={formData.budgetValue}
onChange={handleInputChange("budgetValue")}
/>
</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={formData.technicalComplexity}
onChange={handleInputChange("technicalComplexity")}
>
<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={formData.hasInternalTeam}
onChange={handleInputChange("hasInternalTeam")}
>
<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={formData.customMessage}
onChange={handleInputChange("customMessage")}
></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>
{formData.projectType &&
formData.desiredDeadline &&
formData.budgetValue && (
<p className="text-center text-lg font-semibold text-cyan-400 mt-4">
{getEstimatedValue()}
</p>
)}
</form>
)}
</motion.div>
);
}
-147
View File
@@ -1,147 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "motion/react";
import { Mail, MessageCircle } from "lucide-react";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { getWhatsAppDisplayNumber } from "@/lib/whatsapp";
import MagneticButton from "./cinematic/MagneticButton";
interface ContactInfoProps {
onWhatsAppClick: () => void;
}
export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
const { data } = usePortfolioData();
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
useEffect(() => {
setWhatsAppDisplay(getWhatsAppDisplayNumber());
}, []);
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">
<MagneticButton
as="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 w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<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>
</MagneticButton>
<MagneticButton
onClick={onWhatsAppClick}
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<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">{whatsAppDisplay}</p>
</div>
</MagneticButton>
<MagneticButton
as="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 w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<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>
</MagneticButton>
<MagneticButton
as="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 w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<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>
</MagneticButton>
</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>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { Section } from "./Section";
const RESPONSIBILITIES = [
{
title: "Quem pode acessar cada dado?",
description:
"Login não basta. Permissões precisam ser verificadas no servidor e testadas entre usuários, empresas e perfis diferentes.",
},
{
title: "Como recuperar a operação?",
description:
"Ambientes separados, acesso restrito à produção e backups com restauração testada reduzem o impacto de uma alteração errada.",
},
{
title: "Quem valida antes de publicar?",
description:
"Código gerado também passa por revisão, testes e avaliação de dependências. O escopo inclui manutenção, custos e riscos de operação.",
},
];
export default function EngineeringWithAI() {
return (
<Section
id="engineering-ai"
eyebrow="IA no desenvolvimento"
title="Se a IA cria um sistema, por que contratar um desenvolvedor?"
description="A IA permite testar ideias e construir protótipos mesmo sem experiência em programação. Quando o sistema passa a cuidar de clientes, dinheiro ou dados pessoais, alguém precisa avaliar o que foi gerado e validar como ele se comporta fora da demonstração."
disableChildrenAnimation
>
<div className="border-l-2 border-[var(--slash)] pl-sp-5">
<p className="max-w-[75ch] text-lg leading-8 text-[var(--ink)]">
Você contrata engenharia para transformar as regras do seu negócio em
um sistema que possa ser testado, operado e mantido. A IA pode fazer
parte desse trabalho. Ela não dispensa decisões sobre arquitetura,
segurança e responsabilidade pelos dados.
</p>
</div>
<div className="mt-sp-8 grid gap-sp-6 md:grid-cols-3">
{RESPONSIBILITIES.map((item) => (
<article
key={item.title}
className="border-t border-[var(--border)] pt-sp-5"
>
<h3 className="text-lg font-semibold leading-snug">{item.title}</h3>
<p className="ds-copy">{item.description}</p>
</article>
))}
</div>
<div className="mt-sp-12">
<h3 className="ds-card-title">O que casos documentados ensinam</h3>
<p className="ds-copy max-w-[75ch]">
São episódios de 2025, não avaliações atuais das plataformas. Eles
mostram riscos de configuração, permissões e operação; não provam que
todo sistema feito com IA seja inseguro ou que um profissional elimine
qualquer risco.
</p>
<div className="mt-sp-6 space-y-sp-3">
<details className="ds-card">
<summary className="cursor-pointer text-base font-semibold leading-7">
Dados expostos em aplicações criadas com Lovable
</summary>
<div className="mt-sp-4 max-w-[85ch] space-y-sp-3 text-sm leading-7">
<p>
Em 29 de maio de 2025, a Semafor noticiou uma análise de Matt
Palmer e Kody Low: 170 de 1.645 aplicações examinadas permitiam
acesso indevido a informações, incluindo dados de usuários e
chaves de API. A reportagem relacionou a exposição à
configuração de acesso dos bancos conectados às aplicações.
</p>
<p>
O registro CVE-2025-48757 descreve políticas insuficientes de
acesso por registro no banco. O fornecedor contesta a atribuição
da vulnerabilidade à plataforma e ressalta a responsabilidade
dos clientes por seus dados. O relato de exposição não comprova
roubo por terceiros em todas essas aplicações.
</p>
<p>
<strong>O cuidado técnico:</strong> testar se uma pessoa
consegue ler ou alterar dados de outra, em vez de considerar a
tela de login uma prova de segurança.
</p>
<div className="flex flex-wrap gap-x-sp-6 gap-y-sp-2">
<a
className="underline underline-offset-4"
href="https://www.semafor.com/article/05/29/2025/the-hottest-new-vibe-coding-startup-lovable-is-a-sitting-duck-for-hackers"
target="_blank"
rel="noopener noreferrer"
>
Reportagem da Semafor (em inglês)
</a>
<a
className="underline underline-offset-4"
href="https://nvd.nist.gov/vuln/detail/CVE-2025-48757"
target="_blank"
rel="noopener noreferrer"
>
Registro e contestação no NVD
</a>
</div>
</div>
</details>
<details className="ds-card">
<summary className="cursor-pointer text-base font-semibold leading-7">
Replit: um agente apagou dados de uma aplicação
</summary>
<div className="mt-sp-4 max-w-[85ch] space-y-sp-3 text-sm leading-7">
<p>
Em julho de 2025, Jason Lemkin, cofundador da SaaStr, relatou
que um agente apagou dados durante a construção de uma
aplicação. A Replit reconheceu o episódio e informou que o banco
foi integralmente recuperado por rollback. Naquele projeto,
alterações de desenvolvimento podiam afetar a produção.
</p>
<p>
A plataforma anunciou separação entre bancos de desenvolvimento
e produção por padrão. <strong>O cuidado técnico:</strong>{" "}
limitar permissões de agentes, separar ambientes e testar a
recuperação antes de depender dela. Neste caso houve exclusão e
recuperação de dados, não roubo de informações.
</p>
<a
className="inline-block underline underline-offset-4"
href="https://replit.com/blog/doubling-down-on-our-commitment-to-secure-vibe-coding"
target="_blank"
rel="noopener noreferrer"
>
Relato e medidas publicadas pela Replit (29/07/2025)
</a>
</div>
</details>
<details className="ds-card">
<summary className="cursor-pointer text-base font-semibold leading-7">
Base44: aplicações privadas com falha de acesso
</summary>
<div className="mt-sp-4 max-w-[85ch] space-y-sp-3 text-sm leading-7">
<p>
Em julho de 2025, pesquisadores da Wiz demonstraram uma falha
que permitia acesso não autorizado a aplicações privadas do
Base44. A falha estava na camada compartilhada de autenticação
da plataforma, não em um erro comprovado de um usuário
iniciante.
</p>
<p>
A Wix corrigiu o problema em menos de 24 horas após a
notificação e informou não ter encontrado evidência de
exploração maliciosa. <strong>O cuidado técnico:</strong>{" "}
avaliar também o fornecedor e as permissões efetivas das
integrações, além do código da aplicação.
</p>
<a
className="inline-block underline underline-offset-4"
href="https://www.wiz.io/blog/critical-vulnerability-base44"
target="_blank"
rel="noopener noreferrer"
>
Pesquisa da Wiz e resposta da Wix (29/07/2025)
</a>
</div>
</details>
</div>
</div>
<div className="mt-sp-8 grid gap-sp-6 lg:grid-cols-2">
<article className="ds-card">
<h3 className="ds-card-title !mt-0">E a LGPD?</h3>
<p className="ds-copy">
Usar IA não afasta as obrigações de proteção de dados. A ANPD exige
medidas técnicas e administrativas também de agentes de pequeno
porte e esclarece que as flexibilizações não os isentam das demais
obrigações da LGPD, como bases legais e direitos dos titulares.
</p>
<p className="ds-copy">
O trabalho técnico inclui controles de acesso, coleta limitada ao
necessário e rotinas de retenção e exclusão. Conformidade depende
também dos processos da empresa e de orientação jurídica; contratar
desenvolvimento não equivale a uma certificação de adequação.
</p>
<a
className="mt-sp-4 inline-block text-sm underline underline-offset-4"
href="https://www.gov.br/anpd/pt-br/acesso-a-informacao/institucional/atos-normativos/regulamentacoes_anpd/resolucao-cd-anpd-no-2-de-27-de-janeiro-de-2022"
target="_blank"
rel="noopener noreferrer"
>
ANPD: Resolução nº 2/2022, arts. 6º, 12 e 13
</a>
</article>
<article className="ds-card">
<h3 className="ds-card-title !mt-0">
Já começou com IA? Podemos partir daí.
</h3>
<p className="ds-copy">
Seu protótipo ajuda a explicar a ideia. Antes de propor uma
reconstrução, avalio o que pode ser aproveitado, quais riscos
precisam de correção e o que falta para atender usuários em
produção.
</p>
<p className="ds-copy">
Você não precisa saber programar para conduzir o negócio. Precisa
entender o que está contratando, acompanhar a entrega e ter clareza
sobre quem cuida de cada parte do sistema.
</p>
<a className="ds-link ds-link-accent mt-sp-6" href="/#contact">
Avaliar meu projeto
</a>
</article>
</div>
</Section>
);
}
@@ -1,556 +0,0 @@
import { render } from "@testing-library/react";
import Experience from "./Experience";
import fc from "fast-check";
import { usePortfolioData } from "@/lib/usePortfolioData";
// Mock the portfolio data hook
jest.mock("@/lib/usePortfolioData");
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
typeof usePortfolioData
>;
// Mock framer-motion
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => false,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
article: ({ children, ...props }: any) => (
<article {...props}>{children}</article>
),
},
};
});
// Mock Section component
jest.mock("./Section", () => ({
Section: ({ children, ...props }: any) => (
<section {...props}>{children}</section>
),
}));
// Mock animation components
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <div data-testid="draw-line" />,
}));
jest.mock("./animations/CounterValue", () => ({
CounterValue: ({ value, suffix }: any) => (
<span>
{value}
{suffix}
</span>
),
}));
// Mock SectionLoading
jest.mock("./SectionLoading", () => ({
SectionLoading: ({ message }: any) => <div>{message}</div>,
}));
describe("Property 5: Experience Categorization", () => {
// Counter for unique IDs
let idCounter = 0;
// Generator for experience entries
const experienceArbitrary = (type: "Software" | "Hardware") =>
fc.record({
id: fc
.string({ minLength: 10, maxLength: 20 })
.map((s) => `exp-${type.toLowerCase()}-${idCounter++}-${s}`),
title: fc.string({ minLength: 5, maxLength: 50 }),
company: fc.string({ minLength: 3, maxLength: 30 }),
period: fc.string({ minLength: 10, maxLength: 30 }),
location: fc.string({ minLength: 5, maxLength: 40 }),
type: fc.constant(type),
description: fc.string({ minLength: 20, maxLength: 200 }),
achievements: fc.array(fc.string({ minLength: 10, maxLength: 100 }), {
minLength: 1,
maxLength: 5,
}),
technologies: fc.array(fc.string({ minLength: 2, maxLength: 20 }), {
minLength: 1,
maxLength: 10,
}),
featured: fc.constant(true),
});
beforeEach(() => {
idCounter = 0;
});
afterEach(() => {
jest.clearAllMocks();
});
it("should filter and render Software experiences in the Software category", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any experience entry with type="Software", it should be
* filtered and rendered within the Software category group. This ensures
* correct categorization of software-related work experiences.
*/
fc.assert(
fc.property(
fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 5,
}),
fc.array(experienceArbitrary("Hardware"), {
minLength: 0,
maxLength: 5,
}),
(softwareExps, hardwareExps) => {
const allExperiences = [...softwareExps, ...hardwareExps];
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Find the Software category section
const softwareHeading = Array.from(
container.querySelectorAll("h3"),
).find((h3) => h3.textContent?.includes("Software"));
if (!softwareHeading) {
// If no software heading, there should be no software experiences
return softwareExps.length === 0;
}
// Get the parent container of software experiences
const softwareSection = softwareHeading.closest("div");
if (!softwareSection) return false;
// Count experience cards in the software section
const softwareCards = softwareSection.querySelectorAll("article");
// The number of software cards should match the number of software experiences
return softwareCards.length === softwareExps.length;
},
),
{ numRuns: 100 },
);
});
it("should filter and render Hardware experiences in the Hardware category", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any experience entry with type="Hardware", it should be
* filtered and rendered within the Hardware category group. This ensures
* correct categorization of hardware-related work experiences.
*/
fc.assert(
fc.property(
fc.array(experienceArbitrary("Software"), {
minLength: 0,
maxLength: 5,
}),
fc.array(experienceArbitrary("Hardware"), {
minLength: 1,
maxLength: 5,
}),
(softwareExps, hardwareExps) => {
const allExperiences = [...softwareExps, ...hardwareExps];
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Find the Hardware category section
const hardwareHeading = Array.from(
container.querySelectorAll("h3"),
).find((h3) => h3.textContent?.includes("Hardware"));
if (!hardwareHeading) {
// If no hardware heading, there should be no hardware experiences
return hardwareExps.length === 0;
}
// Get the parent container of hardware experiences
const hardwareSection = hardwareHeading.closest("div");
if (!hardwareSection) return false;
// Count experience cards in the hardware section
const hardwareCards = hardwareSection.querySelectorAll("article");
// The number of hardware cards should match the number of hardware experiences
return hardwareCards.length === hardwareExps.length;
},
),
{ numRuns: 100 },
);
});
it("should separate Software and Hardware experiences into distinct category groups", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any mix of Software and Hardware experiences, they should be
* rendered in separate category groups with distinct headings. No experience
* should appear in both categories.
*/
fc.assert(
fc.property(
fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 3,
}),
fc.array(experienceArbitrary("Hardware"), {
minLength: 1,
maxLength: 3,
}),
(softwareExps, hardwareExps) => {
const allExperiences = [...softwareExps, ...hardwareExps];
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Find both category headings
const headings = Array.from(container.querySelectorAll("h3"));
const softwareHeading = headings.find((h3) =>
h3.textContent?.includes("Software"),
);
const hardwareHeading = headings.find((h3) =>
h3.textContent?.includes("Hardware"),
);
// Both headings should exist
if (!softwareHeading || !hardwareHeading) return false;
// Get all experience cards
const allCards = container.querySelectorAll("article");
// Total cards should equal total experiences
return allCards.length === allExperiences.length;
},
),
{ numRuns: 100 },
);
});
it("should not render category heading when no experiences exist for that type", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any category (Software or Hardware) with zero experiences,
* the category heading should not be rendered. This ensures clean UI without
* empty category sections.
*/
fc.assert(
fc.property(
fc.oneof(
fc.record({
software: fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 5,
}),
hardware: fc.constant([]),
}),
fc.record({
software: fc.constant([]),
hardware: fc.array(experienceArbitrary("Hardware"), {
minLength: 1,
maxLength: 5,
}),
}),
),
({ software, hardware }) => {
const allExperiences = [...software, ...hardware];
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
const headings = Array.from(container.querySelectorAll("h3"));
const softwareHeading = headings.find((h3) =>
h3.textContent?.includes("Software"),
);
const hardwareHeading = headings.find((h3) =>
h3.textContent?.includes("Hardware"),
);
// If software array is empty, no software heading should exist
const softwareCheck =
software.length === 0 ? !softwareHeading : !!softwareHeading;
// If hardware array is empty, no hardware heading should exist
const hardwareCheck =
hardware.length === 0 ? !hardwareHeading : !!hardwareHeading;
return softwareCheck && hardwareCheck;
},
),
{ numRuns: 100 },
);
});
it("should maintain experience data integrity within each category", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any experience entry, its data (title, company, description)
* should be rendered correctly within its assigned category. The categorization
* process should not modify or lose experience data.
*/
fc.assert(
fc.property(
fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 3,
}),
fc.array(experienceArbitrary("Hardware"), {
minLength: 1,
maxLength: 3,
}),
(softwareExps, hardwareExps) => {
const allExperiences = [...softwareExps, ...hardwareExps];
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Check that all experience titles are present in the rendered output
const allTitlesPresent = allExperiences.every((exp) => {
const titleElements = Array.from(container.querySelectorAll("h3"));
return titleElements.some((el) =>
el.textContent?.includes(exp.title),
);
});
// Check that all company names are present
const allCompaniesPresent = allExperiences.every((exp) => {
return container.textContent?.includes(exp.company);
});
return allTitlesPresent && allCompaniesPresent;
},
),
{ numRuns: 100 },
);
});
it("should handle mixed case and variations in type field values", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any experience entry, the type field should be matched
* case-sensitively to ensure correct categorization. Only exact matches
* to "Software" or "Hardware" should be categorized.
*/
fc.assert(
fc.property(
fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 3,
}),
(softwareExps) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences: softwareExps,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Find the Software category heading
const headings = Array.from(container.querySelectorAll("h3"));
const softwareHeading = headings.find((h3) =>
h3.textContent?.includes("Software"),
);
// Software heading should exist
if (!softwareHeading) return false;
// All experiences should be rendered
const cards = container.querySelectorAll("article");
return cards.length === softwareExps.length;
},
),
{ numRuns: 100 },
);
});
it("should render all experiences when both categories have entries", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any non-empty set of Software and Hardware experiences,
* all experiences should be rendered across both categories. No experiences
* should be lost during the categorization process.
*/
fc.assert(
fc.property(
fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 4,
}),
fc.array(experienceArbitrary("Hardware"), {
minLength: 1,
maxLength: 4,
}),
(softwareExps, hardwareExps) => {
const allExperiences = [...softwareExps, ...hardwareExps];
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Count all rendered experience cards
const allCards = container.querySelectorAll("article");
// Total cards should equal total experiences
return allCards.length === allExperiences.length;
},
),
{ numRuns: 100 },
);
});
it("should maintain category separation across multiple renders", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any number of component renders with the same data,
* the categorization should remain consistent. Software experiences
* should always appear in the Software category and Hardware experiences
* in the Hardware category.
*/
fc.assert(
fc.property(
fc.integer({ min: 2, max: 4 }), // Number of renders
fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 2,
}),
fc.array(experienceArbitrary("Hardware"), {
minLength: 1,
maxLength: 2,
}),
(numRenders, softwareExps, hardwareExps) => {
const allExperiences = [...softwareExps, ...hardwareExps];
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container, unmount } = render(<Experience />);
// Check that both categories exist
const headings = Array.from(container.querySelectorAll("h3"));
const softwareHeading = headings.find((h3) =>
h3.textContent?.includes("Software"),
);
const hardwareHeading = headings.find((h3) =>
h3.textContent?.includes("Hardware"),
);
if (!softwareHeading || !hardwareHeading) {
allRendersValid = false;
}
// Check total card count
const allCards = container.querySelectorAll("article");
if (allCards.length !== allExperiences.length) {
allRendersValid = false;
}
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
});
@@ -1,641 +0,0 @@
import { render } from "@testing-library/react";
import Experience from "./Experience";
import fc from "fast-check";
import { usePortfolioData } from "@/lib/usePortfolioData";
// Mock the portfolio data hook
jest.mock("@/lib/usePortfolioData");
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
typeof usePortfolioData
>;
// Mock framer-motion
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => false,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
article: ({ children, ...props }: any) => (
<article {...props}>{children}</article>
),
},
};
});
// Mock Section component
jest.mock("./Section", () => ({
Section: ({ children, ...props }: any) => (
<section {...props}>{children}</section>
),
}));
// Mock animation components
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <div data-testid="draw-line" />,
}));
jest.mock("./animations/CounterValue", () => ({
CounterValue: ({ value, suffix }: any) => (
<span>
{value}
{suffix}
</span>
),
}));
// Mock SectionLoading
jest.mock("./SectionLoading", () => ({
SectionLoading: ({ message }: any) => <div>{message}</div>,
}));
describe("Property 6: Chronological Ordering Within Categories", () => {
// Counter for unique IDs
let idCounter = 0;
// Generator for experience entries with specific year
const experienceWithYearArbitrary = (
type: "Software" | "Hardware",
year: number,
) =>
fc.record({
id: fc
.string({ minLength: 10, maxLength: 20 })
.map((s) => `exp-${type.toLowerCase()}-${year}-${idCounter++}-${s}`),
title: fc.string({ minLength: 5, maxLength: 50 }),
company: fc.string({ minLength: 3, maxLength: 30 }),
period: fc.constant(`Janeiro ${year} - Dezembro ${year}`),
location: fc.string({ minLength: 5, maxLength: 40 }),
type: fc.constant(type),
description: fc.string({ minLength: 20, maxLength: 200 }),
achievements: fc.array(fc.string({ minLength: 10, maxLength: 100 }), {
minLength: 1,
maxLength: 5,
}),
technologies: fc.array(fc.string({ minLength: 2, maxLength: 20 }), {
minLength: 1,
maxLength: 10,
}),
featured: fc.constant(true),
});
// Generator for array of experiences with random years
const experiencesWithYearsArbitrary = (
type: "Software" | "Hardware",
count: number,
) => {
return fc
.array(fc.integer({ min: 2015, max: 2025 }), {
minLength: count,
maxLength: count,
})
.chain((years) => {
return fc.tuple(
...years.map((year) => experienceWithYearArbitrary(type, year)),
);
});
};
beforeEach(() => {
idCounter = 0;
});
afterEach(() => {
jest.clearAllMocks();
});
it("should sort Software experiences in descending chronological order by year", () => {
/**
* **Validates: Requirements 4.3**
*
* Property: For any set of Software experiences with different years,
* they should be rendered in descending chronological order (newest first).
* This ensures users see the most recent experiences at the top.
*/
fc.assert(
fc.property(
fc
.integer({ min: 2, max: 5 })
.chain((count) => experiencesWithYearsArbitrary("Software", count)),
(softwareExps) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences: softwareExps,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Find the Software category section
const softwareHeading = Array.from(
container.querySelectorAll("h3"),
).find((h3) => h3.textContent?.includes("Software"));
if (!softwareHeading) return false;
// Get the parent container of software experiences
const softwareSection = softwareHeading.closest("div");
if (!softwareSection) return false;
// Get all experience cards in the software section
const softwareCards = Array.from(
softwareSection.querySelectorAll("article"),
);
// Extract years from the rendered cards
const renderedYears = softwareCards.map((card) => {
const periodText =
card.querySelector(".ds-kicker")?.textContent || "";
const yearMatch = periodText.match(/\d{4}/);
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
});
// Check if years are in descending order
for (let i = 0; i < renderedYears.length - 1; i++) {
if (renderedYears[i] < renderedYears[i + 1]) {
return false;
}
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should sort Hardware experiences in descending chronological order by year", () => {
/**
* **Validates: Requirements 4.3**
*
* Property: For any set of Hardware experiences with different years,
* they should be rendered in descending chronological order (newest first).
* This ensures users see the most recent experiences at the top.
*/
fc.assert(
fc.property(
fc
.integer({ min: 2, max: 5 })
.chain((count) => experiencesWithYearsArbitrary("Hardware", count)),
(hardwareExps) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences: hardwareExps,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Find the Hardware category section
const hardwareHeading = Array.from(
container.querySelectorAll("h3"),
).find((h3) => h3.textContent?.includes("Hardware"));
if (!hardwareHeading) return false;
// Get the parent container of hardware experiences
const hardwareSection = hardwareHeading.closest("div");
if (!hardwareSection) return false;
// Get all experience cards in the hardware section
const hardwareCards = Array.from(
hardwareSection.querySelectorAll("article"),
);
// Extract years from the rendered cards
const renderedYears = hardwareCards.map((card) => {
const periodText =
card.querySelector(".ds-kicker")?.textContent || "";
const yearMatch = periodText.match(/\d{4}/);
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
});
// Check if years are in descending order
for (let i = 0; i < renderedYears.length - 1; i++) {
if (renderedYears[i] < renderedYears[i + 1]) {
return false;
}
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should maintain chronological order within each category independently", () => {
/**
* **Validates: Requirements 4.3**
*
* Property: For any mix of Software and Hardware experiences with various years,
* each category should maintain its own descending chronological order independently.
* The ordering in one category should not affect the other.
*/
fc.assert(
fc.property(
fc
.integer({ min: 2, max: 4 })
.chain((softwareCount) =>
fc
.integer({ min: 2, max: 4 })
.chain((hardwareCount) =>
fc.tuple(
experiencesWithYearsArbitrary("Software", softwareCount),
experiencesWithYearsArbitrary("Hardware", hardwareCount),
),
),
),
([softwareExps, hardwareExps]) => {
const allExperiences = [...softwareExps, ...hardwareExps];
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Check Software category ordering
const softwareHeading = Array.from(
container.querySelectorAll("h3"),
).find((h3) => h3.textContent?.includes("Software"));
if (!softwareHeading) return false;
const softwareSection = softwareHeading.closest("div");
if (!softwareSection) return false;
const softwareCards = Array.from(
softwareSection.querySelectorAll("article"),
);
const softwareYears = softwareCards.map((card) => {
const periodText =
card.querySelector(".ds-kicker")?.textContent || "";
const yearMatch = periodText.match(/\d{4}/);
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
});
// Check if software years are in descending order
for (let i = 0; i < softwareYears.length - 1; i++) {
if (softwareYears[i] < softwareYears[i + 1]) {
return false;
}
}
// Check Hardware category ordering
const hardwareHeading = Array.from(
container.querySelectorAll("h3"),
).find((h3) => h3.textContent?.includes("Hardware"));
if (!hardwareHeading) return false;
const hardwareSection = hardwareHeading.closest("div");
if (!hardwareSection) return false;
const hardwareCards = Array.from(
hardwareSection.querySelectorAll("article"),
);
const hardwareYears = hardwareCards.map((card) => {
const periodText =
card.querySelector(".ds-kicker")?.textContent || "";
const yearMatch = periodText.match(/\d{4}/);
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
});
// Check if hardware years are in descending order
for (let i = 0; i < hardwareYears.length - 1; i++) {
if (hardwareYears[i] < hardwareYears[i + 1]) {
return false;
}
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should handle experiences with the same year within a category", () => {
/**
* **Validates: Requirements 4.3**
*
* Property: For any set of experiences with the same year within a category,
* they should all be rendered without errors. The relative order of same-year
* experiences is not specified but should be stable.
*/
fc.assert(
fc.property(
fc.integer({ min: 2015, max: 2025 }),
fc.integer({ min: 2, max: 4 }),
(year, count) => {
const softwareExps = Array.from({ length: count }, (_, i) => ({
id: `exp-software-${year}-${i}`,
title: `Software Title ${i}`,
company: `Company ${i}`,
period: `Janeiro ${year} - Dezembro ${year}`,
location: `Location ${i}`,
type: "Software" as const,
description: `Description ${i}`,
achievements: [`Achievement ${i}`],
technologies: [`Tech ${i}`],
featured: true,
}));
mockUsePortfolioData.mockReturnValue({
data: {
experiences: softwareExps,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Find the Software category section
const softwareHeading = Array.from(
container.querySelectorAll("h3"),
).find((h3) => h3.textContent?.includes("Software"));
if (!softwareHeading) return false;
const softwareSection = softwareHeading.closest("div");
if (!softwareSection) return false;
const softwareCards = softwareSection.querySelectorAll("article");
// All experiences should be rendered
return softwareCards.length === count;
},
),
{ numRuns: 100 },
);
});
it("should correctly parse years from various period formats", () => {
/**
* **Validates: Requirements 4.3**
*
* Property: For any experience with a period string containing a 4-digit year,
* the year should be correctly extracted and used for chronological ordering.
* This ensures robust parsing across different date formats.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
year: fc.integer({ min: 2015, max: 2025 }),
format: fc.constantFrom(
(y: number) => `Janeiro ${y} - Dezembro ${y}`,
(y: number) => `${y} - Presente`,
(y: number) => `Dezembro ${y} - Fevereiro ${y + 1}`,
(y: number) => `Junho ${y} - Presente`,
),
}),
{ minLength: 2, maxLength: 5 },
),
(periodConfigs) => {
const softwareExps = periodConfigs.map((config, i) => ({
id: `exp-software-${i}`,
title: `Software Title ${i}`,
company: `Company ${i}`,
period: config.format(config.year),
location: `Location ${i}`,
type: "Software" as const,
description: `Description ${i}`,
achievements: [`Achievement ${i}`],
technologies: [`Tech ${i}`],
featured: true,
}));
mockUsePortfolioData.mockReturnValue({
data: {
experiences: softwareExps,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Find the Software category section
const softwareHeading = Array.from(
container.querySelectorAll("h3"),
).find((h3) => h3.textContent?.includes("Software"));
if (!softwareHeading) return false;
const softwareSection = softwareHeading.closest("div");
if (!softwareSection) return false;
const softwareCards = Array.from(
softwareSection.querySelectorAll("article"),
);
// Extract years from the rendered cards
const renderedYears = softwareCards.map((card) => {
const periodText =
card.querySelector(".ds-kicker")?.textContent || "";
const yearMatch = periodText.match(/\d{4}/);
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
});
// Check if years are in descending order
for (let i = 0; i < renderedYears.length - 1; i++) {
if (renderedYears[i] < renderedYears[i + 1]) {
return false;
}
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should maintain chronological order across component re-renders", () => {
/**
* **Validates: Requirements 4.3**
*
* Property: For any set of experiences, the chronological ordering should
* remain consistent across multiple component renders. The sorting should
* be deterministic and stable.
*/
fc.assert(
fc.property(
fc
.integer({ min: 3, max: 5 })
.chain((count) => experiencesWithYearsArbitrary("Software", count)),
fc.integer({ min: 2, max: 3 }), // Number of renders
(softwareExps, numRenders) => {
let firstRenderYears: number[] = [];
for (let i = 0; i < numRenders; i++) {
mockUsePortfolioData.mockReturnValue({
data: {
experiences: softwareExps,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container, unmount } = render(<Experience />);
const softwareHeading = Array.from(
container.querySelectorAll("h3"),
).find((h3) => h3.textContent?.includes("Software"));
if (!softwareHeading) return false;
const softwareSection = softwareHeading.closest("div");
if (!softwareSection) return false;
const softwareCards = Array.from(
softwareSection.querySelectorAll("article"),
);
const renderedYears = softwareCards.map((card) => {
const periodText =
card.querySelector(".ds-kicker")?.textContent || "";
const yearMatch = periodText.match(/\d{4}/);
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
});
if (i === 0) {
firstRenderYears = renderedYears;
} else {
// Check if the order matches the first render
if (renderedYears.length !== firstRenderYears.length)
return false;
for (let j = 0; j < renderedYears.length; j++) {
if (renderedYears[j] !== firstRenderYears[j]) return false;
}
}
unmount();
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should sort experiences with year ranges by the first year", () => {
/**
* **Validates: Requirements 4.3**
*
* Property: For any experience with a period containing multiple years
* (e.g., "2023 - 2024"), the sorting should use the first year found.
* This ensures consistent ordering for multi-year experiences.
*/
fc.assert(
fc.property(
fc.array(fc.integer({ min: 2015, max: 2024 }), {
minLength: 2,
maxLength: 5,
}),
(years) => {
const softwareExps = years.map((year, i) => ({
id: `exp-software-${i}`,
title: `Software Title ${i}`,
company: `Company ${i}`,
period: `Janeiro ${year} - Dezembro ${year + 1}`,
location: `Location ${i}`,
type: "Software" as const,
description: `Description ${i}`,
achievements: [`Achievement ${i}`],
technologies: [`Tech ${i}`],
featured: true,
}));
mockUsePortfolioData.mockReturnValue({
data: {
experiences: softwareExps,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
const softwareHeading = Array.from(
container.querySelectorAll("h3"),
).find((h3) => h3.textContent?.includes("Software"));
if (!softwareHeading) return false;
const softwareSection = softwareHeading.closest("div");
if (!softwareSection) return false;
const softwareCards = Array.from(
softwareSection.querySelectorAll("article"),
);
// Extract first year from each rendered card
const renderedYears = softwareCards.map((card) => {
const periodText =
card.querySelector(".ds-kicker")?.textContent || "";
const yearMatch = periodText.match(/\d{4}/);
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
});
// Check if years are in descending order
for (let i = 0; i < renderedYears.length - 1; i++) {
if (renderedYears[i] < renderedYears[i + 1]) {
return false;
}
}
return true;
},
),
{ numRuns: 100 },
);
});
});
@@ -1,407 +0,0 @@
import { render } from "@testing-library/react";
import Experience from "./Experience";
import fc from "fast-check";
import { usePortfolioData } from "@/lib/usePortfolioData";
// Mock the portfolio data hook
jest.mock("@/lib/usePortfolioData");
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
typeof usePortfolioData
>;
// Mock framer-motion
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => false,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
article: ({ children, ...props }: any) => (
<article {...props}>{children}</article>
),
},
};
});
// Mock Section component
jest.mock("./Section", () => ({
Section: ({ children, ...props }: any) => (
<section {...props}>{children}</section>
),
}));
// Mock animation components
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <div data-testid="draw-line" />,
}));
jest.mock("./animations/CounterValue", () => ({
CounterValue: ({ value, suffix }: any) => (
<span>
{value}
{suffix}
</span>
),
}));
// Mock SectionLoading
jest.mock("./SectionLoading", () => ({
SectionLoading: ({ message }: any) => <div>{message}</div>,
}));
describe("Property 11: Semantic Heading Hierarchy", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("should maintain proper heading hierarchy (h2 for section, h3 for cards)", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any sequence of heading elements in the Experience section,
* the heading levels should follow proper semantic hierarchy (h2 for section
* title, h3 for experience card titles, no skipped levels). This ensures
* screen readers can properly navigate the document structure.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.string(),
title: fc.string({ minLength: 1 }),
company: fc.string({ minLength: 1 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.string({ minLength: 1 }),
achievements: fc.array(fc.string({ minLength: 1 })),
technologies: fc.array(fc.string({ minLength: 1 })),
featured: fc.constant(true),
}),
{ minLength: 1, maxLength: 10 },
),
(experiences) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Get all heading elements in order
const headings = Array.from(
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
if (headings.length === 0) {
return false;
}
// Extract heading levels
const levels = headings.map((h) => parseInt(h.tagName.substring(1)));
// Check for proper hierarchy:
// 1. Should start with h2 (section title)
// 2. Experience card titles should be h3
// 3. No skipped levels (difference should not be > 1)
// First heading should be h2 (section title)
if (levels[0] !== 2) {
return false;
}
// Check for skipped levels
for (let i = 1; i < levels.length; i++) {
const diff = levels[i] - levels[i - 1];
// Difference should be -1, 0, or 1 (no skipping levels)
if (Math.abs(diff) > 1 && diff > 0) {
return false;
}
}
// All experience card titles should be h3
const cardTitles = container.querySelectorAll("article h3");
if (cardTitles.length === 0) {
return false;
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should not skip heading levels in the hierarchy", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any generated set of experiences, the heading hierarchy
* should never skip levels (e.g., h2 directly to h4). This is critical
* for accessibility as screen reader users rely on heading levels to
* understand document structure.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.uuid(),
title: fc.lorem({ maxCount: 5 }),
company: fc.lorem({ maxCount: 2 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.lorem({ maxCount: 10 }),
achievements: fc.array(fc.lorem({ maxCount: 5 }), { maxLength: 3 }),
technologies: fc.array(fc.lorem({ maxCount: 1 }), { maxLength: 5 }),
featured: fc.constant(true),
}),
{ minLength: 2, maxLength: 8 },
),
(experiences) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Get all headings
const headings = Array.from(
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
const levels = headings.map((h) => parseInt(h.tagName.substring(1)));
// Check that we never skip levels when going deeper
for (let i = 1; i < levels.length; i++) {
const currentLevel = levels[i];
const previousLevel = levels[i - 1];
// When going deeper (increasing level number), should only increase by 1
if (
currentLevel > previousLevel &&
currentLevel - previousLevel > 1
) {
return false;
}
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should use h2 for section title consistently", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any Experience section render, the main section title
* should always be an h2 element. This maintains consistency with the
* overall page structure where main sections use h2.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.string(),
title: fc.string({ minLength: 1 }),
company: fc.string({ minLength: 1 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.string({ minLength: 1 }),
achievements: fc.array(fc.string({ minLength: 1 })),
technologies: fc.array(fc.string({ minLength: 1 })),
featured: fc.constant(true),
}),
{ minLength: 1, maxLength: 5 },
),
(experiences) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Find the section element
const section = container.querySelector("section");
if (!section) return false;
// The first heading in the section should be h2
const firstHeading = section.querySelector("h1, h2, h3, h4, h5, h6");
if (!firstHeading) return false;
return firstHeading.tagName === "H2";
},
),
{ numRuns: 100 },
);
});
it("should use h3 for all experience card titles", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any set of experience cards rendered, each card's title
* should be an h3 element. This maintains proper hierarchy under the
* section h2 and ensures consistent navigation for assistive technology.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.uuid(),
title: fc.lorem({ maxCount: 5 }),
company: fc.lorem({ maxCount: 2 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.lorem({ maxCount: 10 }),
achievements: fc.array(fc.lorem({ maxCount: 5 })),
technologies: fc.array(fc.lorem({ maxCount: 1 })),
featured: fc.constant(true),
}),
{ minLength: 1, maxLength: 10 },
),
(experiences) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Find all article elements (experience cards)
const articles = container.querySelectorAll("article");
if (articles.length === 0) {
return false;
}
// Each article should have at least one h3 for the title
let allArticlesHaveH3 = true;
articles.forEach((article) => {
const h3Elements = article.querySelectorAll("h3");
// Should have at least one h3 (the title)
if (h3Elements.length === 0) {
allArticlesHaveH3 = false;
}
});
return allArticlesHaveH3;
},
),
{ numRuns: 100 },
);
});
it("should maintain hierarchy across multiple renders", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any number of component re-renders with the same data,
* the heading hierarchy should remain consistent. This ensures the
* semantic structure is stable and reliable for assistive technology.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.string(),
title: fc.string({ minLength: 1 }),
company: fc.string({ minLength: 1 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.string({ minLength: 1 }),
achievements: fc.array(fc.string({ minLength: 1 })),
technologies: fc.array(fc.string({ minLength: 1 })),
featured: fc.constant(true),
}),
{ minLength: 1, maxLength: 5 },
),
fc.integer({ min: 2, max: 5 }),
(experiences, numRenders) => {
let previousHierarchy: number[] | null = null;
for (let i = 0; i < numRenders; i++) {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container, unmount } = render(<Experience />);
const headings = Array.from(
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
const currentHierarchy = headings.map((h) =>
parseInt(h.tagName.substring(1)),
);
if (previousHierarchy !== null) {
// Hierarchy should be identical across renders
if (
JSON.stringify(currentHierarchy) !==
JSON.stringify(previousHierarchy)
) {
return false;
}
}
previousHierarchy = currentHierarchy;
unmount();
}
return true;
},
),
{ numRuns: 100 },
);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { render, screen, within } from "@testing-library/react";
import type { HTMLAttributes, ReactNode } from "react";
import type { PortfolioExperience } from "@/lib/portfolio-data";
import Experience from "./Experience";
type MotionProps = HTMLAttributes<HTMLElement> & {
initial?: unknown;
whileInView?: unknown;
viewport?: unknown;
variants?: unknown;
whileHover?: unknown;
whileTap?: unknown;
transition?: unknown;
};
jest.mock("motion/react", () => {
const createMotionElement =
(Tag: "div" | "article") =>
({
children,
initial: _initial,
whileInView: _whileInView,
viewport: _viewport,
variants: _variants,
whileHover: _whileHover,
whileTap: _whileTap,
transition: _transition,
...props
}: MotionProps & { children?: ReactNode }) => (
<Tag {...props}>{children}</Tag>
);
return {
motion: {
div: createMotionElement("div"),
article: createMotionElement("article"),
},
useReducedMotion: () => false,
};
});
jest.mock("./Section", () => ({
Section: ({
children,
id,
title,
}: {
children?: ReactNode;
id?: string;
title?: string;
}) => (
<section id={id}>
<h2>{title}</h2>
{children}
</section>
),
}));
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => null,
}));
function experience({
id,
title,
period,
featured,
}: {
id: string;
title: string;
period: string;
featured: boolean;
}): PortfolioExperience {
return {
id,
title,
company: "Empresa",
period,
location: "Remoto",
type: "Software",
description: "Descrição",
achievements: [],
technologies: [],
featured,
};
}
describe("Experience", () => {
it("renders only featured experiences from newest to oldest", () => {
render(
<Experience
experiences={[
experience({
id: "hidden",
title: "Oculta",
period: "2026 - Atual",
featured: false,
}),
experience({
id: "older",
title: "Mais antiga",
period: "2019 - 2021",
featured: true,
}),
experience({
id: "recent",
title: "Mais recente",
period: "2024 - Atual",
featured: true,
}),
]}
/>,
);
expect(
screen.queryByRole("heading", { name: "Oculta" }),
).not.toBeInTheDocument();
expect(
screen
.getAllByRole("article")
.map(
(article) =>
within(article).getByRole("heading", { level: 3 }).textContent,
),
).toEqual(["Mais recente", "Mais antiga"]);
});
});
+12 -8
View File
@@ -1,15 +1,15 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { PortfolioExperience } from "@/lib/portfolio-data";
import { DrawLine } from "./animations/DrawLine";
import { Section } from "./Section";
export default function Experience({
experiences,
experiences = [],
}: {
experiences: PortfolioExperience[];
experiences?: PortfolioExperience[];
}) {
const shouldReduceMotion = useReducedMotion();
const sortedExperiences = [...experiences]
@@ -48,8 +48,8 @@ export default function Experience({
<Section
id="experience"
eyebrow="Selected experience"
title="Experiencia em engenharia de software e sistemas criticos"
description="Projetos e contextos em que combinei arquitetura de software, integracoes complexas e entrega com responsabilidade operacional."
title="Experiência em engenharia de software e sistemas críticos"
description="Projetos e contextos em que combinei arquitetura de software, integrações complexas e entrega com responsabilidade operacional."
disableChildrenAnimation
>
<motion.div
@@ -79,7 +79,9 @@ export default function Experience({
<div className="max-w-[720px]">
<div className="ds-kicker">{exp.period}</div>
<DrawLine delay={0.04} />
<h3 className="ds-card-title">{exp.title}</h3>
<h3 className="ds-card-title" data-reel="left">
{exp.title}
</h3>
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(10,10,10,.64)] dark:text-[rgba(255,255,255,.6)]">
{exp.company} • {exp.type}
</p>
@@ -87,7 +89,9 @@ export default function Experience({
<div className="ds-metric">{exp.location}</div>
</div>
<p className="ds-copy">{exp.description}</p>
<p className="ds-copy" data-reel="right">
{exp.description}
</p>
<ul className="ds-list">
{exp.achievements?.map((achievement) => (
@@ -95,7 +99,7 @@ export default function Experience({
))}
</ul>
<ul className="ds-tags mt-sp-6">
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{exp.technologies?.slice(0, 6).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
@@ -1,300 +0,0 @@
import { render } from "@testing-library/react";
import Footer from "./Footer";
import fc from "fast-check";
describe("Property 9: ARIA Labels for Links", () => {
it("should have descriptive aria-label attributes for all social media links", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link in the Footer component, it should have
* a descriptive aria-label attribute that identifies the platform and action.
* This ensures accessibility for users with assistive technologies.
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed, testing static component
() => {
const { container } = render(<Footer />);
// Find all social media links (they have SVG icons and are in the Connect section)
const socialLinks = container.querySelectorAll(
"footer a[aria-label]",
);
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Verify we found social links
if (socialLinksWithSvg.length === 0) {
return false;
}
// Check each social link has a non-empty, descriptive aria-label
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
// aria-label must exist and be non-empty
if (!ariaLabel || ariaLabel.trim().length === 0) {
return false;
}
// aria-label should be descriptive (at least 5 characters)
// and should describe an action (contain words like "Visit", "View", "Go to", etc.)
return ariaLabel.length >= 5;
});
},
),
{ numRuns: 100 },
);
});
it("should have unique aria-label for each social platform", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any set of social media links, each should have a unique
* aria-label that identifies the specific platform. This ensures users can
* distinguish between different social links when using screen readers.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links with aria-labels
const socialLinks = container.querySelectorAll("footer a[aria-label]");
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Collect all aria-labels
const ariaLabels = socialLinksWithSvg.map((link) =>
link.getAttribute("aria-label"),
);
// Verify we have aria-labels
if (ariaLabels.length === 0) {
return false;
}
// Check that all aria-labels are unique
const uniqueLabels = new Set(ariaLabels);
return uniqueLabels.size === ariaLabels.length;
}),
{ numRuns: 100 },
);
});
it("should have aria-label that identifies both platform and action", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link, the aria-label should contain both
* the platform name (GitHub, LinkedIn, etc.) and an action verb (Visit, View, etc.).
* This provides complete context for assistive technology users.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links
const socialLinks = container.querySelectorAll("footer a[aria-label]");
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Verify we found social links
if (socialLinksWithSvg.length === 0) {
return false;
}
// Common action verbs for social links
const actionVerbs = ["visit", "view", "go to", "open", "see", "check"];
// Check each link has an action verb in its aria-label
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
if (!ariaLabel) {
return false;
}
const lowerLabel = ariaLabel.toLowerCase();
// Check if aria-label contains at least one action verb
return actionVerbs.some((verb) => lowerLabel.includes(verb));
});
}),
{ numRuns: 100 },
);
});
it("should maintain aria-labels across multiple renders", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any number of component renders, social links should consistently
* maintain their aria-label attributes. This verifies that accessibility attributes
* are not accidentally removed during re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
(numRenders) => {
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<Footer />);
// Find social links
const socialLinks = container.querySelectorAll(
"footer a[aria-label]",
);
const socialLinksWithSvg = Array.from(socialLinks).filter(
(link) => {
return link.querySelector("svg") !== null;
},
);
// Check each link has a valid aria-label
socialLinksWithSvg.forEach((link) => {
const ariaLabel = link.getAttribute("aria-label");
if (!ariaLabel || ariaLabel.trim().length < 5) {
allRendersValid = false;
}
});
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
it('should have aria-label for every social link with target="_blank"', () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social link that opens in a new tab (target="_blank"),
* it must have an aria-label. This ensures users with assistive technologies
* are informed about where the link leads before activating it.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all links that open in new tab
const newTabLinks = container.querySelectorAll(
'footer a[target="_blank"]',
);
// Verify we found links
if (newTabLinks.length === 0) {
return false;
}
// Each new tab link should have an aria-label
return Array.from(newTabLinks).every((link) => {
const ariaLabel = link.getAttribute("aria-label");
return ariaLabel !== null && ariaLabel.trim().length > 0;
});
}),
{ numRuns: 100 },
);
});
it("should not have empty or whitespace-only aria-labels", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link with an aria-label attribute,
* the value should not be empty or contain only whitespace. Empty
* aria-labels provide no accessibility benefit and can confuse screen readers.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links
const socialLinks = container.querySelectorAll("footer a[aria-label]");
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Verify we found social links
if (socialLinksWithSvg.length === 0) {
return false;
}
// Check that no aria-label is empty or whitespace-only
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
return ariaLabel !== null && ariaLabel.trim().length > 0;
});
}),
{ numRuns: 100 },
);
});
it("should have aria-label that matches or relates to the link title", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link with both aria-label and title attributes,
* the aria-label should provide context that relates to the title (platform name).
* This ensures consistency between visual tooltips and screen reader announcements.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links with both aria-label and title
const socialLinks = container.querySelectorAll(
"footer a[aria-label][title]",
);
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// If no links have both attributes, that's okay (not all implementations require title)
if (socialLinksWithSvg.length === 0) {
return true;
}
// Check that aria-label relates to title
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
const title = link.getAttribute("title");
if (!ariaLabel || !title) {
return false;
}
// aria-label should contain the platform name from title
// or title should be contained in aria-label
const lowerLabel = ariaLabel.toLowerCase();
const lowerTitle = title.toLowerCase();
return (
lowerLabel.includes(lowerTitle) || lowerTitle.includes(lowerLabel)
);
});
}),
{ numRuns: 100 },
);
});
});
@@ -1,264 +0,0 @@
import { render } from "@testing-library/react";
import Footer from "./Footer";
import fc from "fast-check";
describe("Property 3: Responsive Component Layout", () => {
it("should include Tailwind responsive utility classes for grid layout", () => {
/**
* **Validates: Requirements 2.6**
*
* Property: For any Footer component, it should include Tailwind responsive
* utility classes (sm:, md:, lg:) to adapt the grid layout across viewport sizes.
* This ensures the component is responsive and provides optimal layout at different
* screen widths.
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed, testing static component
() => {
const { container } = render(<Footer />);
// Find the main grid container
const gridContainer = container.querySelector("footer .grid");
// Verify grid container exists
if (!gridContainer) {
return false;
}
const className = gridContainer.className;
// Check for responsive grid-cols classes
// Should have base grid-cols-1 and responsive variants
const hasBaseGridCols = className.includes("grid-cols-1");
const hasMdResponsive = className.includes("md:grid-cols-");
const hasLgResponsive = className.includes("lg:grid-cols-");
return hasBaseGridCols && hasMdResponsive && hasLgResponsive;
},
),
{ numRuns: 100 },
);
});
it("should include responsive padding classes", () => {
/**
* **Validates: Requirements 2.6**
*
* Property: For any Footer component, the container should use the design system
* section class (ds-section) which provides responsive padding that scales
* appropriately across viewport sizes. This ensures consistent spacing that
* adapts to different screen widths.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find the footer element
const footer = container.querySelector("footer");
if (!footer) {
return false;
}
const className = footer.className;
// Check for design system section class which handles responsive padding
const hasDsSection = className.includes("ds-section");
// The ds-section class in globals.css provides responsive padding via CSS
// padding: var(--sp-20) var(--sp-16) on desktop
// padding: var(--sp-16) var(--sp-6) on mobile (via @media query)
return hasDsSection;
}),
{ numRuns: 100 },
);
});
it("should include responsive gap classes for grid spacing", () => {
/**
* **Validates: Requirements 2.6**
*
* Property: For any Footer component grid, it should include gap
* classes to adjust spacing between grid items.
* This ensures optimal spacing that scales with the layout.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find the grid container
const gridContainer = container.querySelector("footer .grid");
if (!gridContainer) {
return false;
}
const className = gridContainer.className;
// Check for gap classes (base gap)
const hasGap = className.includes("gap-sp-");
// Should have at least base gap
return hasGap;
}),
{ numRuns: 100 },
);
});
it("should maintain responsive classes across multiple renders", () => {
/**
* **Validates: Requirements 2.6**
*
* Property: For any number of component renders, the Footer should consistently
* maintain its responsive utility classes. This verifies that responsive behavior
* is not accidentally removed during re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
(numRenders) => {
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<Footer />);
// Check grid container
const gridContainer = container.querySelector("footer .grid");
if (!gridContainer) {
allRendersValid = false;
unmount();
continue;
}
const className = gridContainer.className;
// Verify responsive classes are present
const hasResponsiveClasses =
className.includes("grid-cols-1") &&
className.includes("md:grid-cols-") &&
className.includes("lg:grid-cols-");
if (!hasResponsiveClasses) {
allRendersValid = false;
}
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
it("should have responsive column span for copyright section", () => {
/**
* **Validates: Requirements 2.6**
*
* Property: For any Footer component, the copyright section should include
* responsive column span classes (md:col-span-2, lg:col-span-1) to adapt
* its position in the grid layout across different viewport sizes.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find the copyright section (contains "Informacoes legais" heading)
const copyrightSection = Array.from(
container.querySelectorAll("footer h3"),
).find((h3) =>
h3.textContent?.includes("Informacoes legais"),
)?.parentElement;
if (!copyrightSection) {
return false;
}
const className = copyrightSection.className;
// Check for responsive column span classes
const hasMdColSpan = className.includes("md:col-span-");
const hasLgColSpan = className.includes("lg:col-span-");
return hasMdColSpan && hasLgColSpan;
}),
{ numRuns: 100 },
);
});
it("should use consistent responsive breakpoint prefixes", () => {
/**
* **Validates: Requirements 2.6**
*
* Property: For any Footer component, all responsive utility classes should
* use consistent Tailwind breakpoint prefixes (md:, lg:). This ensures the
* component follows Tailwind's responsive design system correctly.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Get all elements with classes in the footer
const allElements = container.querySelectorAll("footer *");
let hasMdPrefix = false;
let hasLgPrefix = false;
let hasInvalidPrefix = false;
allElements.forEach((element) => {
const className = element.className;
if (typeof className === "string") {
// Check for valid responsive prefixes
if (className.includes("md:")) hasMdPrefix = true;
if (className.includes("lg:")) hasLgPrefix = true;
// Check for invalid/inconsistent prefixes (sm: should not be used based on design)
// We're looking for md: and lg: specifically
}
});
// Should have md: and lg: prefixes, no invalid ones
return hasMdPrefix && hasLgPrefix && !hasInvalidPrefix;
}),
{ numRuns: 100 },
);
});
it("should have mobile-first responsive design with base classes", () => {
/**
* **Validates: Requirements 2.6**
*
* Property: For any Footer component, it should follow mobile-first responsive
* design by having base classes for mobile and progressive enhancement with
* responsive variants. This ensures the component works on all devices.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find the grid container
const gridContainer = container.querySelector("footer .grid");
if (!gridContainer) {
return false;
}
const className = gridContainer.className;
// Mobile-first means base class (grid-cols-1) should be present
// Then enhanced with md: and lg: variants
const hasMobileBase = className.includes("grid-cols-1");
const hasTabletEnhancement = className.includes("md:grid-cols-");
const hasDesktopEnhancement = className.includes("lg:grid-cols-");
// Verify progressive enhancement: mobile -> tablet -> desktop
return hasMobileBase && hasTabletEnhancement && hasDesktopEnhancement;
}),
{ numRuns: 100 },
);
});
});
@@ -1,246 +0,0 @@
import { render } from "@testing-library/react";
import Footer from "./Footer";
import fc from "fast-check";
describe("Property 2: Social Links Open in New Tab", () => {
it('should have target="_blank" and rel="noopener noreferrer" for all social links', () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any social media link in the Footer component, it should have
* target="_blank" and rel="noopener noreferrer" attributes to open in a new tab
* securely. This ensures consistent behavior across all social links and prevents
* security vulnerabilities (tabnabbing).
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed, testing static component
() => {
const { container } = render(<Footer />);
// Find all social media links (they are within the "Connect" section)
const socialLinks = container.querySelectorAll(
'a[aria-label*="Visit"]',
);
// Verify we found social links
if (socialLinks.length === 0) {
return false;
}
// Check each social link
let allLinksValid = true;
socialLinks.forEach((link) => {
const target = link.getAttribute("target");
const rel = link.getAttribute("rel");
// Both attributes must be present and correct
if (target !== "_blank" || rel !== "noopener noreferrer") {
allLinksValid = false;
}
});
return allLinksValid;
},
),
{ numRuns: 100 },
);
});
it("should consistently apply security attributes across all social link variations", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any subset of social links rendered, each link should maintain
* the security attributes (target="_blank" and rel="noopener noreferrer").
* This property verifies that the security pattern is consistently applied
* regardless of which social platforms are included.
*/
fc.assert(
fc.property(
fc.integer({ min: 0, max: 10 }), // Random seed for variation
() => {
const { container } = render(<Footer />);
// Get all anchor tags in the footer
const allLinks = container.querySelectorAll("footer a");
// Filter to only social links (those with SVG icons and aria-labels about visiting profiles)
const socialLinks = Array.from(allLinks).filter((link) => {
const ariaLabel = link.getAttribute("aria-label");
const hasSvg = link.querySelector("svg") !== null;
return ariaLabel && ariaLabel.includes("Visit") && hasSvg;
});
// Verify we found social links
if (socialLinks.length === 0) {
return false;
}
// Check that ALL social links have the required attributes
return socialLinks.every((link) => {
const target = link.getAttribute("target");
const rel = link.getAttribute("rel");
return target === "_blank" && rel === "noopener noreferrer";
});
},
),
{ numRuns: 100 },
);
});
it("should not apply new tab attributes to non-social links", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: Only social media links should have target="_blank". Other links
* in the footer (like email) should not force new tab behavior. This ensures
* the new tab behavior is intentionally applied only where appropriate.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Get all anchor tags in the footer
const allLinks = container.querySelectorAll("footer a");
// Separate social links from other links
const socialLinks: Element[] = [];
const otherLinks: Element[] = [];
allLinks.forEach((link) => {
const ariaLabel = link.getAttribute("aria-label");
const hasSvg = link.querySelector("svg") !== null;
if (ariaLabel && ariaLabel.includes("Visit") && hasSvg) {
socialLinks.push(link);
} else {
otherLinks.push(link);
}
});
// All social links should have target="_blank"
const allSocialLinksHaveTarget = socialLinks.every((link) => {
return link.getAttribute("target") === "_blank";
});
// Non-social links (like email) should NOT have target="_blank"
const noOtherLinksHaveTarget = otherLinks.every((link) => {
return link.getAttribute("target") !== "_blank";
});
return allSocialLinksHaveTarget && noOtherLinksHaveTarget;
}),
{ numRuns: 100 },
);
});
it("should maintain security attributes after multiple renders", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any number of component renders, social links should consistently
* maintain their security attributes. This verifies that the attributes are not
* accidentally removed or modified during re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
(numRenders) => {
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<Footer />);
// Find social links
const socialLinks = container.querySelectorAll(
'a[aria-label*="Visit"]',
);
// Check each link
socialLinks.forEach((link) => {
const target = link.getAttribute("target");
const rel = link.getAttribute("rel");
if (target !== "_blank" || rel !== "noopener noreferrer") {
allRendersValid = false;
}
});
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
it("should have both target and rel attributes present together", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any social link, if target="_blank" is present, then
* rel="noopener noreferrer" must also be present. These attributes work
* together to provide secure new tab behavior, and one without the other
* creates a security vulnerability.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all links with target="_blank"
const linksWithTarget = container.querySelectorAll(
'footer a[target="_blank"]',
);
// Verify we found links
if (linksWithTarget.length === 0) {
return false;
}
// Each link with target="_blank" must have rel="noopener noreferrer"
return Array.from(linksWithTarget).every((link) => {
const rel = link.getAttribute("rel");
return rel === "noopener noreferrer";
});
}),
{ numRuns: 100 },
);
});
it("should have exactly the expected number of social links with security attributes", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: The Footer should render a specific number of social links (based on
* the socialLinks array), and all of them should have the security attributes.
* This ensures no links are accidentally missing the attributes.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social links with security attributes
const secureLinks = container.querySelectorAll(
'footer a[target="_blank"][rel="noopener noreferrer"]',
);
// Find all social links (with aria-label containing "Visit")
const allSocialLinks = container.querySelectorAll(
'footer a[aria-label*="Visit"]',
);
// The number of secure links should equal the number of social links
// This ensures ALL social links have the security attributes
return (
secureLinks.length === allSocialLinks.length &&
allSocialLinks.length > 0
);
}),
{ numRuns: 100 },
);
});
});
+48 -299
View File
@@ -1,326 +1,75 @@
import { render, screen } from "@testing-library/react";
import Footer from "./Footer";
describe("Footer Component - Contact Information", () => {
it("should render contact section heading", () => {
describe("Footer contact and navigation", () => {
it("offers a direct mailto link without repeating the contact call to action", () => {
render(<Footer />);
const contactHeading = screen.getByText("Contato direto");
expect(contactHeading).toBeInTheDocument();
expect(contactHeading.tagName).toBe("H3");
});
// o convite principal fica na secao Contato, logo acima do rodape
expect(
screen.queryByRole("link", { name: "Conversar sobre meu projeto" }),
).not.toBeInTheDocument();
it("should render email address", () => {
render(<Footer />);
const emailLink = screen.getByText("[email protected]");
expect(emailLink).toBeInTheDocument();
expect(emailLink.tagName).toBe("A");
});
it("should have mailto link for email", () => {
render(<Footer />);
const emailLink = screen.getByText("[email protected]");
const emailLink = screen.getByRole("link", {
name: "[email protected]",
});
expect(emailLink).toHaveAttribute(
"href",
"mailto:[email protected]",
);
expect(emailLink).not.toHaveAttribute("target", "_blank");
});
it("should apply correct styling classes to email link", () => {
it("renders quick links as same-tab navigation", () => {
render(<Footer />);
const emailLink = screen.getByText("[email protected]");
const classes = emailLink.className;
const servicesLink = screen.getByRole("link", { name: "Hub de serviços" });
expect(servicesLink).toHaveAttribute("href", "/servicos");
expect(servicesLink).not.toHaveAttribute("target", "_blank");
expect(screen.getByRole("link", { name: "Privacidade" })).toHaveAttribute(
"href",
"/privacidade",
);
});
expect(classes).toContain("text-[12px]");
expect(classes).toContain("hover:text-[var(--slash)]");
it("keeps the résumé page and PDF download as the final secondary links", () => {
render(<Footer />);
const résuméLink = screen.getByRole("link", { name: "Currículo" });
const downloadLink = screen.getByRole("link", {
name: "Baixar currículo (PDF)",
});
expect(résuméLink).toHaveAttribute("href", "/curriculo");
expect(downloadLink).toHaveAttribute("href", "/Profile.pdf");
expect(downloadLink).toHaveAttribute(
"download",
"Evandro_Fonseca_Junior_CV.pdf",
);
expect(screen.getAllByRole("link").slice(-2)).toEqual([
résuméLink,
downloadLink,
]);
});
});
describe("Footer Component - Social Links", () => {
it("should render social links section heading", () => {
describe("Footer social links", () => {
it("opens social links securely in a new tab", () => {
render(<Footer />);
const connectHeading = screen.getByText("Redes profissionais");
expect(connectHeading).toBeInTheDocument();
expect(connectHeading.tagName).toBe("H3");
});
const socialLinks = screen.getAllByRole("link", { name: /profile/i });
it("should render GitHub social link", () => {
render(<Footer />);
for (const link of socialLinks) {
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(link.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
}
const githubLink = screen.getByLabelText("Visit GitHub profile");
expect(githubLink).toBeInTheDocument();
expect(githubLink.tagName).toBe("A");
});
it("should render LinkedIn social link", () => {
render(<Footer />);
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
expect(linkedinLink).toBeInTheDocument();
expect(linkedinLink.tagName).toBe("A");
});
it("should have correct href for GitHub link", () => {
render(<Footer />);
const githubLink = screen.getByLabelText("Visit GitHub profile");
expect(githubLink).toHaveAttribute(
expect(screen.getByLabelText("Visit GitHub profile")).toHaveAttribute(
"href",
"https://github.com/evandrodevbr",
);
});
it("should have correct href for LinkedIn link", () => {
render(<Footer />);
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
expect(linkedinLink).toHaveAttribute(
"href",
"https://linkedin.com/in/evandrodevbr",
);
});
it('should open social links in new tab with target="_blank"', () => {
render(<Footer />);
const githubLink = screen.getByLabelText("Visit GitHub profile");
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
expect(githubLink).toHaveAttribute("target", "_blank");
expect(linkedinLink).toHaveAttribute("target", "_blank");
});
it('should have rel="noopener noreferrer" for security', () => {
render(<Footer />);
const githubLink = screen.getByLabelText("Visit GitHub profile");
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
expect(githubLink).toHaveAttribute("rel", "noopener noreferrer");
expect(linkedinLink).toHaveAttribute("rel", "noopener noreferrer");
});
it("should have aria-label for accessibility", () => {
render(<Footer />);
const githubLink = screen.getByLabelText("Visit GitHub profile");
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
expect(githubLink).toHaveAttribute("aria-label", "Visit GitHub profile");
expect(linkedinLink).toHaveAttribute(
"aria-label",
"Visit LinkedIn profile",
);
});
it("should render SVG icons for social links", () => {
render(<Footer />);
const githubLink = screen.getByLabelText("Visit GitHub profile");
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
const githubSvg = githubLink.querySelector("svg");
const linkedinSvg = linkedinLink.querySelector("svg");
expect(githubSvg).toBeInTheDocument();
expect(linkedinSvg).toBeInTheDocument();
});
it('should have aria-hidden="true" on SVG icons', () => {
render(<Footer />);
const githubLink = screen.getByLabelText("Visit GitHub profile");
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
const githubSvg = githubLink.querySelector("svg");
const linkedinSvg = linkedinLink.querySelector("svg");
expect(githubSvg).toHaveAttribute("aria-hidden", "true");
expect(linkedinSvg).toHaveAttribute("aria-hidden", "true");
});
});
describe("Footer Component - Copyright", () => {
it("should render copyright section heading", () => {
render(<Footer />);
const copyrightHeading = screen.getByText("Informacoes legais");
expect(copyrightHeading).toBeInTheDocument();
expect(copyrightHeading.tagName).toBe("H3");
});
it("should display current year in copyright text", () => {
render(<Footer />);
const currentYear = new Date().getFullYear();
const copyrightText = screen.getByText(
new RegExp(`© ${currentYear} Evandro Fonseca Junior`),
);
expect(copyrightText).toBeInTheDocument();
});
it("should update copyright year dynamically", () => {
// Test that the component uses new Date().getFullYear() dynamically
// by verifying it shows the current year (not a hardcoded value)
const currentYear = new Date().getFullYear();
render(<Footer />);
// Verify the copyright includes the current year
const copyrightText = screen.getByText(
new RegExp(`© ${currentYear} Evandro Fonseca Junior`),
);
expect(copyrightText).toBeInTheDocument();
// Verify it's not hardcoded to a specific year like 2023 or 2024
// by checking that the year matches the actual current year
expect(currentYear).toBeGreaterThanOrEqual(2024);
});
it("should apply correct styling to copyright text", () => {
render(<Footer />);
const currentYear = new Date().getFullYear();
const copyrightText = screen.getByText(
new RegExp(`© ${currentYear} Evandro Fonseca Junior`),
);
const classes = copyrightText.className;
expect(classes).toContain("text-[12px]");
expect(classes).toContain("text-[rgba(255,255,255,0.5)]");
});
});
describe("Footer Component - Responsive Layout", () => {
beforeEach(() => {
// Mock window.matchMedia for responsive tests
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});
it("should have responsive grid layout classes", () => {
const { container } = render(<Footer />);
const gridContainer = container.querySelector(".grid");
expect(gridContainer).toBeInTheDocument();
const classes = gridContainer?.className || "";
expect(classes).toContain("grid-cols-1");
expect(classes).toContain("md:grid-cols-2");
expect(classes).toContain("lg:grid-cols-3");
});
it("should have responsive padding classes on container", () => {
const { container } = render(<Footer />);
const innerContainer = container.querySelector(".ds-section-shell");
expect(innerContainer).toBeInTheDocument();
// The ds-section class handles responsive padding via CSS
const footer = container.querySelector("footer");
const classes = footer?.className || "";
expect(classes).toContain("ds-section");
});
it("should have responsive gap classes on grid", () => {
const { container } = render(<Footer />);
const gridContainer = container.querySelector(".grid");
const classes = gridContainer?.className || "";
expect(classes).toContain("gap-sp-6");
});
it("should have responsive column span for copyright section", () => {
const { container } = render(<Footer />);
// Find the copyright section by its heading
const copyrightHeading = screen.getByText("Informacoes legais");
const copyrightSection = copyrightHeading.closest(".ds-card");
expect(copyrightSection).toBeInTheDocument();
const classes = copyrightSection?.className || "";
expect(classes).toContain("md:col-span-2");
expect(classes).toContain("lg:col-span-1");
});
});
describe("Footer Component - Design System Consistency", () => {
it("should use design system kicker class for section labels", () => {
render(<Footer />);
const kickers = document.querySelectorAll(".ds-kicker");
expect(kickers.length).toBeGreaterThan(0);
});
it("should have ds-section class on footer element", () => {
const { container } = render(<Footer />);
const footer = container.querySelector("footer");
expect(footer).toBeInTheDocument();
const classes = footer?.className || "";
expect(classes).toContain("ds-section");
});
it("should have ds-section-dark class for dark background", () => {
const { container } = render(<Footer />);
const footer = container.querySelector("footer");
const classes = footer?.className || "";
expect(classes).toContain("ds-section-dark");
});
it("should use design system shell class for container", () => {
const { container } = render(<Footer />);
const shell = container.querySelector(".ds-section-shell");
expect(shell).toBeInTheDocument();
});
});
describe("Footer Component - Structure", () => {
it("should render as footer semantic element", () => {
const { container } = render(<Footer />);
const footer = container.querySelector("footer");
expect(footer).toBeInTheDocument();
});
it("should have three main sections", () => {
render(<Footer />);
const contactHeading = screen.getByText("Contato direto");
const connectHeading = screen.getByText("Redes profissionais");
const copyrightHeading = screen.getByText("Informacoes legais");
expect(contactHeading).toBeInTheDocument();
expect(connectHeading).toBeInTheDocument();
expect(copyrightHeading).toBeInTheDocument();
});
it("should have design system shell container", () => {
const { container } = render(<Footer />);
const innerContainer = container.querySelector(".ds-section-shell");
expect(innerContainer).toBeInTheDocument();
expect(
screen.getByLabelText("Visit LinkedIn profile").getAttribute("href"),
).toContain("https://www.linkedin.com/in/evandrodevbr/");
});
});
+38 -18
View File
@@ -16,27 +16,30 @@ const socialLinks: SocialLink[] = [
name: "LinkedIn",
href: "https://www.linkedin.com/in/evandrodevbr/?skipRedirect=true",
icon: "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",
ariaLabel: "Connect on LinkedIn",
ariaLabel: "Visit LinkedIn profile",
},
{
name: "Instagram",
href: "https://www.instagram.com/evandrodevbr/",
icon: "M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.85.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.85-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z",
ariaLabel: "Follow on Instagram",
ariaLabel: "Visit Instagram profile",
},
];
const quickLinks = [
{ label: "Hub de servicos", href: "/servicos" },
{ label: "Hub de serviços", href: "/servicos" },
{ label: "Cases", href: "/cases" },
{ label: "Privacidade", href: "/privacidade" },
{ label: "Consultoria Next.js", href: "/servicos/consultoria-nextjs" },
{
label: "Integracao hardware + software",
label: "Integração hardware + software",
href: "/servicos/integracao-hardware-software-iot",
},
];
import { CV_DOWNLOAD_NAME, CV_URL } from "@/lib/site-navigation";
import MagneticButton from "./cinematic/MagneticButton";
import { Logo } from "./Logo";
export default function Footer() {
const currentYear = new Date().getFullYear();
@@ -49,23 +52,14 @@ export default function Footer() {
<div>
<div className="ds-kicker">Contato</div>
<span className="ds-draw-line" />
<h3 className="ds-card-title">
Engenharia com menos ruido e mais clareza de execucao
</h3>
<p className="ds-copy max-w-[48ch]">
Se voce precisa de alguem para conectar produto, integracao e
entrega com mais criterio tecnico, este portfolio e o ponto de
partida.
</p>
<div className="mt-sp-6">
<div className="mt-sp-5">
<p className="mb-2 text-[12px] tracking-[0.02em] text-[var(--meta)]">
E-mail direto
</p>
<MagneticButton
as="a"
href="mailto:[email protected]"
className="inline-block text-[18px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)]"
className="inline-flex min-h-11 items-center break-all text-[16px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)] sm:text-[18px]"
strength={0.2}
>
evandrofonsecajunior@gmail.com
@@ -74,19 +68,27 @@ export default function Footer() {
</div>
<div className="mt-sp-8">
<a
href="/#home"
aria-label="Evandro.dev.br — início"
className="mb-sp-6 inline-flex min-h-11 max-w-full items-center"
>
<Logo theme="light" className="w-60" />
</a>
<div className="ds-metric">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-slash" />
Garuva, SC | Remote worldwide
</div>
<p className="mt-sp-4 text-[12px] text-[var(--meta)]">
Copyright {currentYear} Evandro Fonseca Junior. All rights reserved.
Copyright {currentYear} Evandro Fonseca Junior. All rights
reserved.
</p>
</div>
</div>
<div className="ds-card flex flex-col justify-between">
<div>
<div className="ds-kicker">Presenca digital</div>
<div className="ds-kicker">Presença digital</div>
<span className="ds-draw-line" />
<h3 className="ds-card-title">Canais profissionais</h3>
@@ -141,9 +143,27 @@ export default function Footer() {
</div>
</div>
<div className="mt-sp-8 ds-metric">Open to collaboration</div>
<div className="mt-sp-8 ds-metric">
Software sob medida, contato direto
</div>
</div>
</div>
<div className="mt-sp-8 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-2 border-t border-[var(--border)] pt-sp-4 text-[12px] text-[var(--meta)]">
<span>Trajetória profissional</span>
<a
href="/curriculo"
className="inline-flex min-h-11 items-center underline-offset-4 hover:text-[var(--ink)] hover:underline"
>
Currículo
</a>
<a
href={CV_URL}
download={CV_DOWNLOAD_NAME}
className="inline-flex min-h-11 items-center underline-offset-4 hover:text-[var(--ink)] hover:underline"
>
Baixar currículo (PDF)
</a>
</div>
</div>
</footer>
);
+284
View File
@@ -0,0 +1,284 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import GoogleAnalytics from "./GoogleAnalytics";
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
const GA_SRC = "https://www.googletagmanager.com/gtag/js?id=G-QTT9QT0T3S";
type AnalyticsWindow = Window & {
dataLayer?: IArguments[];
gtag?: (...args: unknown[]) => void;
[GA_DISABLE_KEY]?: boolean;
};
const analyticsWindow = window as AnalyticsWindow;
function consentUpdates(gtag: jest.Mock) {
return gtag.mock.calls.filter(
([command, action]) => command === "consent" && action === "update",
);
}
function setCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
document.cookie = value;
}
describe("GoogleAnalytics consent", () => {
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
document.getElementById("google-analytics-script")?.remove();
localStorage.clear();
setCookie("_ga=; Max-Age=0; Path=/");
setCookie("_ga_PORTFOLIO=; Max-Age=0; Path=/");
setCookie("portfolio_session=; Max-Age=0; Path=/");
analyticsWindow.dataLayer = undefined;
analyticsWindow.gtag = undefined;
analyticsWindow[GA_DISABLE_KEY] = undefined;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("waits for a decision before loading analytics", async () => {
render(<GoogleAnalytics />);
expect(
await screen.findByRole("button", { name: "Aceitar analytics" }),
).toBeVisible();
expect(screen.getByRole("dialog")).toHaveAccessibleDescription();
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ler política de privacidade" }),
).toHaveAttribute("href", "/privacidade");
});
it("persists acceptance and enables analytics without advertising consent", async () => {
const user = userEvent.setup();
render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Aceitar analytics" }),
);
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toHaveAttribute(
"src",
GA_SRC,
);
const commands = analyticsWindow.dataLayer?.map((entry) =>
Array.from(entry),
);
expect(commands).toContainEqual([
"consent",
"default",
{
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(commands).toContainEqual([
"consent",
"update",
{
analytics_storage: "granted",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(commands).toContainEqual([
"config",
"G-QTT9QT0T3S",
{
allow_ad_personalization_signals: false,
anonymize_ip: true,
},
]);
expect(commands).toContainEqual([
"config",
"AW-18432694647",
{ allow_ad_personalization_signals: false },
]);
expect(commands).toContainEqual(["set", "ads_data_redaction", true]);
expect(
screen.getByRole("button", { name: "Preferências de analytics" }),
).not.toHaveFocus();
});
it("persists refusal and keeps analytics absent after reload", async () => {
const user = userEvent.setup();
const firstRender = render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Recusar analytics" }),
);
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
firstRender.unmount();
render(<GoogleAnalytics />);
expect(
await screen.findByRole("button", { name: "Preferências de analytics" }),
).toBeVisible();
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
});
it("keeps preferences reachable and fully revokes analytics only", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
setCookie("_ga=main; Path=/");
setCookie("_ga_PORTFOLIO=property; Path=/");
setCookie("portfolio_session=keep; Path=/");
const gtag = jest.fn();
analyticsWindow.gtag = gtag;
render(<GoogleAnalytics />);
await screen.findByTestId("google-analytics-script");
await user.click(
screen.getByRole("button", { name: "Preferências de analytics" }),
);
await user.click(screen.getByRole("button", { name: "Revogar analytics" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
expect(consentUpdates(gtag)).toContainEqual([
"consent",
"update",
{
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(document.cookie).not.toContain("_ga=");
expect(document.cookie).not.toContain("_ga_PORTFOLIO=");
expect(document.cookie).toContain("portfolio_session=keep");
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
const preferencesButton = screen.getByRole("button", {
name: "Preferências de analytics",
});
expect(preferencesButton).toBeVisible();
expect(preferencesButton).toHaveFocus();
});
it("restores focus after closing or applying opened preferences", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "denied");
render(<GoogleAnalytics />);
const preferencesButton = await screen.findByRole("button", {
name: "Preferências de analytics",
});
await user.click(preferencesButton);
expect(
screen.getByRole("link", { name: "Ler política de privacidade" }),
).toHaveAttribute("href", "/privacidade");
await user.click(
screen.getByRole("button", { name: "Fechar preferências" }),
);
expect(preferencesButton).toHaveFocus();
await user.click(preferencesButton);
await user.click(screen.getByRole("button", { name: "Aceitar analytics" }));
expect(preferencesButton).toHaveFocus();
});
it("fails closed when local storage is unavailable", async () => {
const user = userEvent.setup();
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("storage blocked");
});
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Aceitar analytics" }),
);
await waitFor(() => {
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
});
});
it("reports when revocation cannot be persisted", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await screen.findByTestId("google-analytics-script");
await user.click(
screen.getByRole("button", { name: "Preferências de analytics" }),
);
await user.click(screen.getByRole("button", { name: "Revogar analytics" }));
expect(
screen.getByRole("alert", {
name: "Falha ao salvar o consentimento",
}),
).toHaveTextContent(
"Não foi possível salvar a revogação. Tente novamente para concluir.",
);
expect(analyticsWindow[GA_DISABLE_KEY]).not.toBe(true);
expect(
screen.getByRole("button", { name: "Revogar analytics" }),
).toBeVisible();
});
it("reports when refusal cannot be persisted", async () => {
const user = userEvent.setup();
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Recusar analytics" }),
);
expect(
screen.getByRole("alert", {
name: "Falha ao salvar o consentimento",
}),
).toHaveTextContent(
"Não foi possível salvar sua recusa. Tente novamente para concluir.",
);
expect(
screen.getByRole("button", { name: "Recusar analytics" }),
).toBeVisible();
expect(
screen.queryByRole("button", { name: "Preferências de analytics" }),
).not.toBeInTheDocument();
});
});
+314 -21
View File
@@ -1,33 +1,326 @@
"use client";
import Script from "next/script";
import { useCallback, useEffect, useRef, useState } from "react";
import {
type AnalyticsConsent,
CONSENT_STORAGE_KEY,
GA_DISABLE_KEY,
GA_MEASUREMENT_ID,
type Gtag,
readConsent,
} from "@/lib/analytics";
const GA_MEASUREMENT_ID = "G-QTT9QT0T3S";
const GOOGLE_ADS_ID = "AW-18432694647";
const GA_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
const SITE_DOMAIN = "evandro.dev.br";
export default function GoogleAnalytics() {
if (process.env.NODE_ENV !== "production") {
return null;
const deniedConsent = {
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
} as const;
function persistConsent(value: AnalyticsConsent): boolean {
try {
localStorage.setItem(CONSENT_STORAGE_KEY, value);
return true;
} catch {
return false;
}
}
function getGtag(): Gtag {
window.dataLayer ??= [];
const dataLayer = window.dataLayer;
if (!window.gtag) {
window.gtag = function gtag() {
// biome-ignore lint/complexity/noArguments: The official gtag bootstrap requires an IArguments entry, not an Array.
dataLayer.push(arguments);
};
}
return window.gtag;
}
function removeAnalyticsLoader() {
document.getElementById("google-analytics-script")?.remove();
}
function writeCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store is not universal and domain-scoped GA cookies must also be expired.
document.cookie = value;
}
function expireAnalyticsCookies() {
const cookieNames = new Set(
document.cookie
.split(";")
.map((cookie) => cookie.split("=")[0]?.trim())
.filter(
(name): name is string =>
name === "_ga" || Boolean(name?.startsWith("_ga_")),
),
);
const { hostname } = window.location;
const domains =
hostname === SITE_DOMAIN || hostname.endsWith(`.${SITE_DOMAIN}`)
? new Set([hostname, SITE_DOMAIN])
: new Set<string>();
for (const name of cookieNames) {
const expiration = `${name}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax`;
writeCookie(expiration);
for (const domain of domains) {
writeCookie(`${expiration}; Domain=${domain}`);
}
}
}
const panelClassName =
"fixed bottom-sp-4 left-sp-4 right-sp-4 z-[9980] ml-auto max-w-[560px] border border-[var(--slash)] bg-[var(--ink)] p-sp-5 text-white shadow-[8px_8px_0_var(--slash)] sm:left-auto";
const buttonClassName =
"inline-flex min-h-11 min-w-11 items-center justify-center border px-sp-4 py-sp-2 font-mono text-[11px] font-bold uppercase tracking-[0.08em] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
function GoogleAnalyticsConsent() {
const [consent, setConsent] = useState<AnalyticsConsent | null>();
const [preferencesOpen, setPreferencesOpen] = useState(false);
const [consentError, setConsentError] = useState<string | null>(null);
const configuredRef = useRef(false);
const preferencesButtonRef = useRef<HTMLButtonElement>(null);
const enableAnalytics = useCallback(() => {
const gtag = getGtag();
window[GA_DISABLE_KEY] = false;
if (!configuredRef.current) {
gtag("consent", "default", deniedConsent);
gtag("set", "ads_data_redaction", true);
gtag("js", new Date());
configuredRef.current = true;
}
gtag("consent", "update", {
...deniedConsent,
analytics_storage: "granted",
});
gtag("config", GA_MEASUREMENT_ID, {
allow_ad_personalization_signals: false,
anonymize_ip: true,
});
// One loader serves both destinations; GT IDs are aliases, not extra installs.
gtag("config", GOOGLE_ADS_ID, {
allow_ad_personalization_signals: false,
});
}, []);
useEffect(() => {
const storedConsent = readConsent();
if (storedConsent === "granted") enableAnalytics();
setConsent(storedConsent);
}, [enableAnalytics]);
useEffect(() => {
if (consent !== "granted" && consent !== undefined) {
removeAnalyticsLoader();
}
}, [consent]);
function closePreferences() {
setPreferencesOpen(false);
preferencesButtonRef.current?.focus();
}
function acceptAnalytics() {
if (!persistConsent("granted")) {
setConsentError(
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
enableAnalytics();
setConsent("granted");
closePreferences();
}
function denyAnalytics() {
if (!persistConsent("denied")) {
setConsentError(
"Não foi possível salvar sua recusa. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
setConsent("denied");
closePreferences();
}
function revokeAnalytics() {
if (!persistConsent("denied")) {
setConsentError(
"Não foi possível salvar a revogação. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
window[GA_DISABLE_KEY] = true;
window.gtag?.("consent", "update", deniedConsent);
expireAnalyticsCookies();
setConsent("denied");
closePreferences();
}
if (consent === undefined) return null;
if (consent === null) {
return (
<section
role="dialog"
aria-modal="false"
aria-labelledby="analytics-consent-title"
aria-describedby="analytics-consent-description"
className={panelClassName}
>
<p
id="analytics-consent-title"
className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--slash)]"
>
{"Privacidade // Analytics"}
</p>
<p
id="analytics-consent-description"
className="mt-sp-2 max-w-[48ch] text-[13px] leading-6 text-white/85"
>
Você decide se permite métricas de navegação com a tag compartilhada
do Google Analytics e Google Ads. Cookies de publicidade e
personalização continuam desativados.
</p>
{consentError && (
<p
role="alert"
aria-label="Falha ao salvar o consentimento"
className="mt-sp-3 text-[13px] leading-6 text-[var(--slash)]"
>
{consentError}
</p>
)}
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
<button
type="button"
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
onClick={acceptAnalytics}
>
Aceitar analytics
</button>
<button
type="button"
className={`${buttonClassName} border-white/40 bg-transparent text-white`}
onClick={denyAnalytics}
>
Recusar analytics
</button>
</div>
<a
className="ds-link ds-link-accent mt-sp-3 inline-block"
href="/privacidade"
>
Ler política de privacidade
</a>
</section>
);
}
return (
<>
<Script src="/sys/libs/vendor-utils.js" strategy="lazyOnload" />
<Script id="google-analytics" strategy="lazyOnload">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
transport_url: window.location.origin + '/sys/api/vitals',
cookie_flags: 'SameSite=None;Secure',
allow_ad_personalization_signals: false,
anonymize_ip: true,
page_location: window.location.href,
page_title: document.title
});
`}
</Script>
{consent === "granted" && (
<Script
id="google-analytics-script"
data-testid="google-analytics-script"
src={GA_SCRIPT_SRC}
strategy="afterInteractive"
/>
)}
<button
type="button"
ref={preferencesButtonRef}
aria-expanded={preferencesOpen}
aria-controls="analytics-preferences"
className={`${buttonClassName} fixed bottom-sp-4 right-sp-4 z-[9980] border-white/25 bg-[var(--ink)] text-white shadow-[4px_4px_0_var(--slash)]`}
onClick={() => setPreferencesOpen((open) => !open)}
>
<span className="max-sm:hidden">Preferências de analytics</span>
<span aria-hidden="true" className="sm:hidden">
Cookies
</span>
</button>
{preferencesOpen && (
<section
id="analytics-preferences"
role="dialog"
aria-modal="false"
aria-labelledby="analytics-preferences-title"
aria-describedby="analytics-preferences-description"
className={panelClassName}
>
<p
id="analytics-preferences-title"
className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--slash)]"
>
{"Preferências // Analytics"}
</p>
<p
id="analytics-preferences-description"
className="mt-sp-2 text-[13px] leading-6 text-white/85"
>
Analytics está {consent === "granted" ? "ativo" : "desativado"}.
Cookies de publicidade e personalização permanecem negados.
</p>
{consentError && (
<p
role="alert"
aria-label="Falha ao salvar o consentimento"
className="mt-sp-3 text-[13px] leading-6 text-[var(--slash)]"
>
{consentError}
</p>
)}
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
{consent === "granted" ? (
<button
type="button"
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
onClick={revokeAnalytics}
>
Revogar analytics
</button>
) : (
<button
type="button"
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
onClick={acceptAnalytics}
>
Aceitar analytics
</button>
)}
<button
type="button"
className={`${buttonClassName} border-white/40 bg-transparent text-white`}
onClick={closePreferences}
>
Fechar preferências
</button>
</div>
<a
className="ds-link ds-link-accent mt-sp-3 inline-block"
href="/privacidade"
>
Ler política de privacidade
</a>
</section>
)}
</>
);
}
export default function GoogleAnalytics() {
if (process.env.NODE_ENV !== "production") return null;
return <GoogleAnalyticsConsent />;
}
+131
View File
@@ -0,0 +1,131 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { useEffect } from "react";
import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation";
import { Header } from "./Header";
function HomeWithHeader() {
const onLayoutReady = useHomeFragmentNavigation();
useEffect(() => {
onLayoutReady();
}, [onLayoutReady]);
return (
<>
<Header />
<section id="about" aria-label="Sobre">
<h2>Sobre</h2>
</section>
<section id="contact" aria-label="Contato">
<h2>Contato</h2>
</section>
</>
);
}
describe("Header", () => {
it("links to commercial destinations without promoting the résumé", () => {
render(<Header />);
const navigation = screen.getByRole("navigation", {
name: "Navegação principal",
});
expect(
within(navigation)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual([
"/#modalities",
"/cases",
"/#process",
"/#about",
"/#experience",
]);
const contactLink = screen
.getAllByRole("link")
.find((link) => link.getAttribute("href") === "/#contact");
expect(contactLink).toHaveAccessibleName();
expect(
screen.queryByRole("link", { name: /currículo|download cv/i }),
).not.toBeInTheDocument();
});
it("announces and controls the mobile menu", () => {
render(<Header />);
const menuButton = screen.getByRole("button", { name: "Abrir menu" });
expect(menuButton).not.toHaveAttribute("aria-controls");
expect(menuButton).toHaveAttribute("aria-expanded", "false");
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument();
fireEvent.click(menuButton);
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
"aria-expanded",
"true",
);
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
expect(
document.getElementById(menuButton.getAttribute("aria-controls") ?? ""),
).toBe(mobileNavigation);
expect(
within(mobileNavigation)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual([
"/#modalities",
"/cases",
"/#process",
"/#about",
"/#experience",
"/#contact",
]);
expect(
within(mobileNavigation).queryByRole("link", {
name: /currículo|download cv/i,
}),
).not.toBeInTheDocument();
const mobileLink = within(mobileNavigation).getByRole("link", {
name: "Sobre",
});
mobileLink.focus();
fireEvent.keyDown(mobileLink, { key: "Escape" });
const openButton = screen.getByRole("button", { name: "Abrir menu" });
expect(openButton).toHaveAttribute("aria-expanded", "false");
expect(document.activeElement).toBe(openButton);
});
it.each([
["/#about", "Sobre"],
["/#contact", "Contato"],
])(
"closes the mobile menu and preserves destination focus for %s",
(href, destination) => {
window.history.replaceState(null, "", "/");
render(<HomeWithHeader />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
const mobileLink = within(mobileNavigation)
.getAllByRole("link")
.find((link) => link.getAttribute("href") === href);
if (!mobileLink)
throw new Error(`Missing navigation destination: ${href}`);
mobileLink.focus();
fireEvent.click(mobileLink);
expect(
screen.getByRole("button", { name: "Abrir menu" }),
).toHaveAttribute("aria-expanded", "false");
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: destination })).toHaveFocus();
},
);
});
+101 -69
View File
@@ -1,68 +1,94 @@
"use client";
import { useState } from "react";
import { Logo } from "./Logo";
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
import MagneticButton from "./cinematic/MagneticButton";
import { Logo } from "./Logo";
const navItems = [
{ href: "#home", label: "Home" },
{ href: "#about", label: "Sobre" },
{ href: "#skills", label: "Habilidades" },
{ href: "#experience", label: "Experiencia" },
{ href: "#projects", label: "Projetos" },
{ href: "#modalities", label: "Contratacao" },
{ href: "#contact", label: "Contato" },
];
const MENU_ID = "site-navigation-menu";
export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
let wasScrolled = false;
const updateScrolled = () => {
const nextScrolled = window.scrollY > 0;
if (nextScrolled === wasScrolled) return;
wasScrolled = nextScrolled;
setScrolled(nextScrolled);
};
updateScrolled();
window.addEventListener("scroll", updateScrolled, { passive: true });
return () => window.removeEventListener("scroll", updateScrolled);
}, []);
const closeMobileMenu = () => setMobileMenuOpen(false);
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeMobileMenu();
menuButtonRef.current?.focus();
};
return (
<header className="ds-section-dark fixed inset-x-0 top-0 z-50 h-[52px] w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/95 px-sp-6 backdrop-blur-xl lg:px-sp-16">
<div className="ds-section-shell flex h-full items-center justify-between">
<a href="#home" className="flex items-center gap-3 no-underline">
<Logo variant="mark" size="sm" theme="dark" className="shrink-0" />
<span className="font-display text-sm font-extrabold tracking-tight text-white">
evandro<span className="text-[var(--slash)]">.dev.br</span>
</span>
<header
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
scrolled
? "border-white/12 bg-[var(--ink)]/95 backdrop-blur-md"
: "border-transparent bg-[var(--ink)]"
}`}
>
<div className="ds-section-shell flex h-full items-center justify-between gap-sp-6">
<a
href="/#home"
aria-label="Evandro.dev.br — início"
className="flex min-h-11 shrink-0 items-center no-underline"
>
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
</a>
<nav className="hidden items-center lg:flex">
<ul className="flex list-none items-center gap-sp-2">
{navItems.map((item) => (
<nav
aria-label="Navegação principal"
className="hidden items-center xl:flex"
>
<ul className="flex list-none items-center gap-sp-4">
{NAVIGATION_ITEMS.map((item) => (
<li key={item.href}>
<MagneticButton
as="a"
<a
href={item.href}
className="ds-link rounded-sm px-sp-3 py-sp-2 text-white/85 transition-colors hover:bg-white/10 hover:text-white"
strength={0.15}
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
>
{item.label}
</MagneticButton>
</a>
</li>
))}
</ul>
</nav>
<div className="flex items-center gap-sp-4">
<span className="ds-metric hidden rounded-full border border-white/10 px-[10px] py-1 text-white/40 lg:inline-flex">
v1.0 / 2026
</span>
<MagneticButton
as="a"
href="/Profile.pdf"
download="Evandro_Fonseca_Junior_CV.pdf"
className="ds-link ds-link-accent hidden rounded-sm font-semibold transition-shadow hover:shadow-[0_0_0_1px_rgba(232,255,0,.8),0_8px_24px_rgba(232,255,0,.35)] lg:inline-flex"
strength={0.2}
>
Download CV
</MagneticButton>
<div className="flex shrink-0 items-center">
<div className="hidden xl:block">
<a
href="/#contact"
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
>
Falar sobre meu projeto
</a>
</div>
<button
ref={menuButtonRef}
type="button"
className="flex h-8 w-8 flex-col items-center justify-center gap-sp-2 border-none bg-transparent lg:hidden"
className="flex h-11 w-11 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] xl:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Menu"
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
aria-expanded={mobileMenuOpen}
onKeyDown={handleMenuKeyDown}
>
<span
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[6px] rotate-45" : ""}`}
@@ -77,40 +103,46 @@ export function Header() {
</div>
</div>
<div
className={`absolute inset-x-0 top-[52px] overflow-hidden border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/98 px-sp-6 backdrop-blur-xl transition-all duration-300 lg:hidden lg:px-sp-16 ${mobileMenuOpen ? "max-h-[420px] opacity-100" : "max-h-0 opacity-0"}`}
>
<div className="ds-section-shell flex flex-col py-sp-6">
<nav className="mb-sp-6">
<ul className="flex list-none flex-col gap-sp-2">
{navItems.map((item) => (
<li key={item.href}>
{mobileMenuOpen ? (
<div className="absolute inset-x-0 top-[var(--site-header-height)] max-h-[calc(100dvh-var(--site-header-height))] overflow-y-auto border-b border-white/12 bg-[var(--ink)] px-sp-6 lg:px-sp-8 xl:hidden">
<div className="ds-section-shell flex flex-col py-sp-6">
<nav
id={MENU_ID}
aria-hidden={!mobileMenuOpen}
aria-label="Navegação mobile"
onKeyDown={handleMenuKeyDown}
>
<ul className="flex list-none flex-col gap-sp-2">
{NAVIGATION_ITEMS.map((item) => (
<li key={item.href}>
<MagneticButton
as="a"
strength={0}
wrapperClassName="w-full"
href={item.href}
className="flex min-h-11 items-center rounded-sm px-sp-3 py-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
onClick={closeMobileMenu}
>
{item.label}
</MagneticButton>
</li>
))}
<li>
<MagneticButton
as="a"
href={item.href}
className="ds-link block rounded-sm py-sp-3 text-white/85 transition-all hover:pl-sp-2 hover:text-white"
onClick={() => setMobileMenuOpen(false)}
strength={0.15}
strength={0}
href="/#contact"
className="mt-sp-4 inline-flex min-h-11 items-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
onClick={closeMobileMenu}
>
{item.label}
Falar sobre meu projeto
</MagneticButton>
</li>
))}
<li>
<MagneticButton
as="a"
href="/Profile.pdf"
download="Evandro_Fonseca_Junior_CV.pdf"
className="ds-link ds-link-accent mt-sp-2 inline-flex rounded-sm font-semibold"
strength={0.15}
>
Download CV
</MagneticButton>
</li>
</ul>
</nav>
</ul>
</nav>
</div>
</div>
</div>
) : null}
</header>
);
}
+41 -110
View File
@@ -1,121 +1,52 @@
import { render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { act } from "@testing-library/react";
import { useReducedMotion } from "motion/react";
import { hydrateRoot, type Root } from "react-dom/client";
import { renderToString } from "react-dom/server";
import Hero from "./Hero";
jest.mock("next/dynamic", () => () => {
const DynamicComponent = ({ variant }: { variant?: string }) => (
<div data-testid="hero-scene" data-variant={variant} />
);
DynamicComponent.displayName = "MockHeroScene";
return DynamicComponent;
jest.mock("motion/react", () => ({
...jest.requireActual("motion/react"),
useReducedMotion: jest.fn(),
}));
afterEach(() => {
jest.restoreAllMocks();
});
jest.mock("framer-motion", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
initial,
animate,
whileHover: _whileHover,
whileTap: _whileTap,
variants: _variants,
transition: _transition,
...props
}: {
children?: ReactNode;
initial?: unknown;
animate?: unknown;
} & Record<string, unknown>) => {
const dataProps = {
...(initial !== undefined
? { "data-initial": JSON.stringify(initial) }
: {}),
...(animate !== undefined
? { "data-animate": JSON.stringify(animate) }
: {}),
};
return React.createElement(tag, { ...props, ...dataProps }, children);
};
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return {
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
motion,
useInView: () => true,
useReducedMotion: () => true,
};
});
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches,
media: query,
it.each([false, true])(
"hydrates without attribute mismatches when reduced motion resolves to %s",
async (reducedMotion) => {
jest.spyOn(window, "matchMedia").mockImplementation((media) => ({
media,
matches: media === "(prefers-reduced-motion: reduce)" && reducedMotion,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
}));
const preference = jest.mocked(useReducedMotion);
preference.mockReturnValue(null);
const container = document.createElement("div");
container.innerHTML = renderToString(<Hero />);
document.body.append(container);
describe("Hero reduced motion", () => {
beforeEach(() => {
mockMatchMedia(true);
jest.spyOn(Math, "random").mockReturnValue(0);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("keeps essential hero content visible with reduced motion enabled", async () => {
render(<Hero />);
await waitFor(() =>
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(),
);
expect(
screen.getByText(/Disponível para novas oportunidades/i),
).toBeInTheDocument();
expect(
screen.getByRole("heading", {
level: 1,
name: /engenharia de software para sistemas/i,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
).toBeInTheDocument();
});
it("keeps motion blocks declarative instead of requiring 3D", async () => {
const { container } = render(<Hero />);
await waitFor(() =>
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(),
);
const rootMotionBlock = container.querySelector(
"div[data-initial][data-animate]",
);
expect(rootMotionBlock).toBeInTheDocument();
expect(rootMotionBlock?.getAttribute("data-initial")).toContain("hidden");
expect(rootMotionBlock?.getAttribute("data-animate")).toContain("visible");
});
});
preference.mockReturnValue(reducedMotion);
const consoleErrors = jest.spyOn(console, "error").mockImplementation();
const recoverableErrors: unknown[] = [];
let root: Root | undefined;
try {
await act(async () => {
root = hydrateRoot(container, <Hero />, {
onRecoverableError: (error) => recoverableErrors.push(error),
});
});
expect(recoverableErrors).toEqual([]);
expect(consoleErrors.mock.calls).toEqual([]);
} finally {
await act(async () => root?.unmount());
container.remove();
}
},
);
+81 -86
View File
@@ -1,17 +1,9 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import Hero from "./Hero";
import Hero, { roundedRepoFloor } from "./Hero";
jest.mock("next/dynamic", () => () => {
const DynamicComponent = ({ variant }: { variant?: string }) => (
<div data-testid="hero-scene" data-variant={variant} />
);
DynamicComponent.displayName = "MockHeroScene";
return DynamicComponent;
});
jest.mock("framer-motion", () => {
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
@@ -22,9 +14,6 @@ jest.mock("framer-motion", () => {
children,
initial: _initial,
animate: _animate,
whileHover: _whileHover,
whileTap: _whileTap,
variants: _variants,
transition: _transition,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
@@ -35,34 +24,11 @@ jest.mock("framer-motion", () => {
},
);
return {
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
motion,
useInView: () => true,
useReducedMotion: () => false,
};
return { motion, useReducedMotion: () => false };
});
function mockMatchMedia(matches = false) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
describe("Hero", () => {
beforeEach(() => {
mockMatchMedia(false);
jest.spyOn(Math, "random").mockReturnValue(0);
jest.spyOn(window, "open").mockImplementation(() => null);
});
@@ -70,56 +36,85 @@ describe("Hero", () => {
jest.restoreAllMocks();
});
it("renders the command center hero and primary actions", async () => {
const { container } = render(<Hero />);
await waitFor(() =>
expect(container.querySelector("[data-hero-variant]")).toHaveAttribute(
"data-hero-variant",
"command-center",
),
);
expect(
screen.getByRole("heading", {
level: 1,
name: /engenharia de software para sistemas/i,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /ver casos selecionados/i }),
).toHaveAttribute("href", "/cases");
expect(screen.getByText(/Download CV/i)).toBeInTheDocument();
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
});
it("keeps social links, proof content and WhatsApp CTA", async () => {
it("routes project conversations to contact without opening an external service", () => {
render(<Hero />);
await waitFor(() =>
expect(
screen.getByText(/Disponível para novas oportunidades/i),
).toBeInTheDocument(),
);
expect(screen.getByLabelText("GitHub")).toBeInTheDocument();
expect(screen.getByLabelText("LinkedIn")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument();
expect(screen.getByText(/Operacao/i)).toBeInTheDocument();
expect(screen.getByText(/Clareza/i)).toBeInTheDocument();
expect(screen.getAllByText(/Resultado/i).length).toBeGreaterThan(0);
fireEvent.click(
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
);
expect(window.open).toHaveBeenCalledWith(
expect.stringContaining("https://wa.me/"),
"_blank",
"noopener,noreferrer",
const contactLink = screen.getByRole("link", {
name: /conversar/i,
});
expect(contactLink).toHaveAttribute("href", "/#contact");
expect(contactLink).not.toHaveAttribute("target", "_blank");
fireEvent.click(contactLink);
expect(window.open).not.toHaveBeenCalled();
expect(screen.getByRole("link", { name: /ver projetos/i })).toHaveAttribute(
"href",
"/cases",
);
});
it("keeps the headline as one readable sentence despite the per-word animation", () => {
render(<Hero />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Sistemas sob medida para operações que não cabem em soluções prontas.",
);
});
it("keeps social destinations with isolated external tabs", () => {
render(<Hero />);
expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute(
"href",
"https://github.com/evandrodevbr",
);
expect(screen.getByRole("link", { name: "LinkedIn" })).toHaveAttribute(
"href",
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
);
expect(screen.getByRole("link", { name: "Email" })).toHaveAttribute(
"href",
"mailto:[email protected]",
);
for (const name of ["GitHub", "LinkedIn"]) {
const link = screen.getByRole("link", { name });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
}
});
it("shows the real GitHub repo count with the public/private breakdown", () => {
render(<Hero stats={{ repos: { total: 68, public: 25, private: 43 } }} />);
expect(screen.getByText("projetos desenvolvidos")).toBeInTheDocument();
expect(screen.getByText("Mais de")).toBeInTheDocument();
expect(screen.getByText("65")).toBeInTheDocument();
expect(screen.getByText("25 públicos · 43 privados")).toBeInTheDocument();
expect(screen.queryByText(/projetos publicados/i)).not.toBeInTheDocument();
expect(screen.queryByText(/demonstração online/i)).not.toBeInTheDocument();
});
it("rounds the repo count down so 'mais de' is always true", () => {
expect(roundedRepoFloor(68)).toBe(65);
expect(roundedRepoFloor(66)).toBe(65);
expect(roundedRepoFloor(65)).toBe(60);
expect(roundedRepoFloor(6)).toBe(5);
expect(roundedRepoFloor(5)).toBe(0);
});
it("hides the repo count while the total is too small to round", () => {
render(<Hero stats={{ repos: { total: 4, public: 2, private: 2 } }} />);
expect(
screen.queryByText(/projetos desenvolvidos/i),
).not.toBeInTheDocument();
});
it("omits the repo count when the GitHub lookup is unavailable", () => {
render(<Hero stats={{ years: 5 }} />);
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
expect(
screen.queryByText(/projetos desenvolvidos/i),
).not.toBeInTheDocument();
});
});
+170 -743
View File
@@ -1,796 +1,223 @@
"use client";
import { motion, type Variants } from "framer-motion";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
import MagneticButton from "./cinematic/MagneticButton";
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
ssr: false,
});
type HeroSceneVariant =
| "command"
| "launch"
| "rescue"
| "signal"
| "diagnostic"
| "portrait";
type HeroVariant = {
id: string;
weight: number;
scene: HeroSceneVariant;
layout: "default" | "visual-first" | "split" | "report" | "portrait";
status: string;
chip: string;
eyebrow: string;
headline: string;
subtitle: string;
cta: string;
whatsappMessage: string;
sideLabel: string;
sideTitle: string;
sideText: string;
points: string[];
proof: Array<{ label: string; value: string }>;
};
import Link from "next/link";
import { type CSSProperties, Fragment } from "react";
import HeroField from "./cinematic/HeroField";
const SOCIAL_LINKS = [
{
href: "https://github.com/evandrodevbr",
label: "GitHub",
icon: (
<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" />
),
},
{
href: "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
label: "LinkedIn",
icon: (
<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" />
),
},
{
href: "mailto:[email protected]",
label: "Email",
icon: (
<>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</>
),
stroke: true,
},
];
const HERO_VARIANTS: HeroVariant[] = [
{
id: "command-center",
weight: 22,
scene: "command",
layout: "default",
status: "Disponível para novas oportunidades",
chip: "Garuva · Joinville · Curitiba · Remoto",
eyebrow:
"Evandro Fonseca Junior | Engenheiro da Computação e Desenvolvedor de Software",
headline:
"Engenharia de software para sistemas que precisam operar de verdade.",
subtitle:
"Profissional disponível para oportunidades CLT e PJ em Garuva, Joinville, Norte de Santa Catarina e Curitiba; também atuo em consultoria para produtos, integrações e sistemas críticos.",
cta: "Conversar sobre uma oportunidade",
whatsappMessage:
"Olá! Quero conversar sobre uma oportunidade profissional em engenharia de software.",
sideLabel: "Contato profissional direto",
sideTitle: "Procurando um engenheiro da computação para o seu time?",
sideText:
"Vamos conversar sobre o contexto técnico, o desafio da vaga e como posso contribuir da arquitetura à entrega.",
points: [
"Engenharia full-stack com base em sistemas e integrações.",
"Next.js, TypeScript, Rust, IoT e arquitetura de software.",
"Disponível para times do Norte de SC, Curitiba e atuação remota.",
],
proof: [
{
label: "Operacao",
value: "Fluxo pensado para discovery, build, deploy e sustentacao.",
},
{
label: "Clareza",
value: "Menos ruído entre produto, engenharia e decisao tecnica.",
},
{
label: "Resultado",
value: "Base pronta para operar, evoluir e receber proximo ciclo.",
},
],
},
{
id: "launch-sequence",
weight: 18,
scene: "launch",
layout: "visual-first",
status: "Launch sequence ready",
chip: "Discovery → build → deploy",
eyebrow: "MVP, produto e entrega em producao",
headline:
"Tire seu MVP do plano e coloque em producao com base tecnica real.",
subtitle:
"Transformo escopo solto em sequencia de lancamento: o que validar, o que construir primeiro e como publicar sem criar uma divida tecnica logo no inicio.",
cta: "Lancar meu MVP",
whatsappMessage:
"Ola! Quero lancar um MVP com uma base tecnica segura. Podemos conversar?",
sideLabel: "Sequencia de lancamento",
sideTitle: "Do primeiro corte ao deploy.",
sideText:
"A conversa define prioridade, risco e o menor caminho confiavel para colocar a primeira versao no ar.",
points: [
"Recorte de MVP sem inflar escopo.",
"Entrega incremental com criterio de producao.",
"Deploy e continuidade pensados desde o inicio.",
],
proof: [
{ label: "01", value: "Diagnostico do objetivo e gargalo principal." },
{ label: "02", value: "Plano de construcao com risco visivel." },
{ label: "03", value: "Publicacao e proximo ciclo sem perder contexto." },
],
},
{
id: "rescue-split",
weight: 16,
scene: "rescue",
layout: "split",
status: "Rescue mode available",
chip: "Legado, instabilidade e projeto travado",
eyebrow: "Rescue tecnico para sistemas que precisam voltar a andar",
headline:
"Codigo confuso, entrega travada ou sistema instavel? Vamos organizar.",
subtitle:
"Entro no ponto onde o projeto parou: leio o fluxo real, separo sintoma de causa e transformo caos tecnico em plano executavel.",
cta: "Destravar meu projeto",
whatsappMessage:
"Ola! Tenho um projeto travado ou instavel e quero destravar. Podemos conversar?",
sideLabel: "Antes / depois tecnico",
sideTitle: "Do caos operacional para uma rota clara.",
sideText:
"O primeiro objetivo nao e reescrever tudo. E encontrar o menor corte seguro que devolve controle e previsibilidade.",
points: [
"Leitura rapida de fluxo, risco e acoplamento.",
"Priorizacao do que destrava sem reescrever tudo.",
"Correcoes com criterio para nao trocar um bug por outro.",
],
proof: [
{
label: "Antes",
value: "Backlog confuso, bugs recorrentes e pouca previsibilidade.",
},
{
label: "Intervencao",
value: "Mapa de risco, cortes pequenos e decisao tecnica clara.",
},
{ label: "Depois", value: "Entrega volta a andar com menos improviso." },
],
},
{
id: "iot-signal-map",
weight: 15,
scene: "signal",
layout: "default",
status: "Signal map active",
chip: "Hardware + software + operacao",
eyebrow: "Produtos conectados, telemetria e integracoes criticas",
headline:
"Conecte dispositivo, backend e interface em uma operacao confiavel.",
subtitle:
"Atuo na fronteira que costuma quebrar primeiro: sinal fisico, API, painel, dados e rotina operacional funcionando como um unico produto.",
cta: "Conectar meu produto",
whatsappMessage:
"Ola! Quero conectar hardware, software e operacao em um produto confiavel. Podemos conversar?",
sideLabel: "Mapa de sinais",
sideTitle: "Dados, comandos e painel no mesmo fluxo.",
sideText:
"A conversa identifica fontes de dados, contratos, pontos de falha e o caminho para transformar integracao em produto.",
points: [
"Contratos claros entre dispositivo, API e interface.",
"Telemetria e dashboards com contexto operacional.",
"Base pronta para automacao, IA ou analytics.",
],
proof: [
{
label: "Entrada",
value: "Sensores, dispositivos, eventos ou sistemas externos.",
},
{
label: "Meio",
value: "APIs, WebSockets, filas, banco e observabilidade.",
},
{
label: "Saida",
value: "Painel, operacao, alerta e decisao de produto.",
},
],
},
{
id: "diagnostic-report",
weight: 15,
scene: "diagnostic",
layout: "report",
status: "Diagnostic report open",
chip: "Arquitetura antes de mais codigo",
eyebrow: "Diagnostico tecnico para decidir melhor",
headline: "Antes de escrever mais codigo, descubra o proximo passo certo.",
subtitle:
"Quando a base cresce sem clareza, mais features podem piorar o problema. Eu ajudo a mapear risco, arquitetura e prioridades antes da proxima decisao cara.",
cta: "Agendar diagnostico",
whatsappMessage:
"Ola! Quero um diagnostico tecnico para decidir o proximo passo do meu sistema. Podemos conversar?",
sideLabel: "Relatorio tecnico",
sideTitle: "Risco, arquitetura e rota de acao.",
sideText:
"Ideal para produto que precisa escalar, migrar, estabilizar ou parar de acumular retrabalho tecnico.",
points: [
"Mapa claro dos gargalos e dependencias.",
"Trade-offs traduzidos para decisao de negocio.",
"Plano de transicao sem promessa magica.",
],
proof: [
{ label: "Risco", value: "O que pode quebrar, atrasar ou custar caro." },
{
label: "Arquitetura",
value: "Onde simplificar, separar ou estabilizar primeiro.",
},
{ label: "Acao", value: "Proximo corte de implementacao com criterio." },
],
},
{
id: "cinematic-portrait",
weight: 14,
scene: "portrait",
layout: "portrait",
status: "Direct technical partner",
chip: "Conversa direta com quem implementa",
eyebrow: "Engenharia senior, ownership e execucao",
headline: "Um parceiro tecnico para transformar complexidade em entrega.",
subtitle:
"Voce nao precisa apenas de tela bonita ou codigo isolado. Precisa de alguem que entenda contexto, escolha o corte certo e acompanhe ate o sistema operar.",
cta: "Falar com Evandro",
whatsappMessage:
"Ola Evandro! Quero conversar sobre um projeto e entender como podemos colocar esse sistema no ar.",
sideLabel: "Founder-led engineering",
sideTitle: "Menos repasse, mais ownership.",
sideText:
"A conversa e comigo: contexto tecnico, prioridade, risco e caminho de entrega sem perder informacao entre camadas.",
points: [
"Atendimento consultivo com leitura tecnica real.",
"Do produto ao backend sem perder contexto.",
"Execucao com criterio, nao so codigo por demanda.",
],
proof: [
{
label: "Perfil",
value: "Engenharia full-stack com base em sistemas e integracoes.",
},
{
label: "Postura",
value: "Clareza, autonomia e decisao tecnica pragmatica.",
},
{
label: "Acesso",
value: "WhatsApp direto para conversa tecnica inicial.",
},
],
},
];
const LAYOUT_CLASSES: Record<HeroVariant["layout"], string> = {
default:
"grid items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10",
"visual-first":
"grid items-start gap-sp-8 lg:grid-cols-[minmax(340px,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-10",
split: "grid items-stretch gap-sp-8 lg:grid-cols-2 lg:gap-sp-10",
report:
"grid items-start gap-sp-8 lg:grid-cols-[minmax(360px,0.85fr)_minmax(0,1.15fr)] lg:gap-sp-10",
portrait:
"grid items-center gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:gap-sp-10",
/**
* Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json;
* repos vem da API do GitHub (lib/github-stats.ts) e some quando nem a consulta nem o último retrato estão disponíveis.
*/
export type HeroStats = {
years?: number;
repos?: { total: number; public: number; private: number };
};
function getGreeting(message: string) {
const hour = new Date().getHours();
if (hour >= 8 && hour < 12) return `Bom dia! ${message}`;
if (hour >= 13 && hour < 18) return `Boa tarde! ${message}`;
return message;
type StatItem = {
value: string;
label: string;
prefix?: string;
detail?: string;
};
/** "Mais de N" só é verdade se N for estritamente menor que o total: arredonda para baixo, em múltiplos de 5. */
export function roundedRepoFloor(total: number) {
return Math.floor((total - 1) / 5) * 5;
}
function SideVisual({ variant }: { variant: HeroVariant }) {
if (variant.scene === "launch") {
return (
<div className="mb-sp-6 grid gap-sp-3">
{variant.proof.map((item, index) => (
<div
key={item.label}
className="cinematic-glass flex gap-sp-4 p-sp-4"
>
<span className="font-mono text-[11px] text-[var(--slash)]">
0{index + 1}
</span>
<div>
<div className="text-[12px] font-bold uppercase tracking-[0.08em]">
{item.label}
</div>
<p className="mt-sp-1 text-[12px] leading-6 text-[rgba(244,242,237,.7)]">
{item.value}
</p>
</div>
</div>
))}
</div>
);
}
function statItems(stats?: HeroStats): StatItem[] {
if (!stats) return [];
const { repos } = stats;
const repoFloor = repos ? roundedRepoFloor(repos.total) : 0;
const items: Array<StatItem | null> = [
stats.years
? { value: String(stats.years), label: "anos de experiência" }
: null,
repos && repoFloor > 0
? {
prefix: "Mais de",
value: String(repoFloor),
label: "projetos desenvolvidos",
detail: `${repos.public} públicos · ${repos.private} privados`,
}
: null,
];
return items.filter((item): item is StatItem => item !== null);
}
if (variant.scene === "rescue") {
return (
<div className="mb-sp-6 grid gap-sp-4 sm:grid-cols-2">
<div className="border border-[rgba(255,80,80,.28)] bg-[rgba(255,80,80,.05)] p-sp-4">
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(255,160,160,.9)]">
Antes
</div>
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(244,242,237,.72)]">
Bugs recorrentes, escopo nebuloso e medo de mexer.
</p>
</div>
<div className="border border-[rgba(204,255,0,.35)] bg-[rgba(204,255,0,.06)] p-sp-4">
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]">
Depois
</div>
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(244,242,237,.86)]">
Rota clara, cortes menores e entrega voltando a andar.
</p>
</div>
</div>
);
}
if (variant.scene === "signal") {
return (
<div className="relative mb-sp-6 h-40 overflow-hidden border border-[rgba(255,255,255,.08)] bg-[rgba(255,255,255,.03)]">
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 420 160">
<path
d="M28 116 C108 18 190 142 284 56 S380 36 400 28"
fill="none"
stroke="rgba(204,255,0,.75)"
strokeWidth="2"
/>
{[28, 150, 284, 400].map((x, index) => (
<circle
key={x}
cx={x}
cy={[116, 78, 56, 28][index]}
r="6"
fill="#ccff00"
/>
))}
</svg>
</div>
);
}
if (variant.scene === "diagnostic") {
return (
<div className="mb-sp-6 border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.04)] p-sp-5">
<div className="mb-sp-4 flex items-center justify-between border-b border-[rgba(255,255,255,.08)] pb-sp-3 font-mono text-[10px] uppercase tracking-[0.12em]">
<span>Risk report</span>
<span className="text-[var(--slash)]">Actionable</span>
</div>
<div className="space-y-sp-3">
{variant.proof.map((item) => (
<div key={item.label}>
<div className="flex justify-between text-[12px] text-[rgba(244,242,237,.78)]">
<span>{item.label}</span>
<span className="text-[var(--slash)]">mapped</span>
</div>
<div className="mt-sp-2 h-px bg-[linear-gradient(90deg,var(--slash),rgba(255,255,255,.08))]" />
</div>
))}
</div>
</div>
);
}
if (variant.scene === "portrait") {
return (
<div className="mb-sp-6 border border-[rgba(204,255,0,.22)] bg-[radial-gradient(circle_at_70%_20%,rgba(204,255,0,.14),transparent_35%),rgba(255,255,255,.035)] p-sp-5">
<div className="font-display text-[clamp(2.5rem,8vw,5rem)] font-extrabold leading-none tracking-[-0.08em] text-[rgba(244,242,237,.95)]">
EFJ
</div>
<div className="mt-sp-4 font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
Engenharia com contato direto
</div>
</div>
);
}
return (
<div className="mb-sp-6 grid gap-sp-3 font-mono text-[11px] uppercase tracking-[0.08em]">
{["deploy pipeline", "api health", "production ready"].map((item) => (
<div
key={item}
className="flex items-center justify-between border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.04)] px-sp-4 py-sp-3"
>
<span>{item}</span>
<span className="text-[var(--slash)]">ok</span>
</div>
))}
</div>
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
const TITLE =
"Sistemas sob medida para operações que não cabem em soluções prontas.".split(
" ",
);
}
const ACCENT = new Set([1, 2]);
export default function Hero() {
const variant = HERO_VARIANTS[0];
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
/** O que cada camada da cena significa, em linguagem simples. */
const CAPTIONS = [
["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
[
"As integrações",
"Os programas que você já usa conversando entre si, sem retrabalho.",
],
["Os dados", "Suas informações guardadas, organizadas e seguras."],
] as const;
useEffect(() => {
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
setShouldReduceMotion(reducedMotion);
setWhatsAppDisplay(getWhatsAppDisplayNumber());
if (
!shouldLoadHeroScene({
reducedMotion,
coarsePointer,
width: window.innerWidth,
})
) {
return;
}
const timer = window.setTimeout(() => setShouldRenderHeroScene(true), 1500);
return () => window.clearTimeout(timer);
}, []);
const handleWhatsAppClick = () => {
window.open(
buildWhatsAppUrl(getGreeting(variant.whatsappMessage)),
"_blank",
"noopener,noreferrer",
);
};
const heroContainer: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1, delayChildren: 0.3 },
},
};
const easeOut: [number, number, number, number] = [0.16, 1, 0.3, 1];
const heroItem: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 40, filter: "blur(10px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 1, ease: easeOut },
},
};
const heroItemSlow: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 60, filter: "blur(12px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 1.2, ease: easeOut },
},
};
const contentOrder =
variant.layout === "visual-first" || variant.layout === "report"
? "lg:order-2"
: "";
const panelOrder =
variant.layout === "visual-first" || variant.layout === "report"
? "lg:order-1"
: "";
export default function Hero({ stats }: { stats?: HeroStats }) {
const items = statItems(stats);
return (
<section
id="home"
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
data-hero-variant={variant.id}
className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
>
{shouldRenderHeroScene && <HeroScene variant={variant.scene} />}
<div className="hero-stage">
<div className="hero-glow" aria-hidden="true" />
<HeroField />
<div className="hero-scrim" aria-hidden="true" />
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
<ol className="hero-captions">
{CAPTIONS.map(([title, text], index) => (
<li key={title} style={{ "--i": index } as CSSProperties}>
<span className="hero-caption-title">{title}</span>
{text}
</li>
))}
</ol>
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.4)_50%,var(--ink)_100%)]" />
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
<div
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.015]"
style={{
backgroundImage:
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)",
}}
/>
<div className="pointer-events-none absolute inset-0 z-[2] bg-[radial-gradient(circle_at_50%_50%,transparent_40%,rgba(10,10,10,0.6)_100%)]" />
<div className="relative z-10 mx-auto flex w-full max-w-[1240px] flex-col px-sp-6 py-sp-16 lg:px-sp-16 lg:py-sp-12">
<motion.div
initial="hidden"
animate="visible"
variants={heroContainer}
className={LAYOUT_CLASSES[variant.layout]}
>
<div className={`max-w-[800px] ${contentOrder}`.trim()}>
<motion.div
className="mb-sp-5 flex flex-wrap items-center gap-sp-3"
variants={heroItem}
>
<div className="cinematic-glass inline-flex items-center gap-sp-3 px-4 py-2">
<motion.span
className="h-2 w-2 rounded-sm bg-[var(--slash)]"
animate={
shouldReduceMotion
? { opacity: 1 }
: {
opacity: [1, 0.25, 1],
boxShadow: [
"0 0 4px rgba(204,255,0,0.6)",
"0 0 12px rgba(204,255,0,0.2)",
"0 0 4px rgba(204,255,0,0.6)",
],
}
}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 2,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
}
}
/>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.82)]">
{variant.status}
</span>
</div>
<div className="cinematic-glass inline-flex items-center gap-sp-2 px-4 py-2 text-[11px] text-[rgba(244,242,237,.72)]">
{variant.chip}
</div>
</motion.div>
<motion.p
className="mb-sp-4 max-w-[560px] font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]"
variants={heroItem}
>
{variant.eyebrow}
</motion.p>
<motion.h1
className="max-w-[940px] text-balance font-display text-[clamp(3rem,7vw,6.2rem)] font-extrabold leading-[0.9] tracking-[-0.055em]"
variants={heroItemSlow}
>
{variant.headline}
</motion.h1>
<motion.p
className="mt-sp-6 max-w-[700px] text-[16px] leading-8 text-[rgba(244,242,237,.88)] lg:text-[17px]"
variants={heroItem}
>
{variant.subtitle}
</motion.p>
<motion.ul
className="mt-sp-8 grid gap-sp-3 sm:grid-cols-3"
variants={heroItem}
>
{variant.points.map((point, i) => (
<motion.li
key={point}
className="cinematic-glass flex min-w-0 gap-sp-3 px-sp-4 py-sp-4 text-[13px] leading-6 text-[rgba(244,242,237,.82)]"
initial={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 30, scale: 0.95 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.8,
delay: 0.8 + i * 0.1,
ease: [0.16, 1, 0.3, 1],
}}
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-8 lg:px-sp-12">
<p className="hero-in mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
Evandro Fonseca Junior · Engenharia de software
</p>
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
<h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw)] text-[clamp(38px,min(6vw,10.5svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
{TITLE.map((word, index) => (
<Fragment key={word}>
<span
className={`hero-word${ACCENT.has(index) ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
<span className="mt-[8px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--slash)] shadow-[0_0_8px_rgba(204,255,0,0.5)]" />
<span>{point}</span>
</motion.li>
))}
</motion.ul>
{word}
</span>{" "}
</Fragment>
))}
</h1>
<p className="hero-in mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s]">
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
entendimento do negócio à arquitetura e à entrega em produção.
</p>
<motion.div
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
variants={heroItem}
<div className="hero-in mt-sp-6 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
<Link
href="/#contact"
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
>
<MagneticButton
as="button"
type="button"
onClick={handleWhatsAppClick}
className="cinematic-cta-primary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#0A0A0A] no-underline"
data-cursor="View"
strength={0.25}
Conversar sobre meu projeto
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<span className="relative z-10">{variant.cta}</span>
</MagneticButton>
<MagneticButton
as="a"
href="/cases"
className="cinematic-cta-secondary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#f4f2ed] no-underline"
data-cursor="View"
strength={0.25}
>
Ver casos selecionados
</MagneticButton>
<MagneticButton
as="a"
href="/assets/Evandro_Fonseca_Junior_CV.pdf"
download
className="inline-flex min-h-12 items-center justify-center px-sp-2 text-[12px] font-medium text-[rgba(244,242,237,.76)] no-underline transition-colors duration-300 hover:text-[var(--slash)]"
strength={0.2}
>
Download CV
</MagneticButton>
</motion.div>
<motion.div
className="mt-sp-10 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3 border-t border-[rgba(255,255,255,.06)] pt-sp-6"
variants={heroItem}
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</Link>
<Link
href="/cases"
className="inline-flex min-h-12 items-center justify-center border border-white/15 bg-[rgba(10,10,10,.35)] px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline backdrop-blur-sm transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
>
<div className="inline-flex items-center gap-sp-3 text-[11px] uppercase tracking-[0.08em] text-[rgba(244,242,237,.68)]">
<span className="h-2 w-2 rounded-full bg-[var(--slash)] shadow-[0_0_8px_var(--slash)]" />
Garuva, SC / Brasil
</div>
<div className="inline-flex items-center gap-sp-3 text-[11px] uppercase tracking-[0.08em] text-[rgba(244,242,237,.68)]">
<span className="h-[1px] w-6 bg-[rgba(255,255,255,.2)]" />
Next.js, Tauri, Rust, Node.js
</div>
<div className="flex items-center gap-sp-2">
Ver projetos entregues
</Link>
</div>
</div>
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
{items.length > 0 && (
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
{items.map(({ prefix, value, label, detail }) => (
<div key={label} className="flex flex-col-reverse">
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
{label}
{detail && (
<span className="block text-[12px] text-[rgba(244,242,237,.55)]">
{detail}
</span>
)}
</dt>
<dd className="font-mono text-[clamp(22px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
{prefix && (
<span className="mr-sp-2 text-[13px] font-normal tracking-normal text-[rgba(244,242,237,.64)]">
{prefix}
</span>
)}
{value}
</dd>
</div>
))}
</dl>
)}
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */}
<div className="max-lg:hidden">
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
{["Sistemas web", "Aplicativos", "Integrações"].map(
(service) => (
<li key={service} className="flex items-center gap-sp-2">
<span
aria-hidden="true"
className="h-1 w-1 bg-[var(--slash)]"
/>
{service}
</li>
),
)}
</ul>
<div className="flex flex-wrap items-center gap-x-sp-5">
{SOCIAL_LINKS.map((link) => (
<MagneticButton
<a
key={link.label}
as="a"
href={link.href}
aria-label={link.label}
target={link.href.startsWith("http") ? "_blank" : undefined}
rel={
link.href.startsWith("http")
? "noopener noreferrer"
: undefined
}
className="cinematic-social-link inline-flex h-11 w-11 items-center justify-center rounded-full"
strength={0.4}
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill={link.stroke ? "none" : "currentColor"}
stroke={link.stroke ? "currentColor" : "none"}
strokeWidth={link.stroke ? "2" : undefined}
aria-hidden="true"
>
{link.icon}
</svg>
</MagneticButton>
{link.label}
</a>
))}
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
Santa Catarina · Atendimento remoto
</span>
</div>
</motion.div>
</div>
</div>
<motion.aside
className={`cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8 ${panelOrder}`.trim()}
initial={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, x: 60, rotateY: -8, transformPerspective: 1000 }
}
animate={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 1, x: 0, rotateY: 0 }
}
transition={{ duration: 1.2, delay: 0.5, ease: [0.16, 1, 0.3, 1] }}
<p
aria-hidden="true"
className="hero-cue hidden items-center gap-sp-3 font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
>
<motion.div
className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(204,255,0,0.8),transparent)]"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 1.5, delay: 1, ease: [0.16, 1, 0.3, 1] }}
/>
<SideVisual variant={variant} />
<div className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
{variant.sideLabel}
</div>
<h2 className="mt-sp-3 font-display text-[clamp(1.35rem,3vw,2rem)] font-extrabold leading-[1.04] tracking-[-0.03em] text-[#f7f4ee]">
{variant.sideTitle}
</h2>
<p className="mt-sp-4 text-[14px] leading-7 text-[rgba(244,242,237,.78)]">
{variant.sideText}
</p>
<button
type="button"
onClick={handleWhatsAppClick}
className="mt-sp-5 inline-flex w-full items-center justify-center bg-[var(--slash)] px-sp-5 py-sp-4 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[var(--ink)] transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
>
Agendar no WhatsApp: {whatsAppDisplay}
</button>
</div>
<div className="mt-sp-6 space-y-sp-5">
{variant.proof.map((item, index) => (
<motion.article
key={`${variant.id}-${item.label}`}
className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5 last:border-b-0 last:pb-0"
initial={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 20, filter: "blur(6px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{
duration: 0.8,
delay: 0.8 + index * 0.12,
ease: [0.16, 1, 0.3, 1],
}}
>
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.56)]">
{item.label}
</div>
<p className="mt-sp-2 text-[14px] leading-7 text-[rgba(244,242,237,.92)]">
{item.value}
</p>
</motion.article>
))}
</div>
</motion.aside>
</motion.div>
Role para mergulhar
<span className="hero-cue-line" />
</p>
</div>
</div>
<motion.div
className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 2, duration: 1 }}
>
<motion.div
className="flex flex-col items-center gap-2"
animate={{ y: [0, 8, 0] }}
transition={{
duration: 2,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
>
<span className="font-mono text-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
Scroll
</span>
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
</motion.div>
</motion.div>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LighthouseMetrics from "./LighthouseMetrics";
it("updates the inspection criteria while preserving keyboard navigation", async () => {
const user = userEvent.setup();
render(<LighthouseMetrics />);
const report = screen.getByRole("region", { name: "Inspeção de qualidade" });
expect(screen.getByRole("radio", { name: /Performance/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/JavaScript/);
const accessibility = screen.getByRole("radio", { name: /Acessibilidade/ });
await user.click(accessibility);
expect(accessibility).toHaveFocus();
expect(within(report).getByRole("list")).toHaveTextContent(/teclado/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /Boas práticas/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/consentimento/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /SEO/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/Sitemap/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /Performance/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/JavaScript/);
});
+306 -79
View File
@@ -1,110 +1,337 @@
"use client";
import { motion } from "framer-motion";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { Section } from "./Section";
import { useState } from "react";
const METRICS = [
{
score: 100,
id: "performance",
label: "Performance",
code: "LCP / INP / CLS",
title: "O essencial chega primeiro.",
description:
"Cada milissegundo conta. Tempos de carregamento instantâneos retêm mais usuários, aumentam a conversão e garantem que a interface pareça nativa.",
"O carregamento começa pelo que importa. Imagens, fontes e JavaScript precisam trabalhar a favor da experiência, não competir com ela.",
checks: [
"Conteúdo principal com prioridade de carregamento",
"Dimensões reservadas para evitar saltos de layout",
"JavaScript carregado onde faz diferença",
],
caption: "Em foco: o conteúdo principal e a estabilidade da página.",
},
{
score: 100,
id: "accessibility",
label: "Acessibilidade",
code: "TECLADO / FOCO / SEMÂNTICA",
title: "A interface inclui mais pessoas.",
description:
"Acesso universal. Contraste perfeito, navegação por teclado e semântica adequada garantindo o uso por todos os perfis e mitigando riscos.",
"Uma boa interface não exige um jeito único de navegar. Teclado, leitores de tela e preferências de movimento fazem parte do projeto.",
checks: [
"Foco visível e navegação por teclado",
"Controles com nomes e estrutura semântica",
"Contraste legível e movimento reduzido",
],
caption:
"Em foco: controles reconhecíveis e um caminho claro pelo teclado.",
},
{
score: 100,
label: "Best Practices",
id: "practices",
label: "Boas práticas",
code: "HTTPS / PRIVACIDADE / DEPENDÊNCIAS",
title: "Confiança também se constrói.",
description:
"Código seguro e moderno. Uso das APIs mais recentes, prevenção inteligente contra vulnerabilidades comuns e front-end limpo.",
"Cuidar da entrega também é cuidar dos dados, das dependências e dos limites do navegador. Segurança não se resume a uma nota.",
checks: [
"Conexões seguras e políticas do navegador",
"Analytics condicionado ao consentimento",
"Dependências verificadas e atualizadas",
],
caption: "Em foco: a conexão e os limites de acesso aos dados.",
},
{
score: 100,
id: "seo",
label: "SEO",
code: "HTML / METADATA / INDEXAÇÃO",
title: "Conteúdo pronto para ser encontrado.",
description:
"Rankeamento orgânico superior. Estrutura impecável para indexação precisa pelos motores de busca e otimização para crawlers sociais.",
"Estrutura antes de promessas. Conteúdo claro, títulos coerentes e dados estruturados ajudam pessoas e buscadores a entender cada página.",
checks: [
"Títulos, descrições e URLs canônicas",
"HTML semântico e conteúdo rastreável",
"Sitemap e dados estruturados factuais",
],
caption: "Em foco: a estrutura que dá contexto ao conteúdo.",
},
];
const BLUEPRINT = (
<svg
className="quality-blueprint"
viewBox="0 0 640 440"
fill="none"
aria-hidden="true"
focusable="false"
>
<g className="quality-dimensions">
<path d="M64 68V48m512 20V48M64 58h512M44 86H24m20 284H24M34 86v284" />
<path d="m60 54 8 8m504-8 8 8M30 82l8 8m-8 276 8 8" />
<text x="304" y="47">
RESPONSIVE
</text>
<path d="M64 390v18m512-18v18M64 400h180m152 0h180" />
<text x="272" y="405">
WEB / BLUEPRINT
</text>
</g>
<rect
className="quality-browser-frame"
x="64"
y="86"
width="512"
height="284"
rx="8"
/>
<path className="quality-wire" d="M64 123h512" />
<g className="quality-browser-dots">
<circle cx="83" cy="105" r="3" />
<circle cx="95" cy="105" r="3" />
<circle cx="107" cy="105" r="3" />
</g>
<rect
className="quality-wire"
x="142"
y="96"
width="350"
height="18"
rx="3"
/>
<text className="quality-address" x="158" y="109">
evandro.dev.br
</text>
<path className="quality-wire" d="M539 100h15m-15 5h15m-15 5h15" />
<text className="quality-browser-heading" x="96" y="176">
Engenharia
</text>
<text className="quality-browser-heading" x="96" y="203">
que funciona.
</text>
<path className="quality-wire" d="M96 222h170M96 230h130" />
<rect
className="quality-browser-button"
x="96"
y="254"
width="142"
height="32"
rx="2"
/>
<text className="quality-browser-button-label" x="112" y="274">
EXPLORAR PROJETO
</text>
<rect
className="quality-wire"
x="330"
y="144"
width="216"
height="142"
rx="3"
/>
<g className="quality-wire">
<path d="m438 164 58 33v55l-58 33-58-33v-55zM380 197l58 33 58-33m-58 33v55m0-121v66" />
<path d="m409 181 58 33v55m-58 0v-55l58-33" />
<path d="M96 306h450M96 324h113m-113 10h80m84-10h113m-113 10h80m84-10h122m-122 10h80" />
</g>
<g className="quality-layer" data-layer="performance">
<rect
className="quality-highlight"
x="318"
y="133"
width="240"
height="165"
rx="4"
/>
<path className="quality-trace" pathLength="1" d="M558 216h43V59h-91" />
<circle cx="558" cy="216" r="4" />
<text x="393" y="63">
LCP / CONTEÚDO
</text>
<path d="M330 299v12m216-12v12m-216-6h216" />
<rect
className="quality-annotation"
x="368"
y="311"
width="154"
height="18"
/>
<text x="376" y="323">
ESPAÇO RESERVADO
</text>
</g>
<g className="quality-layer" data-layer="accessibility">
<rect
className="quality-highlight quality-focus-frame"
x="88"
y="246"
width="158"
height="48"
rx="5"
/>
<path className="quality-trace" pathLength="1" d="M88 270H50v149h20" />
<circle cx="88" cy="270" r="4" />
<text x="79" y="424">
TAB / FOCO VISÍVEL
</text>
<rect x="260" y="259" width="45" height="24" rx="3" />
<text x="270" y="275">
TAB
</text>
</g>
<g className="quality-layer" data-layer="practices">
<rect
className="quality-highlight"
x="134"
y="91"
width="366"
height="28"
rx="4"
/>
<path className="quality-trace" pathLength="1" d="M500 105h101v314h-90" />
<circle cx="500" cy="105" r="4" />
<path
className="quality-highlight"
d="m499 220 36 13v32c0 25-36 41-36 41s-36-16-36-41v-32z"
/>
<rect x="487" y="255" width="24" height="20" rx="2" />
<path d="M491 255v-7a8 8 0 0 1 16 0v7m-8 6v7" />
<text x="290" y="424">
HTTPS / CONSENTIMENTO
</text>
</g>
<g className="quality-layer" data-layer="seo">
<rect
className="quality-highlight"
x="85"
y="144"
width="224"
height="95"
rx="3"
/>
<path className="quality-trace" pathLength="1" d="M85 159H48V58h135" />
<circle cx="85" cy="159" r="4" />
<text x="74" y="49">
HTML / SEMÂNTICA
</text>
<text x="99" y="140">
&lt;h1&gt;
</text>
<path d="M331 317h213v29H331z" />
<text x="350" y="337">
&lt;article&gt; / &lt;nav&gt;
</text>
</g>
</svg>
);
export default function LighthouseMetrics() {
const shouldReduceMotion = useReducedMotion();
const [active, setActive] = useState(METRICS[0]);
return (
<Section
<section
id="lighthouse"
eyebrow="Quality & Performance"
title="Engenharia de ponta a ponta: 100/100 não é vaidade."
description="Sistemas não podem apenas parecer bons. Eles precisam ser consistentes, rápidos, acessíveis, seguros e fáceis de processar. Entregar métricas máximas significa que a fundação técnica do projeto está preparada para escalar sem fricção."
dark
disableChildrenAnimation={false}
className="ds-section ds-section-dark quality-lab"
aria-labelledby="quality-title"
data-inspection={active.id}
>
<div className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4">
{METRICS.map((metric, i) => (
<motion.article
key={metric.label}
className="ds-card flex flex-col items-center gap-sp-6 text-center lg:items-start lg:text-left"
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
whileInView={
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.6, delay: i * 0.1 }}
>
{/* The Ring Container */}
<div className="relative flex h-[100px] w-[100px] shrink-0 items-center justify-center rounded-full bg-[rgba(0,0,0,0.3)] shadow-inner">
<svg
className="absolute inset-0 h-full w-full rotate-[-90deg] drop-shadow-[0_0_12px_rgba(12,206,107,0.35)]"
viewBox="0 0 36 36"
aria-hidden="true"
>
{/* Background Track */}
<path
className="text-[rgba(255,255,255,0.1)]"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
/>
{/* Animated Value Track */}
<motion.path
className="text-[#0cce6b]"
initial={{ strokeDasharray: "0, 100" }}
whileInView={{ strokeDasharray: `${metric.score}, 100` }}
viewport={{ once: true }}
transition={{
duration: 1.5,
ease: "easeOut",
delay: i * 0.15 + 0.2,
}}
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
<div className="font-mono text-[24px] font-bold tracking-tight text-[#0cce6b]">
{metric.score}
</div>
</div>
<div className="ds-section-shell">
<div className="quality-intro">
<header className="quality-copy">
<p className="ds-eyebrow">Critérios de entrega</p>
<h2 id="quality-title" className="ds-section-title">
Engenharia de ponta a ponta.
<span>Qualidade em cada camada.</span>
</h2>
<p>
Sistemas precisam mais do que uma boa aparência. Explore as
decisões que tornam uma interface rápida, acessível, confiável e
fácil de encontrar.
</p>
<a className="ds-link ds-link-accent" href="/#contact">
Conversar sobre a qualidade do seu projeto
</a>
</header>
{/* Content Details */}
<div className="flex-1">
<h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]">
{metric.label}
</h3>
<p className="mt-sp-3 text-[13px] leading-[1.8] text-[rgba(255,255,255,.75)]">
{metric.description}
</p>
<figure className="quality-visual bg-grid-light">
<div className="quality-visual-label">
<span>Laboratório de qualidade</span>
<span>Modelo interativo</span>
</div>
</motion.article>
))}
{BLUEPRINT}
<figcaption>
{METRICS.map((metric) => (
<span key={metric.id} aria-hidden={active.id !== metric.id}>
{metric.caption}
</span>
))}
</figcaption>
</figure>
</div>
<fieldset className="quality-controls" aria-describedby="quality-hint">
<legend className="sr-only">Escolha um aspecto da qualidade</legend>
{METRICS.map((metric) => (
<label key={metric.id} className="quality-control">
<input
type="radio"
name="quality-pillar"
value={metric.id}
checked={active.id === metric.id}
onChange={() => setActive(metric)}
aria-controls="quality-report"
/>
<span className="quality-control-content">
<span className="quality-control-name">{metric.label}</span>
<span className="quality-control-target">Ver critérios</span>
</span>
</label>
))}
</fieldset>
<p id="quality-hint" className="quality-hint">
Selecione um pilar para explorar. No teclado, use as setas.
</p>
<section
id="quality-report"
className="quality-report-stack"
aria-label="Inspeção de qualidade"
aria-live="polite"
aria-atomic="true"
>
{METRICS.map((metric) => (
<div
key={metric.id}
className="quality-report"
aria-hidden={active.id !== metric.id}
>
<div className="quality-report-copy">
<p className="quality-code">{metric.code}</p>
<h3>{metric.title}</h3>
<p>{metric.description}</p>
</div>
<ul>
{metric.checks.map((check) => (
<li key={check}>
<span aria-hidden="true">/</span>
{check}
</li>
))}
</ul>
</div>
))}
</section>
<p className="quality-disclaimer">
Exemplos de critérios avaliados conforme o projeto. Este painel não
representa uma auditoria, certificação ou garantia de resultado.
</p>
</div>
</Section>
</section>
);
}
+14 -174
View File
@@ -1,185 +1,25 @@
"use client";
import { motion } from "framer-motion";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import Image from "next/image";
interface LogoProps {
variant?: "mark" | "wordmark" | "icon";
size?: "sm" | "md" | "lg" | "xl";
theme?: "dark" | "light" | "accent";
theme?: "dark" | "light";
className?: string;
showTagline?: boolean;
loading?: "eager" | "lazy";
}
const sizeMap = {
sm: { width: 32, height: 32 },
md: { width: 48, height: 48 },
lg: { width: 64, height: 64 },
xl: { width: 120, height: 80 },
};
export function Logo({
variant = "mark",
size = "md",
theme = "dark",
className = "",
showTagline = false,
className = "w-48",
loading = "lazy",
}: Readonly<LogoProps>) {
const dimensions = sizeMap[size];
const shouldReduceMotion = useReducedMotion();
const colors = {
dark: {
primary: "#FFFFFF",
slash: "#CCFF00",
},
light: {
primary: "#0A0A0A",
slash: "#0A0A0A",
},
accent: {
primary: "#0A0A0A",
slash: "rgba(0,0,0,.3)",
},
};
const c = colors[theme];
const hoverMotion = shouldReduceMotion
? undefined
: {
scale: 1.03,
rotate: -2,
};
const hoverTransition = {
type: "spring" as const,
stiffness: 280,
damping: 18,
};
if (variant === "icon") {
return (
<motion.svg
width={dimensions.width}
height={dimensions.height}
viewBox="0 0 64 64"
fill="none"
className={className}
role="img"
aria-label="Evandro icon logo"
whileHover={hoverMotion}
transition={hoverTransition}
>
<title>Evandro icon logo</title>
<rect width="64" height="64" rx="14" fill={c.primary} />
<rect
x="10"
y="10"
width="7"
height="44"
fill={theme === "dark" ? "#0A0A0A" : c.primary}
/>
<rect
x="17"
y="10"
width="18"
height="7"
fill={theme === "dark" ? "#0A0A0A" : c.primary}
/>
<rect
x="17"
y="28.5"
width="14"
height="7"
fill={theme === "dark" ? "#0A0A0A" : c.primary}
/>
<rect
x="17"
y="47"
width="18"
height="7"
fill={theme === "dark" ? "#0A0A0A" : c.primary}
/>
<polygon points="37,38 41,38 50,26 46,26" fill={c.slash} />
<circle
cx="52"
cy="47"
r="5"
fill={theme === "dark" ? "#0A0A0A" : c.primary}
/>
</motion.svg>
);
}
if (variant === "wordmark") {
return (
<motion.div
className={`flex items-center gap-3 ${className}`}
whileHover={hoverMotion}
transition={hoverTransition}
>
<svg
width={dimensions.width}
height={dimensions.height}
viewBox="0 0 48 32"
fill="none"
role="img"
aria-label="Evandro wordmark logo"
>
<title>Evandro wordmark logo</title>
<rect x="2" y="2" width="7" height="28" fill={c.primary} />
<rect x="9" y="2" width="20" height="7" fill={c.primary} />
<rect x="9" y="12.5" width="16" height="7" fill={c.primary} />
<rect x="9" y="23" width="20" height="7" fill={c.primary} />
<polygon points="30,22 34,22 42,10 38,10" fill={c.slash} />
<circle cx="44" cy="25" r="4" fill={c.primary} />
</svg>
<div>
<div
className="font-display text-lg font-extrabold tracking-tight"
style={{ color: c.primary }}
>
evandro<span style={{ color: c.slash }}>.</span>dev
<span style={{ color: c.slash }}>.</span>br
</div>
{showTagline && (
<div
className="mt-1 text-[9px] tracking-widest"
style={{
color:
theme === "dark"
? "rgba(255,255,255,.25)"
: "rgba(10,10,10,.4)",
}}
>
DESENVOLVIMENTO DE SOFTWARE
</div>
)}
</div>
</motion.div>
);
}
return (
<motion.svg
width={dimensions.width}
height={dimensions.height}
viewBox="0 0 120 80"
fill="none"
className={className}
role="img"
aria-label="Evandro mark logo"
whileHover={hoverMotion}
transition={hoverTransition}
>
<title>Evandro mark logo</title>
<rect x="10" y="8" width="9" height="64" fill={c.primary} />
<rect x="19" y="8" width="28" height="9" fill={c.primary} />
<rect x="19" y="35.5" width="22" height="9" fill={c.primary} />
<rect x="19" y="63" width="28" height="9" fill={c.primary} />
<polygon points="50,55 57,55 74,25 67,25" fill={c.slash} />
<circle cx="80" cy="63" r="7" fill={c.primary} />
</motion.svg>
<Image
src={`/brand/wordmark-${theme}.webp`}
alt="Evandro.dev.br"
width={1600}
height={330}
sizes="240px"
loading={loading}
className={`block h-auto max-w-full ${className}`}
/>
);
}
+66
View File
@@ -0,0 +1,66 @@
import Image from "next/image";
import { getProjectVideo } from "@/lib/project-showcase";
import { ProjectMedia } from "./showcase/ProjectMedia";
type CoverProject = {
id: string;
title: string;
demoUrl?: string | null;
githubUrl?: string | null;
};
/** Capturas de tela publicadas dos projetos que têm interface pública. */
const SCREENSHOTS: Record<string, string | undefined> = {
nutriscan: "/assets/notebook/nutriscan.webp",
"sc-plus": "/assets/notebook/sc-plus.webp",
"sev-modaintima": "/assets/notebook/sev-modaintima.webp",
"as-servicos": "/assets/notebook/as-servicos.webp",
};
function initials(title: string) {
return title
.split(/\s+/)
.filter((word) => word.length > 2 || /^[A-Z]/.test(word))
.slice(0, 2)
.map((word) => word[0])
.join("")
.toUpperCase();
}
/**
* Capa do card de projeto: a captura de tela real quando existe; sem captura, um painel gerado em código
* (só o monograma, sem repetir a stack que já aparece nas tags do card) em vez de uma imagem inventada.
*/
export function ProjectCover({
project,
priority = false,
}: {
project: CoverProject;
priority?: boolean;
}) {
const video = getProjectVideo(project.id);
const src = SCREENSHOTS[project.id];
if (video) return <ProjectMedia project={project} />;
if (src) {
return (
<div className="ds-cover">
<Image
src={src}
alt={`Captura de tela de ${project.title}`}
width={1600}
height={1000}
sizes="(min-width: 1360px) 33vw, (min-width: 1025px) 50vw, 100vw"
priority={priority}
/>
</div>
);
}
return (
<div className="ds-cover ds-cover-fallback" aria-hidden="true">
<span className="ds-cover-mark">{initials(project.title)}</span>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { act, render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { ProjectVideo } from "./ProjectVideo";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
const video = getShowcaseAssets("nutriscan", "desktop");
beforeEach(() => {
reducedMotion = false;
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("ProjectVideo", () => {
it("renders a muted looping inline video that downloads nothing until it is active", () => {
const { container } = render(
<ProjectVideo video={video} title="Demo" active={false} />,
);
const element = container.querySelector("video");
expect(element).not.toBeNull();
expect(element).toHaveAttribute("autoplay");
expect(element).toHaveAttribute("loop");
expect(element).toHaveAttribute("playsinline");
expect(element).toHaveAttribute("preload", "none");
expect(element).toHaveAttribute("poster", video.poster);
expect(element?.muted).toBe(true);
expect(element).toHaveAttribute("data-ready", "false");
expect(container.querySelectorAll("source")).toHaveLength(0);
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
});
it("attaches the sources and plays when it becomes the active card, then pauses when it stops being", () => {
const { container, rerender } = render(
<ProjectVideo video={video} title="Demo" active={false} />,
);
rerender(<ProjectVideo video={video} title="Demo" active />);
const sources = [...container.querySelectorAll("source")];
expect(sources.map((source) => source.getAttribute("src"))).toEqual(
video.sources.map((source) => source.src),
);
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
rerender(<ProjectVideo video={video} title="Demo" active={false} />);
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled();
// sai de cena, mas o arquivo já baixado fica: voltar a tocar não baixa de novo
expect(container.querySelectorAll("source")).toHaveLength(2);
});
it("fades the video in once it is playing", () => {
const { container } = render(
<ProjectVideo video={video} title="Demo" active />,
);
const element = container.querySelector("video") as HTMLVideoElement;
act(() => {
element.dispatchEvent(new Event("playing"));
});
expect(element).toHaveAttribute("data-ready", "true");
});
it("shows only the poster, with no video element, when the user prefers reduced motion", () => {
reducedMotion = true;
const { container } = render(
<ProjectVideo video={video} title="Demo" active />,
);
expect(container.querySelector("video")).toBeNull();
expect(
screen.getByRole("img", { name: "Captura de tela de Demo" }),
).toBeInTheDocument();
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
});
});
+84
View File
@@ -0,0 +1,84 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { ShowcaseAssets } from "@/lib/project-showcase";
/**
* Vídeo de rolagem na capa do card. Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que
* está sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o
* card fica ativo; fora de cena ele pausa. Com prefers-reduced-motion só o poster aparece.
* O poster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar.
*/
export function ProjectVideo({
video,
title,
active,
}: {
video: ShowcaseAssets;
title: string;
active: boolean;
}) {
const reducedMotion = useReducedMotion();
const ref = useRef<HTMLVideoElement>(null);
const [attached, setAttached] = useState(false);
const [ready, setReady] = useState(false);
const playing = active && !reducedMotion;
useEffect(() => {
if (playing) setAttached(true);
}, [playing]);
useEffect(() => {
// as fontes entram depois do primeiro render: o elemento precisa ler a lista nova
if (attached) ref.current?.load();
}, [attached]);
useEffect(() => {
const element = ref.current;
if (!element || !attached) return;
if (playing) {
element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real
element.play().catch(() => {});
} else {
element.pause(); // também cancela um autoplay que ainda esperava o arquivo chegar
}
}, [attached, playing]);
if (reducedMotion) {
return (
<Image
src={video.poster}
alt={`Captura de tela de ${title}`}
width={video.width}
height={video.height}
sizes="(min-width: 1360px) 33vw, (min-width: 1025px) 50vw, 100vw"
/>
);
}
return (
<video
ref={ref}
autoPlay
muted
loop
playsInline
preload="none"
poster={video.poster}
width={video.width}
height={video.height}
data-ready={ready}
onPlaying={() => setReady(true)}
aria-label={`Demonstração em vídeo de ${title}`}
disablePictureInPicture
disableRemotePlayback
>
{attached &&
video.sources.map((source) => (
<source key={source.src} src={source.src} type={source.type} />
))}
</video>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import {
getFeaturedProjects,
type PortfolioProjectView,
} from "@/lib/portfolio-data";
import Projects from "./Projects";
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
animate: _animate,
initial: _initial,
transition: _transition,
variants: _variants,
viewport: _viewport,
whileHover: _whileHover,
whileInView: _whileInView,
whileTap: _whileTap,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
React.createElement(tag, props, children);
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return { motion };
});
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => true,
}));
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => null,
}));
jest.mock("./Section", () => ({
Section: ({ children, title }: { children: ReactNode; title: string }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
}));
const [baseProject] = getFeaturedProjects(null);
const projects: PortfolioProjectView[] = [
{
...baseProject,
id: "available-demo",
title: "Demo disponível",
description: "Descrição disponível",
technologies: ["Stack disponível"],
status: "Disponível",
demoUrl: "https://available.example",
githubUrl: "https://github.com/example/available",
demoAvailability: "available",
demoCheckedAt: "2026-09-04T12:00:00.000Z",
},
{
...baseProject,
id: "unavailable-demo",
title: "Demo indisponível",
description: "Descrição indisponível",
technologies: ["Stack indisponível"],
status: "Indisponível",
demoUrl: "https://unavailable.example",
githubUrl: "https://github.com/example/unavailable",
demoAvailability: "unavailable",
demoCheckedAt: "2026-09-04T12:00:00.000Z",
},
{
...baseProject,
id: "unknown-demo",
title: "Demo desconhecida",
description: "Descrição desconhecida",
technologies: ["Stack desconhecida"],
status: "Não verificada",
demoUrl: "https://unknown.example",
githubUrl: "https://github.com/example/unknown",
demoAvailability: "unknown",
demoCheckedAt: null,
},
{
...baseProject,
id: "no-demo",
title: "Projeto sem demo",
description: "Descrição sem demo",
technologies: ["Stack sem demo"],
status: "Sem demo",
demoUrl: null,
githubUrl: "https://github.com/example/no-demo",
demoAvailability: "none",
demoCheckedAt: null,
},
];
describe("Projects", () => {
it("renders demo actions according to availability", () => {
render(<Projects projects={projects} />);
expect(
screen.getByRole("heading", { name: "Demo disponível" }),
).toBeVisible();
expect(screen.getByRole("link", { name: "Abrir projeto" })).toHaveAttribute(
"href",
"https://available.example",
);
const unavailable = screen.getByText(
"Demo indisponível na última verificação",
);
expect(unavailable).toHaveAttribute("aria-disabled", "true");
expect(unavailable).not.toHaveAttribute("href");
expect(unavailable.closest("a")).toBeNull();
expect(
screen.getByRole("link", {
name: /Abrir projeto.*Disponibilidade não verificada/i,
}),
).toHaveAttribute("href", "https://unknown.example");
const noDemoCard = screen
.getByRole("heading", { name: "Projeto sem demo" })
.closest("article");
expect(noDemoCard).not.toBeNull();
expect(
within(noDemoCard as HTMLElement).queryByRole("link", {
name: /Abrir projeto/i,
}),
).not.toBeInTheDocument();
});
it("keeps project evidence visible when the demo is unavailable", () => {
render(<Projects projects={projects} />);
const card = screen
.getByRole("heading", { name: "Demo indisponível" })
.closest("article");
expect(card).not.toBeNull();
const unavailableCard = within(card as HTMLElement);
expect(unavailableCard.getByText("Descrição indisponível")).toBeVisible();
expect(unavailableCard.getByText("Stack indisponível")).toBeVisible();
expect(unavailableCard.getByText("Indisponível")).toBeVisible();
expect(
unavailableCard.getByRole("link", { name: "Ver codigo" }),
).toHaveAttribute("href", "https://github.com/example/unavailable");
expect(
unavailableCard.queryByRole("link", { name: /Abrir projeto/i }),
).not.toBeInTheDocument();
});
});
+141 -95
View File
@@ -1,37 +1,28 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { PortfolioProject } from "@/lib/portfolio-data";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { caseStudies } from "@/lib/site-content";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { ProjectCover } from "./ProjectCover";
import { Section } from "./Section";
import { ProjectReel } from "./showcase/ProjectReel";
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
export default function Projects({
projects,
}: {
projects: PortfolioProject[];
projects: PortfolioProjectView[];
}) {
const shouldReduceMotion = useReducedMotion();
const gridVariants: Variants = {
hidden: {},
visible: {
transition: {
delayChildren: 0.08,
staggerChildren: 0.08,
},
},
};
const cardVariants: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 24, clipPath: "inset(100% 0 0 0)" },
hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
clipPath: "inset(0% 0 0 0)",
transition: shouldReduceMotion
? { duration: 0.25 }
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
@@ -41,92 +32,147 @@ export default function Projects({
return (
<Section
id="projects"
eyebrow="Selected work"
title="Projetos em Next.js, Tauri, Rust e integracao de sistemas"
description="Cases e produtos que mostram minha atuacao em engenharia de software, consultoria tecnica e construcao de sistemas de alta exigencia."
eyebrow="Projetos"
title="Veja o que já saiu do papel."
description="Sistemas e aplicações com contexto, decisões técnicas e limites documentados. Explore os cases para entender o problema e a solução, além da interface."
dark
disableChildrenAnimation
>
<motion.div
className="ds-grid-2"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={gridVariants}
>
{projects.map((project) => (
<motion.article
key={project.id}
className="ds-card h-full"
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 170, damping: 18 }}
>
<div className="flex items-start justify-between gap-sp-6">
<div className="max-w-[560px]">
<div className="ds-kicker">{project.category}</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">{project.title}</h3>
{project.subtitle && (
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
{project.subtitle}
</p>
<ShowcaseProvider projects={projects}>
<div className="ds-grid-fluid">
{projects.map((project) => {
const caseStudy = caseStudies.find(
(entry) => entry.id === project.id,
);
return (
<motion.article
key={project.id}
className="ds-card h-full"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: "some" }}
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 170, damping: 18 }}
>
<ProjectCover project={project} />
<div className="flex flex-wrap items-start justify-between gap-sp-6">
<div className="max-w-[560px]">
<div className="ds-kicker">{project.category}</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">{project.title}</h3>
{project.subtitle && (
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
{project.subtitle}
</p>
)}
</div>
<MagneticButton
as="span"
className="ds-tag"
strength={0.15}
wrapperClassName="shrink-0 max-sm:ml-auto"
>
{project.status}
</MagneticButton>
</div>
<p className="ds-copy">
{caseStudy?.summary ?? project.description}
</p>
{caseStudy && (
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">O desafio</p>
<p className="ds-copy">{caseStudy.impact}</p>
</div>
)}
{caseStudy?.role && (
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">Minha participação</p>
<p className="ds-copy">{caseStudy.role}</p>
</div>
)}
</div>
<MagneticButton as="span" className="ds-tag" strength={0.15}>
{project.status}
</MagneticButton>
</div>
<p className="ds-copy">{project.description}</p>
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{project.technologies.slice(0, 5).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
</li>
))}
</ul>
<ul className="ds-tags mt-sp-6">
{project.technologies.slice(0, 5).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
{project.githubUrl && (
<MagneticButton
as="a"
href={project.githubUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-muted"
strength={0.25}
<div
className="mt-sp-6 flex flex-wrap gap-sp-3"
data-reel="top"
>
Ver codigo
</MagneticButton>
)}
{project.demoUrl && (
<MagneticButton
as="a"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-accent"
strength={0.25}
>
Abrir projeto
</MagneticButton>
)}
</div>
</motion.article>
))}
</motion.div>
{caseStudy && (
<MagneticButton
as="a"
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
strength={0.2}
>
Entender a solução
</MagneticButton>
)}
{project.githubUrl && (
<MagneticButton
as="a"
href={project.githubUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-muted"
strength={0.25}
>
Ver codigo
</MagneticButton>
)}
{project.demoUrl &&
(project.demoAvailability === "available" ||
project.demoAvailability === "unknown") && (
<MagneticButton
as="a"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-accent"
strength={0.25}
>
Abrir projeto
{project.demoAvailability === "unknown" && (
<span className="sr-only">
{" "}
Disponibilidade não verificada
</span>
)}
</MagneticButton>
)}
{project.demoAvailability === "unavailable" && (
<span
className="ds-link ds-link-accent"
aria-disabled="true"
>
Demo indisponível na última verificação
</span>
)}
</div>
</motion.article>
);
})}
</div>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
<ProjectReel />
</ShowcaseProvider>
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
<MagneticButton
as="a"
href="/cases"
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import type { ReactNode } from "react";
import { useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
-39
View File
@@ -1,39 +0,0 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface RouteTransitionProps {
children: ReactNode;
}
export function RouteTransition({ children }: Readonly<RouteTransitionProps>) {
const pathname = usePathname();
const shouldReduceMotion = useReducedMotion();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -20 }}
transition={
shouldReduceMotion
? { duration: 0.2 }
: {
opacity: { duration: 0.3, ease: [0.16, 1, 0.3, 1] },
y: {
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
},
}
}
>
{children}
</motion.div>
</AnimatePresence>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { motion, type Variants } from "motion/react";
import type React from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { DrawLine } from "./animations/DrawLine";
+8 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { useId } from "react";
import { TopProgressBar } from "./animations/TopProgressBar";
interface SectionLoadingProps {
@@ -14,6 +15,11 @@ export function SectionLoading({
skeletons = 2,
}: Readonly<SectionLoadingProps>) {
const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section";
const skeletonId = useId();
const skeletonKeys = Array.from(
{ length: skeletons },
(_, index) => `${skeletonId}-${index}`,
);
return (
<section className={sectionClass}>
@@ -21,8 +27,8 @@ export function SectionLoading({
<div className="ds-section-shell">
<div className="ds-loading">{message}</div>
<div className="ds-grid-2 mt-sp-6" aria-hidden="true">
{Array.from({ length: skeletons }).map((_, index) => (
<div key={`skeleton-${index}`} className="ds-card ds-skeleton-card">
{skeletonKeys.map((skeletonKey) => (
<div key={skeletonKey} className="ds-card ds-skeleton-card">
<div className="ds-skeleton ds-skeleton-kicker" />
<div className="ds-skeleton ds-skeleton-title" />
<div className="ds-skeleton ds-skeleton-line" />
+8 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { PortfolioSkillCategory } from "@/lib/portfolio-data";
import { DrawLine } from "./animations/DrawLine";
@@ -41,8 +41,8 @@ export default function Skills({
<Section
id="skills"
eyebrow="Core stack"
title="Next.js, Tauri, Rust e stack para produtos criticos"
description="Tecnologias e fundamentos que uso em engenharia de software, arquitetura de software, IoT e integracoes complexas."
title="Next.js, Tauri, Rust e stack para produtos críticos"
description="Tecnologias e fundamentos que uso em engenharia de software, arquitetura de software, IoT e integrações complexas."
dark
disableChildrenAnimation
>
@@ -69,13 +69,15 @@ export default function Skills({
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<h3 className="ds-card-title !mt-0">{category.category}</h3>
<h3 className="ds-card-title !mt-0" data-reel="left">
{category.category}
</h3>
<DrawLine delay={0.05} />
<p className="ds-copy !mt-sp-4">
<p className="ds-copy !mt-sp-4" data-reel="right">
Ferramentas que uso para construir com clareza tecnica e ritmo de
entrega.
</p>
<ul className="ds-tags">
<ul className="ds-tags" data-reel-stagger="fade">
{category.skills.map((skill) => (
<li key={skill} className="ds-tag">
{skill}
+151
View File
@@ -0,0 +1,151 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import WhatsAppLink from "./WhatsAppLink";
const CONSENT_KEY = "evandro.analytics-consent.v1";
const DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.clear();
window.gtag = jest.fn();
delete window[DISABLE_KEY];
window.history.replaceState(
{},
"",
"/[email protected]#private",
);
});
afterEach(() => {
jest.restoreAllMocks();
delete window.gtag;
delete window[DISABLE_KEY];
localStorage.clear();
window.history.replaceState({}, "", "/");
});
function renderLink() {
render(
<WhatsAppLink service="Sistemas web" placement="service_hub_hero">
Conversar no WhatsApp
</WhatsAppLink>,
);
return screen.getByRole("link", { name: "Conversar no WhatsApp" });
}
it.each([null, "denied", "{malformed", '"granted"'])(
"keeps a native destination without collecting or replaying when consent is %s",
async (consent) => {
if (consent !== null) localStorage.setItem(CONSENT_KEY, consent);
const link = renderLink();
const url = new URL(link.getAttribute("href") || "");
expect(url.origin).toBe("https://wa.me");
expect(url.pathname).toMatch(/^\/\d+$/);
expect(url.searchParams.get("text")).toContain("Sistemas web");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(fireEvent.click(link, { button: 0 })).toBe(true);
expect(window.gtag).not.toHaveBeenCalled();
localStorage.setItem(CONSENT_KEY, "granted");
await Promise.resolve();
expect(window.gtag).not.toHaveBeenCalled();
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledTimes(1);
},
);
it("emits one GA4 handoff per primary, middle or keyboard activation without personal URL data", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
const link = renderLink();
await user.click(link);
expect(window.gtag).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenLastCalledWith("event", "whatsapp_open", {
send_to: "G-QTT9QT0T3S",
service: "Sistemas web",
placement: "service_hub_hero",
page_path: "/servicos",
page_location: "https://evandro.dev.br/servicos",
page_referrer: "",
});
expect(
fireEvent(
link,
new MouseEvent("auxclick", {
button: 1,
bubbles: true,
cancelable: true,
}),
),
).toBe(true);
expect(window.gtag).toHaveBeenCalledTimes(2);
fireEvent(link, new MouseEvent("auxclick", { button: 2, bubbles: true }));
expect(window.gtag).toHaveBeenCalledTimes(2);
link.focus();
await user.keyboard("{Enter}");
expect(window.gtag).toHaveBeenCalledTimes(3);
});
it("stops collecting immediately after revocation and honors the GA disabled flag", () => {
localStorage.setItem(CONSENT_KEY, "granted");
const link = renderLink();
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledTimes(1);
localStorage.setItem(CONSENT_KEY, "denied");
expect(fireEvent.click(link, { button: 0 })).toBe(true);
expect(window.gtag).toHaveBeenCalledTimes(1);
localStorage.setItem(CONSENT_KEY, "granted");
window[DISABLE_KEY] = true;
expect(fireEvent.click(link, { button: 0 })).toBe(true);
expect(window.gtag).toHaveBeenCalledTimes(1);
});
it("keeps navigation available when storage, analytics or its loader is blocked", () => {
localStorage.setItem(CONSENT_KEY, "granted");
const link = renderLink();
const storage = jest
.spyOn(Storage.prototype, "getItem")
.mockImplementation(() => {
throw new Error("storage blocked");
});
expect(fireEvent.click(link, { button: 0 })).toBe(true);
expect(window.gtag).not.toHaveBeenCalled();
storage.mockRestore();
delete window.gtag;
expect(fireEvent.click(link, { button: 0 })).toBe(true);
window.gtag = jest.fn(() => {
throw new Error("analytics blocked");
});
expect(fireEvent.click(link, { button: 0 })).toBe(true);
});
it("does not collect outside production", () => {
jest.replaceProperty(process.env, "NODE_ENV", "development");
localStorage.setItem(CONSENT_KEY, "granted");
expect(fireEvent.click(renderLink(), { button: 0 })).toBe(true);
expect(window.gtag).not.toHaveBeenCalled();
});
it("uses a route template rather than collecting arbitrary dynamic path segments", () => {
localStorage.setItem(CONSENT_KEY, "granted");
window.history.replaceState(
{},
"",
"/servicos/ana%40example.com?message=private",
);
fireEvent.click(renderLink(), { button: 0 });
expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", {
send_to: "G-QTT9QT0T3S",
service: "Sistemas web",
placement: "service_hub_hero",
page_path: "/servicos/[slug]",
page_location: "https://evandro.dev.br/servicos/[slug]",
page_referrer: "",
});
});
+38
View File
@@ -0,0 +1,38 @@
"use client";
import type { ReactNode } from "react";
import { trackWhatsAppOpen } from "@/lib/analytics";
import { buildWhatsAppUrl } from "@/lib/whatsapp";
interface WhatsAppLinkProps {
children: ReactNode;
className?: string;
service: string;
placement: string;
}
export default function WhatsAppLink({
children,
className = "",
service,
placement,
}: WhatsAppLinkProps) {
return (
<a
href={buildWhatsAppUrl(
`Olá, Evandro! Quero conversar sobre ${service}. Contato iniciado pelo site evandro.dev.br.`,
)}
target="_blank"
rel="noopener noreferrer"
className={`${className} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]`}
onClick={(event) => {
if (event.button === 0) trackWhatsAppOpen({ service, placement });
}}
onAuxClick={(event) => {
if (event.button === 1) trackWhatsAppOpen({ service, placement });
}}
>
{children}
</a>
);
}
+127 -72
View File
@@ -1,6 +1,6 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { motion, type Variants } from "motion/react";
import Link from "next/link";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { serviceHubLinks } from "@/lib/site-content";
@@ -11,7 +11,6 @@ import { Section } from "./Section";
interface Tier {
id: string;
badge: string;
level: string;
title: string;
tagline: string;
description: string;
@@ -25,64 +24,83 @@ interface Tier {
const TIERS: Tier[] = [
{
id: "discovery",
badge: "Entrada / Baixo Risco",
level: "01",
title: "Auditoria & Diagnostico Tecnico",
tagline: "Fotografia clara do problema antes de qualquer comprometimento.",
id: "build",
badge: "Do zero à produção",
title: "Um sistema para a sua operação",
tagline: "Quando as ferramentas prontas já não atendem.",
description:
"Analise profunda de arquitetura, performance ou seguranca com entrega de um plano de acao executavel. Ideal para quem precisa de direcao antes de contratar execucao.",
"Transforme regras de negócio, planilhas e processos dispersos em um sistema próprio, com os fluxos e as integrações que a empresa precisa.",
deliverables: [
"Relatorio de gargalos e divida tecnica",
"Mapeamento de riscos e dependencias",
"Sugestao de stack e cronograma de mitigacao",
"Plano de acao priorizado",
"Definição de escopo e arquitetura",
"Interface, backend e banco de dados",
"Integrações e permissões de acesso",
"Validação, publicação e documentação",
],
pricing: "Valor Fixo",
pricingNote: "Contrato unico. Entrega em ate 5 dias uteis.",
featured: false,
ctaLabel: "Solicitar diagnostico",
ctaHref: "#contact",
},
{
id: "cycles",
badge: "Mais Escolhido",
level: "02",
title: "Ciclos de Engenharia",
tagline: "Capacidade produtiva garantida. Entrega a cada 2 semanas.",
description:
"Desenvolvimento continuo com a previsibilidade de orcamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.",
deliverables: [
"Features em producao a cada ciclo",
"Refatoracao iterativa com rastreabilidade",
"Documentacao tecnica atualizada",
"Capacidade de pivotar prioridades no inicio de cada sprint",
],
pricing: "Fixo por Ciclo",
pricingNote: "Assinatura quinzenal ou mensal. Cancele a qualquer ciclo.",
pricing: "Proposta por projeto",
pricingNote: "Escopo, etapas e investimento definidos antes da execução.",
featured: true,
ctaLabel: "Iniciar ciclo de engenharia",
ctaHref: "#contact",
ctaLabel: "Conversar sobre meu sistema",
ctaHref: "/#contact",
},
{
id: "fractional",
badge: "Premium / High-Ticket",
level: "03",
title: "Fractional Tech Lead",
tagline: "Lideranca tecnica senior. Sem o custo de um CTO full-time.",
id: "discovery",
badge: "Antes de investir",
title: "Diagnóstico de um sistema existente",
tagline: "Entenda o que aproveitar e o que precisa mudar.",
description:
"Direcao arquitetural, gestao de risco e sustentacao de operacoes criticas. Para empresas que precisam de decisoes de alto impacto com responsabilidade continua.",
"Seu sistema ficou lento, frágil ou difícil de manter? Avalio o código e a operação para orientar a próxima decisão, inclusive em projetos iniciados com IA.",
deliverables: [
"SLA prioritario Zero-Risk",
"Code review e lideranca do time interno",
"Decisoes de infra: Cloud, VPS, CI/CD",
"Consultoria estrategica recorrente",
"Levantamento de riscos e dependências",
"Revisão de acesso aos dados",
"Análise dos gargalos da aplicação",
"Plano de correção por prioridade",
],
pricing: "Retainer Mensal",
pricingNote: "Renovacao mensal. Vagas limitadas.",
featured: false,
ctaLabel: "Agendar conversa estrategica",
ctaHref: "#contact",
pricing: "Escopo de análise definido",
pricingNote: "Profundidade e prazo combinados conforme o sistema.",
ctaLabel: "Avaliar meu projeto",
ctaHref: "/#contact",
},
{
id: "evolution",
badge: "Continuidade",
title: "Integração e evolução",
tagline: "Faça o que já existe trabalhar melhor.",
description:
"Conecte ferramentas, reduza tarefas manuais e acrescente funcionalidades ao sistema atual, com atenção às dependências e ao impacto de cada mudança.",
deliverables: [
"Integração entre sistemas e equipamentos",
"Novos fluxos e automações",
"Testes das partes afetadas",
"Planejamento de manutenção e operação",
],
pricing: "Etapas acordadas",
pricingNote:
"Prioridades, suporte e responsabilidades definidos em contrato.",
ctaLabel: "Planejar a próxima etapa",
ctaHref: "/#contact",
},
];
const PROCESS = [
{
title: "Entender a operação",
description:
"Quem usa, qual problema precisa resolver e quais dados e ferramentas já existem.",
},
{
title: "Definir a entrega",
description:
"Escopo, arquitetura, riscos, investimento e critérios de aceite antes de desenvolver.",
},
{
title: "Construir e validar",
description:
"Entregas por etapa, demonstrações e testes dos fluxos, das permissões e das integrações.",
},
{
title: "Publicar e dar continuidade",
description:
"Implantação, documentação e condições de manutenção combinadas para a operação.",
},
];
@@ -112,15 +130,12 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
<span className="ds-kicker max-md:text-[10px] max-md:leading-[1.6]">
{tier.badge}
</span>
<span className="text-[11px] tracking-[0.08em] text-[var(--meta)]">
{tier.level}
</span>
</div>
<DrawLine delay={0.04} />
<div className="mt-sp-4 min-w-0">
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(22px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(19px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
{tier.title}
</h3>
<p className="mt-sp-2 text-[12px] leading-[1.7] text-[var(--meta)] md:text-[11px]">
@@ -128,11 +143,17 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
</p>
</div>
<p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]">
<p
className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
data-reel="right"
>
{tier.description}
</p>
<ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]">
<ul
className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
data-reel-stagger="fade"
>
{tier.deliverables.map((deliverable) => (
<li key={deliverable} className="pl-sp-1">
{deliverable}
@@ -144,7 +165,7 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
<div className="font-display text-[clamp(17px,4.6vw,18px)] font-extrabold leading-none tracking-tight">
{tier.pricing}
</div>
<div className="text-[12px] leading-[1.7] text-[var(--meta)]">
<div className="font-display text-[12px] leading-[1.7] text-[var(--meta)]">
{tier.pricingNote}
</div>
</div>
@@ -193,37 +214,49 @@ export function WorkModalities() {
return (
<Section
id="modalities"
eyebrow="Working models"
title="Servicos de engenharia de software e consultoria tecnica."
description="Formatos de contratacao para arquitetura de software, integracoes criticas, produtos conectados e evolucao de sistemas em producao."
eyebrow="Soluções"
title="Software feito a partir do seu negócio."
description="Para empresas que precisam de um sistema próprio, de ferramentas conectadas ou de uma avaliação técnica antes de dar o próximo passo."
disableChildrenAnimation
>
<div className="mb-sp-8 ds-card bg-[rgba(10,10,10,0.02)]">
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[760px]">
<div className="ds-kicker">Paginas de servico</div>
<h3 className="ds-card-title !mt-sp-3">Especialidades organizadas por intencao de busca</h3>
<p className="ds-copy !mt-sp-3 max-w-[62ch]">
Se voce chegou buscando uma stack, um tipo de consultoria ou um contexto tecnico especifico,
aqui esta o caminho mais direto para a pagina certa.
<div className="ds-kicker">Reconhece algum destes cenários?</div>
<h3 className="ds-card-title !mt-sp-3" data-reel="left">
Planilhas demais. Sistemas isolados. Trabalho repetido.
</h3>
<p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
A operação cresceu, mas as ferramentas não acompanharam. Ou a
ideia precisa sair do protótipo para atender clientes. O projeto
começa pelo problema, sem exigir que você já saiba qual tecnologia
usar.
</p>
</div>
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-accent" strength={0.2}>
Ver hub de servicos
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-accent"
strength={0.2}
>
Conhecer todos os serviços
</MagneticButton>
</div>
<div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3">
<div
className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3"
data-reel-stagger="left"
>
{serviceHubLinks.map((service) => (
<Link
key={service.href}
href={service.href}
className="rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
className="min-w-0 rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
>
<div className="text-[12px] font-semibold tracking-[0.02em] text-[var(--ink)]">
<div className="font-display text-base font-semibold leading-snug text-[var(--ink)]">
{service.label}
</div>
<div className="mt-sp-2 text-[12px] leading-6 text-[var(--meta)]">
<div className="mt-sp-2 font-display text-sm leading-6 text-[var(--ink-80)]">
{service.description}
</div>
</Link>
@@ -244,6 +277,28 @@ export function WorkModalities() {
</motion.div>
))}
</motion.div>
<div id="process" className="mt-sp-12 scroll-mt-24">
<h3 className="ds-card-title">Como o projeto acontece</h3>
<p className="ds-copy max-w-[65ch]">
Você acompanha as decisões e valida cada etapa. Prazo e investimento
dependem do escopo, das integrações e dos riscos identificados.
</p>
<ol className="mt-sp-6 grid gap-sp-6 sm:grid-cols-2 xl:grid-cols-4">
{PROCESS.map((step, index) => (
<li
key={step.title}
className="border-t border-[var(--border)] pt-sp-4"
>
<span className="ds-kicker">
{String(index + 1).padStart(2, "0")}
</span>
<h4 className="mt-sp-3 text-base font-semibold">{step.title}</h4>
<p className="ds-copy">{step.description}</p>
</li>
))}
</ol>
</div>
</Section>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useInView } from "framer-motion";
import { useInView } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
@@ -42,7 +42,7 @@ export function CounterValue({
const frame = (now: number) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const eased = 1 - (1 - progress) ** 3;
setDisplayValue(Math.round(value * eased));
if (progress < 1) {
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
@@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import Image, { type StaticImageData } from "next/image";
import { useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
@@ -27,9 +27,7 @@ export function LazyRevealImage({
<motion.div
className={`relative ${className}`.trim()}
initial={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 20, scale: 1.02 }
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20, scale: 1.02 }
}
animate={
loaded
@@ -1,81 +0,0 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import { TextDiffuseReveal } from "./TextDiffuseReveal";
let mockReducedMotion = false;
let mockInView = true;
let mockAnimations: Array<{ animate?: Record<string, unknown> }> = [];
jest.mock("framer-motion", () => {
const React = require("react");
return {
motion: {
span: ({
children,
initial: _initial,
animate,
transition: _transition,
onAnimationComplete: _onAnimationComplete,
...props
}: {
children?: ReactNode;
initial?: unknown;
animate?: Record<string, unknown>;
transition?: unknown;
onAnimationComplete?: () => void;
} & Record<string, unknown>) => {
mockAnimations.push({ animate });
return React.createElement("span", props, children);
},
},
useInView: () => mockInView,
useReducedMotion: () => mockReducedMotion,
};
});
describe("TextDiffuseReveal reduced motion", () => {
beforeEach(() => {
mockReducedMotion = false;
mockInView = true;
mockAnimations = [];
});
it("clears blur and scale when reduced motion resolves after mount", () => {
const { rerender } = render(<TextDiffuseReveal text="Reveal" />);
mockAnimations = [];
mockReducedMotion = true;
rerender(<TextDiffuseReveal text="Reveal" />);
expect(mockAnimations).toHaveLength(6);
expect(mockAnimations).toEqual(
expect.arrayContaining(
Array.from({ length: 6 }, () =>
expect.objectContaining({
animate: {
opacity: 1,
filter: "blur(0px)",
scale: 1,
},
}),
),
),
);
});
it("keeps the first render independent of the reduced-motion preference", () => {
mockReducedMotion = true;
mockInView = false;
render(<TextDiffuseReveal text="Reveal" />);
expect(mockAnimations[0]).toEqual({
animate: {
filter: "blur(12px)",
opacity: 0,
scale: 0.9,
},
});
});
});
@@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react";
import { TextDiffuseReveal } from "./TextDiffuseReveal";
let mockInView = false;
jest.mock("motion/react", () => ({ useInView: () => mockInView }));
describe("TextDiffuseReveal", () => {
beforeEach(() => {
mockInView = false;
});
it("keeps the whole sentence readable once for assistive technology", () => {
const { container } = render(
<TextDiffuseReveal text="Software feito a partir do seu negócio" />,
);
expect(
screen.getByText("Software feito a partir do seu negócio"),
).toHaveClass("sr-only");
const words = container.querySelectorAll(".text-reveal-word");
expect(words).toHaveLength(7);
expect(words[0].closest("[aria-hidden='true']")).not.toBeNull();
});
it("marks the title as shown only after it enters the viewport", () => {
const { container, rerender } = render(
<TextDiffuseReveal text="Reveal me" />,
);
expect(container.querySelector(".text-reveal")).not.toHaveAttribute(
"data-shown",
);
mockInView = true;
rerender(<TextDiffuseReveal text="Reveal me" />);
expect(container.querySelector(".text-reveal")).toHaveAttribute(
"data-shown",
);
});
});
+32 -104
View File
@@ -1,8 +1,7 @@
"use client";
import { motion, useInView } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { useInView } from "motion/react";
import { type CSSProperties, useRef } from "react";
interface TextDiffuseRevealProps {
text: string;
@@ -12,8 +11,12 @@ interface TextDiffuseRevealProps {
amount?: number;
}
const EXPO_OUT = [0.16, 1, 0.3, 1] as const;
/**
* Título que entra palavra por palavra. A animação é CSS (.text-reveal em globals.css) com transparência
* e deslocamento: a placa de vídeo compõe sem redesenhar. A versão anterior animava cada letra com
* desfoque e renderizava o React de novo a cada letra, o que pesava justamente durante as transições.
* Movimento reduzido: o CSS mostra o texto direto, sem animação.
*/
export function TextDiffuseReveal({
text,
className = "",
@@ -22,111 +25,36 @@ export function TextDiffuseReveal({
amount = 0.3,
}: Readonly<TextDiffuseRevealProps>) {
const ref = useRef<HTMLSpanElement | null>(null);
const shouldReduceMotion = useReducedMotion();
const isInView = useInView(ref, { once, amount });
const [staggerDelay, setStaggerDelay] = useState(0.03);
const [completedChars, setCompletedChars] = useState(0);
useEffect(() => {
const media = window.matchMedia("(max-width: 768px)");
const updateStagger = () => setStaggerDelay(media.matches ? 0.015 : 0.03);
updateStagger();
media.addEventListener("change", updateStagger);
return () => media.removeEventListener("change", updateStagger);
}, []);
const tokens = useMemo(() => text.split(/(\n|\s+)/), [text]);
const animatedChars = useMemo(
() =>
Array.from(text).filter((char) => char !== "\n" && char !== " ").length,
[text],
);
useEffect(() => {
if (!isInView) {
setCompletedChars(0);
}
}, [isInView]);
const hasActiveWillChange =
!shouldReduceMotion && isInView && completedChars < animatedChars;
let animatedIndex = 0;
let word = 0;
return (
<span
ref={ref}
className={className}
aria-label={text}
style={{
willChange: hasActiveWillChange
? "filter, opacity, transform"
: undefined,
}}
className={`text-reveal ${className}`.trim()}
data-shown={isInView ? "" : undefined}
style={{ "--d": `${startDelay}s` } as CSSProperties}
>
{tokens.map((token, tokenIndex) => {
if (!token) {
return null;
}
if (token === "\n") {
return <br key={`line-${tokenIndex}`} />;
}
if (/^\s+$/.test(token)) {
return (
<span key={`space-${tokenIndex}`} aria-hidden="true">
{token.replace(/ /g, "\u00A0")}
</span>
);
}
return (
<span
key={`word-${tokenIndex}`}
className="inline-block whitespace-nowrap"
>
{Array.from(token).map((char, charIndex) => {
const currentIndex = animatedIndex;
animatedIndex += 1;
return (
<motion.span
key={`char-${tokenIndex}-${charIndex}-${char}`}
className="inline-block"
initial={
shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
animate={
isInView
? { filter: "blur(0px)", opacity: 1, scale: 1 }
: shouldReduceMotion
? { filter: "blur(0px)", opacity: 0, scale: 1 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
transition={{
duration: shouldReduceMotion ? 0.3 : 0.6,
ease: EXPO_OUT,
delay: startDelay + currentIndex * staggerDelay,
}}
onAnimationComplete={() => {
if (!shouldReduceMotion) {
setCompletedChars((count) =>
count < animatedChars ? count + 1 : count,
);
}
}}
>
{char}
</motion.span>
);
})}
</span>
);
})}
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{text
.split(/(\n|\s+)/)
.filter(Boolean)
.map((token, index) => {
const key = `${index}:${token}`;
if (token === "\n") return <br key={key} />;
if (/^\s+$/.test(token)) return " ";
return (
<span
key={key}
className="text-reveal-word"
style={{ "--i": word++ } as CSSProperties}
>
{token}
</span>
);
})}
</span>
</span>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface TopProgressBarProps {
@@ -1,4 +1,3 @@
import React from 'react';
export default function PointCloudScene() {
return <div data-testid="point-cloud-mock"></div>;
}
@@ -0,0 +1,13 @@
import { render } from "@testing-library/react";
import FilmGrain from "./FilmGrain";
describe("static cinematic effects", () => {
it("renders grain without a canvas", () => {
const grain = render(<FilmGrain />);
expect(grain.container.firstElementChild).toHaveAttribute(
"aria-hidden",
"true",
);
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,141 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import {
resetHomeArrivalState,
useHomeFragmentNavigation,
} from "./useHomeFragmentNavigation";
function HomeNavigation() {
const onLayoutReady = useHomeFragmentNavigation();
return (
<>
<button type="button" onClick={onLayoutReady}>
Establish layout
</button>
<a href="/#about">Sobre</a>
<a href="/#contact">Contato</a>
<section id="about" aria-label="Sobre">
<button type="button">Read biography</button>
</section>
<section id="contact" aria-label="Contato">
<button type="button">Send message</button>
</section>
</>
);
}
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
beforeEach(() => {
resetHomeArrivalState();
window.history.replaceState(null, "", "/");
HTMLElement.prototype.scrollIntoView = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
window.history.replaceState(null, "", "/");
if (originalScrollIntoView) {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}
});
describe("home fragment navigation", () => {
it("starts an unfragmented visit at the top after the home establishes layout", () => {
let restoredPosition = 3200;
jest
.spyOn(window, "scrollTo")
.mockImplementation((options: ScrollToOptions | number) => {
if (typeof options === "object")
restoredPosition = options.top ?? restoredPosition;
});
render(<HomeNavigation />);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(restoredPosition).toBe(0);
});
it("leaves a same-document return to the position the browser restores", () => {
const scrollTo = jest
.spyOn(window, "scrollTo")
.mockImplementation(() => {});
const firstVisit = render(<HomeNavigation />);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
firstVisit.unmount();
expect(window.history.scrollRestoration).not.toBe("manual");
scrollTo.mockClear();
render(<HomeNavigation />);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(scrollTo).not.toHaveBeenCalled();
expect(window.history.scrollRestoration).not.toBe("manual");
});
it("does not reset the visitor's position after intentional scrolling during startup", () => {
let position = 0;
jest
.spyOn(window, "scrollTo")
.mockImplementation((options: ScrollToOptions | number) => {
if (typeof options === "object") position = options.top ?? position;
});
render(<HomeNavigation />);
position = 600;
fireEvent.wheel(window, { deltaY: 600 });
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(position).toBe(600);
});
it("waits for the home layout on fragment arrival, then never steals focus on refresh", () => {
window.history.replaceState(null, "", "/#contact");
render(<HomeNavigation />);
const contact = screen.getByRole("region", { name: "Contato" });
expect(contact).not.toHaveFocus();
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(contact).toHaveFocus();
screen.getByRole("button", { name: "Read biography" }).focus();
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(
screen.getByRole("button", { name: "Read biography" }),
).toHaveFocus();
});
it("abandons a pending arrival when the visitor intentionally scrolls", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
const message = screen.getByRole("button", { name: "Send message" });
message.focus();
fireEvent.wheel(window, { deltaY: 200 });
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(message).toHaveFocus();
});
it("keeps repeated same-fragment links native while moving focus to the destination", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
const link = screen.getByRole("link", { name: "Sobre" });
link.focus();
expect(fireEvent.click(link)).toBe(true);
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
link.focus();
expect(fireEvent.click(link)).toBe(true);
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
link.focus();
fireEvent.click(link, { ctrlKey: true });
expect(link).toHaveFocus();
});
it("does not resurrect a stale fragment when the URL changes before readiness", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
const message = screen.getByRole("button", { name: "Send message" });
message.focus();
act(() => {
window.history.replaceState(null, "", "/#contact");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(message).toHaveFocus();
});
});
+43 -82
View File
@@ -1,109 +1,70 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useRef } from "react";
import type {
PortfolioExperience,
PortfolioProject,
PortfolioProjectView,
PortfolioSkillCategory,
} from "@/lib/portfolio-data";
import Hero from "../Hero";
import About from "../About";
import Contact from "../Contact";
import EngineeringWithAI from "../EngineeringWithAI";
import Experience from "../Experience";
import Footer from "../Footer";
import Hero, { type HeroStats } from "../Hero";
import LighthouseMetrics from "../LighthouseMetrics";
import Projects from "../Projects";
import { SectionLoading } from "../SectionLoading";
import CinematicSection from "./CinematicSection";
const About = dynamic(() => import("../About"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const Skills = dynamic(() => import("../Skills"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const Experience = dynamic(() => import("../Experience"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const WorkModalities = dynamic(
() => import("../WorkModalities").then((mod) => mod.WorkModalities),
{ loading: () => <SectionLoading message="Carregando..." /> },
);
const Contact = dynamic(() => import("../Contact"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const Footer = dynamic(() => import("../Footer"));
const ScrollVelocityText = dynamic(() => import("./ScrollVelocityText"), {
ssr: false,
});
const SectionDivider = dynamic(() => import("./SectionDivider"), {
ssr: false,
});
import Skills from "../Skills";
import { WorkModalities } from "../WorkModalities";
import ExperienceDisclosure from "./ExperienceDisclosure";
import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
export default function CinematicHome({
projects,
experiences,
skillCategories,
heroStats,
}: {
projects: PortfolioProject[];
heroStats?: HeroStats;
projects: PortfolioProjectView[];
experiences: PortfolioExperience[];
skillCategories: PortfolioSkillCategory[];
}) {
const onLayoutReady = useHomeFragmentNavigation();
const mainRef = useRef<HTMLElement>(null);
useCinematicScroll(mainRef);
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
useEffect(() => onLayoutReady(), [onLayoutReady]);
return (
<div className="flex min-h-screen flex-col overflow-x-hidden">
<main className="ds-main">
<Hero />
<div className="flex min-h-screen flex-col overflow-x-clip">
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. */}
<main ref={mainRef} className="ds-main relative z-0">
<Hero stats={heroStats} />
{/* Marquee divider after hero */}
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
<ScrollVelocityText text="SOFTWARE ENGINEERING" baseVelocity={-1.5} />
</div>
<Projects projects={projects} />
<CinematicSection effect="parallax-depth">
<WorkModalities />
<EngineeringWithAI />
<LighthouseMetrics />
<div className="bg-[var(--paper)]">
<About />
</CinematicSection>
<SectionDivider variant="waveform" />
<CinematicSection effect="clip-reveal">
<Skills categories={skillCategories} />
</CinematicSection>
<div className="overflow-hidden bg-[var(--ink)] py-4 text-[rgba(204,255,0,0.04)]">
<ScrollVelocityText
text="NEXT.JS TAURI RUST NODE.JS TYPESCRIPT"
baseVelocity={1}
/>
<ExperienceDisclosure>
<Skills categories={skillCategories} />
<Experience experiences={experiences} />
</ExperienceDisclosure>
</div>
<CinematicSection effect="fade-scale">
<Experience experiences={experiences} />
</CinematicSection>
<SectionDivider variant="gradient" />
<CinematicSection effect="rotate-in">
<Projects projects={projects} />
</CinematicSection>
<CinematicSection effect="clip-reveal">
<LighthouseMetrics />
</CinematicSection>
<SectionDivider variant="pulse" />
<CinematicSection effect="parallax-depth">
<WorkModalities />
</CinematicSection>
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
<ScrollVelocityText text="VAMOS CONVERSAR" baseVelocity={-2} />
</div>
<CinematicSection effect="fade-scale">
<Contact />
</CinematicSection>
<Contact />
</main>
<Footer />
<div className="relative z-10">
<Footer />
</div>
</div>
);
}
+13 -9
View File
@@ -1,21 +1,25 @@
"use client";
import dynamic from "next/dynamic";
import PageIntro from "./PageIntro";
import { usePathname } from "next/navigation";
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false });
const HomeButton = dynamic(() => import("./HomeButton"), { ssr: false });
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
ssr: false,
});
export default function CinematicOverlays() {
const pathname = usePathname();
const isHome = pathname === "/";
return (
<>
<PageIntro />
<CustomCursor />
<FilmGrain />
<ScrollProgress />
<HomeButton />
{isHome ? (
<>
<FilmGrain />
<ScrollProgress />
</>
) : null}
</>
);
}
@@ -1,173 +0,0 @@
"use client";
import { useRef, useEffect, type ReactNode } from "react";
interface CinematicSectionProps {
children: ReactNode;
className?: string;
effect?: "fade-scale" | "clip-reveal" | "parallax-depth" | "rotate-in";
id?: string;
}
export default function CinematicSection({
children,
className = "",
effect = "fade-scale",
id,
}: CinematicSectionProps) {
const sectionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
// Check reduced motion preference
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
switch (effect) {
case "fade-scale": {
gsap.set(el, {
opacity: 0,
scale: 0.92,
transformOrigin: "center center",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 85%",
end: "top 20%",
scrub: 0.8,
},
});
tl.to(el, {
opacity: 1,
scale: 1,
ease: "power2.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "clip-reveal": {
gsap.set(el, {
clipPath: "inset(15% 5% 15% 5%)",
opacity: 0.3,
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
end: "top 15%",
scrub: 1,
},
});
tl.to(el, {
clipPath: "inset(0% 0% 0% 0%)",
opacity: 1,
ease: "power3.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "parallax-depth": {
gsap.set(el, {
y: 80,
opacity: 0,
rotateX: 4,
transformPerspective: 1200,
transformOrigin: "center bottom",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 90%",
end: "top 30%",
scrub: 0.6,
},
});
tl.to(el, {
y: 0,
opacity: 1,
rotateX: 0,
ease: "power2.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "rotate-in": {
gsap.set(el, {
opacity: 0,
rotateY: -5,
x: -60,
transformPerspective: 1000,
transformOrigin: "left center",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
end: "top 25%",
scrub: 0.8,
},
});
tl.to(el, {
opacity: 1,
rotateY: 0,
x: 0,
ease: "power3.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
}
};
void setup();
return () => {
cleanup?.();
};
}, [effect]);
return (
<div ref={sectionRef} className={className} id={id}>
{children}
</div>
);
}
-119
View File
@@ -1,119 +0,0 @@
"use client";
import { useRef, useEffect } from "react";
interface CinematicTextProps {
text: string;
className?: string;
tag?: "h1" | "h2" | "h3" | "p" | "span";
delay?: number;
splitBy?: "chars" | "words" | "lines";
}
export default function CinematicText({
text,
className = "",
tag: Tag = "h2",
delay = 0,
splitBy = "chars",
}: CinematicTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
const spans = el.querySelectorAll("[data-char]");
if (spans.length === 0) return;
gsap.set(spans, {
opacity: 0,
y: 60,
rotateX: -90,
transformOrigin: "center bottom",
transformPerspective: 600,
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
toggleActions: "play none none none",
},
});
tl.to(spans, {
opacity: 1,
y: 0,
rotateX: 0,
duration: 0.8,
ease: "back.out(1.2)",
stagger: splitBy === "chars" ? 0.02 : splitBy === "words" ? 0.06 : 0.12,
delay,
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
};
void setup();
return () => {
cleanup?.();
};
}, [text, delay, splitBy]);
const renderContent = () => {
if (splitBy === "chars") {
return text.split("").map((char, i) => (
<span
key={`${char}-${i}`}
data-char
className="inline-block will-change-transform"
style={{ whiteSpace: char === " " ? "pre" : undefined }}
>
{char === " " ? "\u00A0" : char}
</span>
));
}
if (splitBy === "words") {
return text.split(" ").map((word, i) => (
<span key={`${word}-${i}`} className="inline-block overflow-hidden">
<span data-char className="inline-block will-change-transform">
{word}
</span>
{i < text.split(" ").length - 1 && "\u00A0"}
</span>
));
}
// lines
return text.split("\n").map((line, i) => (
<span key={`${line}-${i}`} className="block overflow-hidden">
<span data-char className="block will-change-transform">
{line}
</span>
</span>
));
};
return (
<div ref={containerRef} className="overflow-hidden">
<Tag className={className}>{renderContent()}</Tag>
</div>
);
}
-159
View File
@@ -1,159 +0,0 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { motion, useMotionValue } from "framer-motion";
export default function CustomCursor() {
const [isHovering, setIsHovering] = useState(false);
const [isClicking, setIsClicking] = useState(false);
const [hoverText, setHoverText] = useState("");
const [isVisible, setIsVisible] = useState(false);
const cursorX = useMotionValue(0);
const cursorY = useMotionValue(0);
const rafRef = useRef<number>(0);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
cursorX.set(e.clientX);
cursorY.set(e.clientY);
if (!isVisible) setIsVisible(true);
});
},
[cursorX, cursorY, isVisible],
);
useEffect(() => {
// Only show custom cursor on desktop with fine pointer
const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
if (!hasFinePointer) return;
const handleDown = () => setIsClicking(true);
const handleUp = () => setIsClicking(false);
const handleLeave = () => setIsVisible(false);
const handleEnter = () => setIsVisible(true);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mousedown", handleDown);
window.addEventListener("mouseup", handleUp);
document.addEventListener("mouseleave", handleLeave);
document.addEventListener("mouseenter", handleEnter);
// Detect hoverable elements
const observer = new MutationObserver(() => {
attachHoverListeners();
});
observer.observe(document.body, { childList: true, subtree: true });
attachHoverListeners();
function attachHoverListeners() {
const hoverables = document.querySelectorAll(
'a, button, [role="button"], input, textarea, select, [data-cursor]',
);
for (const el of hoverables) {
el.addEventListener("mouseenter", handleElementEnter as EventListener);
el.addEventListener("mouseleave", handleElementLeave as EventListener);
}
}
function handleElementEnter(e: Event) {
const el = e.currentTarget as HTMLElement;
setIsHovering(true);
const cursorData = el.getAttribute("data-cursor");
if (cursorData) setHoverText(cursorData);
}
function handleElementLeave() {
setIsHovering(false);
setHoverText("");
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mousedown", handleDown);
window.removeEventListener("mouseup", handleUp);
document.removeEventListener("mouseleave", handleLeave);
document.removeEventListener("mouseenter", handleEnter);
observer.disconnect();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [handleMouseMove]);
// Don't render on touch devices
if (
typeof window !== "undefined" &&
!window.matchMedia("(pointer: fine)").matches
) {
return null;
}
return (
<>
<style jsx global>{`
@media (pointer: fine) {
* { cursor: none !important; }
}
`}</style>
{/* Dot cursor */}
<motion.div
className="pointer-events-none fixed top-0 left-0 z-[9999] mix-blend-difference"
style={{
x: cursorX,
y: cursorY,
translateX: "-50%",
translateY: "-50%",
}}
animate={{
scale: isClicking ? 0.5 : isHovering ? 0.5 : 1,
opacity: isVisible ? 1 : 0,
}}
transition={{ duration: 0.15 }}
>
<div
className="rounded-full bg-[#ccff00]"
style={{ width: 8, height: 8 }}
/>
</motion.div>
{/* Ring cursor */}
<motion.div
className="pointer-events-none fixed top-0 left-0 z-[9998] mix-blend-difference"
style={{
x: cursorX,
y: cursorY,
translateX: "-50%",
translateY: "-50%",
}}
animate={{
width: isHovering ? 80 : 40,
height: isHovering ? 80 : 40,
opacity: isVisible ? (isHovering ? 0.8 : 0.4) : 0,
borderWidth: isHovering ? 2 : 1,
}}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
>
<div
className="flex h-full w-full items-center justify-center rounded-full border-[#ccff00]"
style={{
borderStyle: "solid",
borderColor: "#ccff00",
borderWidth: "inherit",
}}
>
{hoverText && (
<motion.span
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
className="text-[9px] font-bold uppercase tracking-widest text-[#ccff00]"
>
{hoverText}
</motion.span>
)}
</div>
</motion.div>
</>
);
}
@@ -0,0 +1,63 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
const TARGETS = new Set(["#experience", "#skills"]);
/**
* Skills e Experiência ficavam numa gaveta sempre fechada e sem link no menu.
* Agora abre sozinha em telas largas (onde há espaço) e quando um link aponta para #experience ou #skills;
* no celular continua recolhida para a página não crescer.
*/
export default function ExperienceDisclosure({
children,
}: {
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
useEffect(() => {
const reveal = (hash: string) => {
if (!TARGETS.has(hash)) return;
setOpen(true);
// espera o React abrir a gaveta para o alvo existir no layout
requestAnimationFrame(() =>
document
.getElementById(hash.slice(1))
?.scrollIntoView({ behavior: "instant", block: "start" }),
);
};
if (window.matchMedia("(min-width: 1024px)").matches) setOpen(true);
else if (TARGETS.has(window.location.hash)) setOpen(true);
const onClick = (event: MouseEvent) => {
const link =
event.target instanceof Element
? event.target.closest("a[href*='#']")
: null;
if (!(link instanceof HTMLAnchorElement)) return;
const url = new URL(link.href);
if (url.pathname === window.location.pathname) reveal(url.hash);
};
const onHashChange = () => reveal(window.location.hash);
document.addEventListener("click", onClick);
window.addEventListener("hashchange", onHashChange);
return () => {
document.removeEventListener("click", onClick);
window.removeEventListener("hashchange", onHashChange);
};
}, []);
return (
<details
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
className="ds-section !pt-0"
>
<summary className="ds-section-shell cursor-pointer py-sp-5 text-lg font-semibold">
Experiência e tecnologias por trás das entregas
</summary>
{children}
</details>
);
}
+1 -63
View File
@@ -1,65 +1,3 @@
"use client";
import { useEffect, useRef } from "react";
export default function FilmGrain() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
let lastFrame = 0;
const fps = 24; // Cinematic film fps
const frameInterval = 1000 / fps;
const resize = () => {
canvas.width = window.innerWidth / 2;
canvas.height = window.innerHeight / 2;
};
resize();
window.addEventListener("resize", resize);
const render = (timestamp: number) => {
animId = requestAnimationFrame(render);
const delta = timestamp - lastFrame;
if (delta < frameInterval) return;
lastFrame = timestamp - (delta % frameInterval);
const { width, height } = canvas;
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const noise = Math.random() * 20;
data[i] = noise;
data[i + 1] = noise;
data[i + 2] = noise;
data[i + 3] = 12; // Very subtle
}
ctx.putImageData(imageData, 0, 0);
};
animId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
};
}, []);
return (
<canvas
ref={canvasRef}
className="pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
style={{ imageRendering: "pixelated" }}
aria-hidden="true"
/>
);
return <div aria-hidden="true" className="film-grain" />;
}
@@ -1,156 +0,0 @@
"use client";
import { useRef, useMemo } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { Float, MeshDistortMaterial, MeshTransmissionMaterial } from "@react-three/drei";
import * as THREE from "three";
function GlassSphere({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (!meshRef.current) return;
const t = state.clock.elapsedTime;
meshRef.current.rotation.x = Math.sin(t * 0.3) * 0.2;
meshRef.current.rotation.z = Math.cos(t * 0.2) * 0.1;
});
return (
<Float speed={1.5} rotationIntensity={0.3} floatIntensity={0.8}>
<mesh ref={meshRef} position={position} scale={scale}>
<sphereGeometry args={[1, 32, 32]} />
<MeshDistortMaterial
color="#ccff00"
transparent
opacity={0.08}
distort={0.4}
speed={2}
wireframe
/>
</mesh>
</Float>
);
}
function WireframeRing({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (!meshRef.current) return;
const t = state.clock.elapsedTime;
meshRef.current.rotation.x = t * 0.1;
meshRef.current.rotation.y = t * 0.15;
});
return (
<Float speed={1} rotationIntensity={0.5} floatIntensity={0.4}>
<mesh ref={meshRef} position={position} scale={scale}>
<torusGeometry args={[1, 0.02, 16, 100]} />
<meshStandardMaterial
color="#ccff00"
transparent
opacity={0.15}
emissive="#ccff00"
emissiveIntensity={0.1}
/>
</mesh>
</Float>
);
}
function DataStream({ count = 50 }: { count?: number }) {
const pointsRef = useRef<THREE.Points>(null);
const positions = useMemo(() => {
const pos = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
// Create a spiral/helix pattern
const angle = (i / count) * Math.PI * 8;
const radius = 2 + Math.sin(angle * 0.5) * 0.5;
pos[i3] = Math.cos(angle) * radius;
pos[i3 + 1] = (i / count - 0.5) * 12;
pos[i3 + 2] = Math.sin(angle) * radius;
}
return pos;
}, [count]);
useFrame((state) => {
if (!pointsRef.current) return;
const t = state.clock.elapsedTime;
pointsRef.current.rotation.y = t * 0.05;
const posAttr = pointsRef.current.geometry.getAttribute("position") as THREE.BufferAttribute;
const arr = posAttr.array as Float32Array;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
arr[i3 + 1] += 0.02;
if (arr[i3 + 1] > 6) arr[i3 + 1] = -6;
}
posAttr.needsUpdate = true;
});
return (
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
count={count}
/>
</bufferGeometry>
<pointsMaterial
color="#ccff00"
size={0.04}
transparent
opacity={0.4}
sizeAttenuation
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
);
}
interface FloatingElementsProps {
variant?: "sphere" | "ring" | "data" | "combined";
className?: string;
}
export default function FloatingElements({
variant = "combined",
className = "",
}: FloatingElementsProps) {
return (
<div className={`pointer-events-none absolute inset-0 ${className}`}>
<Canvas
camera={{ position: [0, 0, 6], fov: 50 }}
dpr={[1, 1.5]}
gl={{ antialias: false, alpha: true, powerPreference: "high-performance" }}
style={{ background: "transparent" }}
>
<ambientLight intensity={0.2} />
<pointLight position={[5, 5, 5]} intensity={0.3} color="#ccff00" />
{(variant === "sphere" || variant === "combined") && (
<>
<GlassSphere position={[3, 1, -2]} scale={0.8} />
<GlassSphere position={[-4, -1, -3]} scale={1.2} />
</>
)}
{(variant === "ring" || variant === "combined") && (
<>
<WireframeRing position={[0, 0, -2]} scale={1.5} />
<WireframeRing position={[-3, 2, -4]} scale={0.8} />
</>
)}
{(variant === "data" || variant === "combined") && (
<DataStream count={60} />
)}
</Canvas>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
/** Etiquetas presas às camadas da cena (a cena calcula a posição de cada uma a cada quadro). */
const TAGS = [
["A tela", "o que sua equipe e seus clientes usam"],
["As integrações", "seus sistemas conversando entre si"],
["Os dados", "suas informações organizadas"],
] as const;
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
ssr: false,
});
function supportsWebGL2() {
if (typeof window.WebGL2RenderingContext === "undefined") return false;
try {
const context = document.createElement("canvas").getContext("webgl2");
context?.getExtension("WEBGL_lose_context")?.loseContext();
return context !== null;
} catch {
return false;
}
}
/**
* Fundo do hero: liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho)
* e monta a cena WebGL só quando a página fica ociosa, com WebGL2 e sem preferência por movimento reduzido.
*/
export default function HeroField() {
const rootRef = useRef<HTMLDivElement>(null);
const progressRef = useRef(0);
const labelsRef = useRef<(HTMLElement | null)[]>([]);
const [mount, setMount] = useState(false);
const [visible, setVisible] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
// fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU
const [done, setDone] = useState(false);
useEffect(() => {
const section = rootRef.current?.closest<HTMLElement>(".hero");
if (!section) return;
let frame = 0;
const update = () => {
frame = 0;
const rect = section.getBoundingClientRect();
const span = Math.max(
rect.height - window.innerHeight,
window.innerHeight * 0.8,
);
const p = Math.min(1, Math.max(0, -rect.top / span));
progressRef.current = p;
section.style.setProperty("--hero-p", p.toFixed(4));
setDone(p >= 0.999);
};
const queue = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", queue, { passive: true });
window.addEventListener("resize", queue);
let intersecting = false;
const sync = () => setVisible(intersecting && !document.hidden);
const observer = new IntersectionObserver(([entry]) => {
intersecting = entry.isIntersecting;
sync();
});
observer.observe(section);
document.addEventListener("visibilitychange", sync);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", queue);
window.removeEventListener("resize", queue);
observer.disconnect();
document.removeEventListener("visibilitychange", sync);
};
}, []);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (!supportsWebGL2()) return;
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
if (typeof window.requestIdleCallback === "function") {
const id = window.requestIdleCallback(() => setMount(true), {
timeout: 2500,
});
return () => window.cancelIdleCallback(id);
}
const id = setTimeout(() => setMount(true), 1200);
return () => clearTimeout(id);
}, []);
const handleReady = useCallback(() => setReady(true), []);
const handleError = useCallback(() => setFailed(true), []);
return (
<div
ref={rootRef}
aria-hidden="true"
className="hero-field"
data-ready={ready && !failed ? "" : undefined}
>
{mount && !failed && (
<Scene
progressRef={progressRef}
labelsRef={labelsRef}
visible={visible && !done}
onReady={handleReady}
onError={handleError}
/>
)}
{mount &&
!failed &&
TAGS.map(([title, text], index) => (
<span
key={title}
ref={(element) => {
labelsRef.current[index] = element;
}}
className="hero-tag"
>
<span className="hero-tag-title">{title}</span>
{text}
</span>
))}
</div>
);
}
+625
View File
@@ -0,0 +1,625 @@
"use client";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
Component,
type ReactNode,
type RefObject,
useEffect,
useMemo,
useRef,
} from "react";
import * as THREE from "three";
export type HeroFieldSceneProps = {
progressRef: RefObject<number>;
/** etiquetas em HTML presas às camadas (tela, integrações, dados), posicionadas a cada quadro */
labelsRef: RefObject<(HTMLElement | null)[]>;
visible: boolean;
onReady: () => void;
onError: () => void;
};
/*
* Um sistema desenhado com pontos, em três camadas que um leigo reconhece:
* em cima, a TELA de um aplicativo (janela, título, botão, cards); no meio, as INTEGRAÇÕES (sistemas
* ligados a uma central, com mensagens correndo pelas linhas); embaixo, os DADOS (uma tabela).
* Colunas de partículas sobem da tabela até a tela: a informação chegando a quem usa.
* Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam, ficam de
* frente e a câmera atravessa uma a uma (as legendas de HeroField explicam cada camada nessa hora).
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
*/
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
type PointStyle = { tone?: number; size?: number; alpha?: number };
class FieldBuilder {
base: number[] = [];
to: number[] = [];
move: number[] = [];
tone: number[] = [];
size: number[] = [];
alpha: number[] = [];
constructor(private density: number) {}
point(
x: number,
y: number,
z: number,
style: PointStyle = {},
to?: [number, number, number],
) {
this.base.push(x, y, z);
this.to.push(...(to ?? [x, y, z]));
this.move.push(to ? 1 : 0);
this.tone.push(style.tone ?? 0);
this.size.push(style.size ?? 0.02);
this.alpha.push(style.alpha ?? 0.7);
}
line(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
const s = step * this.density;
const n = Math.max(1, Math.round(Math.hypot(x1 - x0, z1 - z0) / s));
for (let i = 0; i <= n; i++)
this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style);
}
rect(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
this.line(y, x0, z0, x1, z0, step, style);
this.line(y, x1, z0, x1, z1, step, style);
this.line(y, x1, z1, x0, z1, step, style);
this.line(y, x0, z1, x0, z0, step, style);
}
fill(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
const s = step * this.density;
for (let x = x0; x <= x1 + 1e-6; x += s)
for (let z = z0; z <= z1 + 1e-6; z += s)
this.point(
x + (Math.random() - 0.5) * s * 0.3,
y,
z + (Math.random() - 0.5) * s * 0.3,
style,
);
}
ring(
y: Layer,
cx: number,
cz: number,
r: number,
step: number,
style?: PointStyle,
) {
const n = Math.max(
8,
Math.round((Math.PI * 2 * r) / (step * this.density)),
);
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
this.point(cx + Math.cos(a) * r, y, cz + Math.sin(a) * r, style);
}
}
disc(
y: Layer,
cx: number,
cz: number,
r: number,
step: number,
style?: PointStyle,
) {
for (let rr = r; rr > 0.005; rr -= step * this.density)
this.ring(y, cx, cz, rr, step, style);
this.point(cx, y, cz, style);
}
/** partículas que correm de a até b (a posição é calculada no shader) */
flow(
a: [number, number, number],
b: [number, number, number],
count: number,
style?: PointStyle,
) {
const n = Math.max(2, Math.round(count / this.density));
for (let i = 0; i < n; i++) this.point(...a, style, b);
}
}
// z negativo = topo da tela quando a camada fica de frente para a câmera
function drawScreen(f: FieldBuilder) {
const y: Layer = 1;
f.fill(y, -2.1, -1.4, 2.1, 1.4, 0.1, { alpha: 0.1 }); // superfície da tela
f.rect(y, -2.1, -1.4, 2.1, 1.4, 0.026, { alpha: 0.95 });
f.line(y, -2.1, -1.05, 2.1, -1.05, 0.03, { alpha: 0.6 }); // barra do navegador
for (const x of [-1.9, -1.72, -1.54])
f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 });
f.rect(y, -1.1, -1.32, 1.3, -1.14, 0.035, { alpha: 0.45 }); // endereço
f.fill(y, -1.8, -0.82, 0.25, -0.58, 0.03, { alpha: 0.85 }); // título
f.line(y, -1.8, -0.38, 0.75, -0.38, 0.04, { alpha: 0.5 }); // texto
f.line(y, -1.8, -0.24, 0.35, -0.24, 0.04, { alpha: 0.5 });
f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, {
tone: 1,
alpha: 0.95,
size: 0.022,
}); // botão
f.rect(y, 0.6, -0.82, 1.8, 0.2, 0.035, { alpha: 0.6 }); // imagem
f.fill(y, 0.6, -0.82, 1.8, 0.2, 0.08, { alpha: 0.25 });
for (const [x0, x1] of [
[-1.8, -0.7],
[-0.55, 0.55],
[0.7, 1.8],
]) {
f.rect(y, x0, 0.45, x1, 1.2, 0.035, { alpha: 0.55 }); // cards
f.line(y, x0 + 0.12, 0.62, x1 - 0.3, 0.62, 0.045, { alpha: 0.5 });
f.line(y, x0 + 0.12, 0.76, x1 - 0.5, 0.76, 0.045, { alpha: 0.35 });
}
}
/** posições dos sistemas ligados à central; as colunas de dados sobem por eles */
const NODES: [number, number][] = Array.from({ length: 6 }, (_, k) => {
const a = (k / 6) * Math.PI * 2 + Math.PI / 6;
return [Math.cos(a) * 1.7, Math.sin(a) * 1.05];
});
function drawIntegrations(f: FieldBuilder) {
const y: Layer = 0;
f.ring(y, 0, 0, 0.34, 0.02, { tone: 1, alpha: 0.95, size: 0.024 }); // central
f.disc(y, 0, 0, 0.2, 0.035, { tone: 1, alpha: 0.5 });
NODES.forEach(([x, z], k) => {
f.ring(y, x, z, 0.2, 0.02, { tone: 1, alpha: 0.9, size: 0.022 });
f.disc(y, x, z, 0.1, 0.03, { alpha: 0.6 });
const len = Math.hypot(x, z);
const ux = x / len;
const uz = z / len;
// linha da central até o sistema, e mensagens correndo nos dois sentidos
f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, {
alpha: 0.35,
});
f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, {
tone: 1,
alpha: 0.95,
size: 0.026,
});
f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, {
alpha: 0.9,
size: 0.024,
});
const [nx, nz] = NODES[(k + 1) % NODES.length];
f.line(y, x, z, nx, nz, 0.08, { alpha: 0.15 });
});
}
function drawData(f: FieldBuilder) {
const y: Layer = -1;
const x0 = -2;
const x1 = 2;
const z0 = -1.3;
const z1 = 1.3;
const cols = [x0, -0.9, 0.2, 1.1, x1];
f.rect(y, x0, z0, x1, z1, 0.03, { alpha: 0.8 });
f.fill(y, x0, z0, x1, z0 + 0.32, 0.045, { tone: 0.6, alpha: 0.45 }); // cabeçalho da tabela
for (let z = z0 + 0.32; z < z1; z += 0.33)
f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 });
for (const x of cols.slice(1, -1))
f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 });
for (let z = z0 + 0.48; z < z1; z += 0.33) {
for (let c = 0; c < cols.length - 1; c++) {
const start = cols[c] + 0.12;
const width =
(cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6);
f.line(y, start, z, start + width, z, 0.045, { alpha: 0.55 }); // valores nas células
}
}
}
function buildField(dense: boolean) {
const f = new FieldBuilder(dense ? 1 : 1.5);
drawScreen(f);
drawIntegrations(f);
drawData(f);
// colunas: a informação sobe dos dados, passa pelos sistemas e chega à tela
for (const [x, z] of [[0, 0] as [number, number], ...NODES])
f.flow([x, -1, z], [x, 1, z], 40, { tone: 1, alpha: 0.6, size: 0.022 });
const count = f.move.length;
const scatter = new Float32Array(count * 3);
const rand = new Float32Array(count * 4);
for (let n = 0; n < count; n++) {
const u = Math.random() * 2 - 1;
const t = Math.random() * Math.PI * 2;
const s = Math.sqrt(1 - u * u);
const d = 4 + Math.random() * 7;
scatter.set([Math.cos(t) * s * d, u * d, Math.sin(t) * s * d], n * 3);
rand.set(
[Math.random(), Math.random(), Math.random(), Math.random()],
n * 4,
);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(f.base, 3),
);
geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3));
geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1));
geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1));
geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(f.size, 1));
geometry.setAttribute("aAlpha", new THREE.Float32BufferAttribute(f.alpha, 1));
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4));
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12);
return geometry;
}
const vertexShader = /* glsl */ `
uniform float uTime;
uniform float uIntro;
uniform float uSpread;
uniform float uScroll;
uniform float uScale;
uniform float uFocus;
uniform float uFade;
uniform vec2 uMouse;
uniform float uMouseAmt;
attribute vec3 aTo;
attribute float aMove;
attribute float aTone;
attribute float aSize;
attribute float aAlpha;
attribute vec3 aScatter;
attribute vec4 aRand;
varying vec3 vColor;
varying float vAlpha;
varying float vSoft;
const vec3 PAPER = vec3(0.957, 0.949, 0.929);
const vec3 LIME = vec3(0.8, 1.0, 0.0);
void main() {
// partículas em movimento correm de position até aTo e recomeçam
float f = fract(uTime * (0.1 + aRand.x * 0.08) + aRand.y);
vec3 b = mix(position, aTo, aMove * f);
vec3 p = vec3(b.x, b.y * uSpread, b.z);
float still = 1.0 - aMove;
float wave = sin(b.x * 1.7 + uTime * 0.55 + b.y * 2.1) * cos(b.z * 1.3 - uTime * 0.4);
p.y += wave * (0.02 + uScroll * 0.2) * still;
// cursor: uma onda que levanta a camada e acende os pontos em verde
float d = length(b.xz - uMouse);
float lift = exp(-d * d * 2.2) * uMouseAmt * still;
p.y += lift * 0.3 + sin(d * 7.0 - uTime * 3.5) * lift * 0.05;
// entrada: cada ponto sai da nuvem com atraso próprio
float t = clamp((uIntro - aRand.z * 0.4) / 0.6, 0.0, 1.0);
float e = 1.0 - pow(1.0 - t, 4.0);
p = mix(aScatter, p, e);
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mv;
float depth = -mv.z;
float blur = clamp(abs(depth - uFocus) * 0.3, 0.0, 1.6);
float size = aSize * (0.8 + aRand.w * 0.4);
gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.3), 64.0);
float alpha = aAlpha * mix(1.0, sin(f * 3.14159), aMove);
alpha *= (0.25 + 0.75 * e) / (1.0 + blur * 1.5);
alpha *= smoothstep(0.25, 1.6, depth) * uFade;
vAlpha = alpha;
vSoft = clamp(0.35 + blur * 0.45, 0.35, 1.0);
vColor = mix(PAPER, LIME, clamp(aTone + lift * 1.6, 0.0, 1.0));
}
`;
const fragmentShader = /* glsl */ `
varying vec3 vColor;
varying float vAlpha;
varying float vSoft;
void main() {
float r = length(gl_PointCoord - 0.5) * 2.0;
float a = 1.0 - smoothstep(1.0 - vSoft, 1.0, r);
if (a <= 0.0) discard;
gl_FragColor = vec4(vColor, a * vAlpha);
}
`;
const smooth = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
};
/** Interpola entre quadros-chave [progresso, valor] com suavização em cada trecho. */
function keyframes(frames: readonly (readonly [number, number])[], p: number) {
if (p <= frames[0][0]) return frames[0][1];
for (let i = 1; i < frames.length; i++) {
const [p1, v1] = frames[i];
if (p <= p1) {
const [p0, v0] = frames[i - 1];
return v0 + (v1 - v0) * smooth(p0, p1, p);
}
}
return frames[frames.length - 1][1];
}
/** Distância mínima entre as camadas no fim do mergulho: cada uma vira um "cômodo" que a câmera visita. */
const SPREAD = 5.5;
/** Distância para enquadrar uma camada inteira numa tela larga. */
const FRAME = 5.2;
/*
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
* atravessa, enquadra os dados, atravessa. As legendas de Hero.tsx (--from/--to em globals.css) seguem
* estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92.
* Em tela vertical (celular) a distância de enquadramento cresce para a camada caber na largura, e as
* camadas se afastam mais para a câmera caber entre elas.
*/
function cameraFrames(rest: number, spread: number, frame: number) {
return [
[0, rest],
[0.28, spread + frame],
[0.44, spread + frame * 0.87],
[0.5, spread - 0.3],
[0.55, frame],
[0.69, frame * 0.87],
[0.74, -0.3],
[0.78, -spread + frame],
[0.89, -spread + frame * 0.87],
[0.95, -spread - 0.3],
[1, -spread - 2],
] as const;
}
// ponto de cada camada onde a etiqueta se prende (canto direito da frente), na ordem tela, integrações, dados
const LABEL_ANCHORS: [number, Layer, number][] = [
[2.1, 1, 1.4],
[1.95, 0, 0.9],
[2, -1, 1.3],
];
function Field({
progressRef,
labelsRef,
onReady,
onError,
}: HeroFieldSceneProps) {
const { gl, camera, size } = useThree();
const group = useRef<THREE.Group>(null);
const dense = useMemo(
() => window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4,
[],
);
const geometry = useMemo(() => buildField(dense), [dense]);
const material = useMemo(
() =>
new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uTime: { value: 0 },
uIntro: { value: 0 },
uSpread: { value: 1.05 },
uScroll: { value: 0 },
uScale: { value: 1 },
uFocus: { value: 9 },
uFade: { value: 1 },
uMouse: { value: new THREE.Vector2(9, 9) },
uMouseAmt: { value: 0 },
},
}),
[],
);
const state = useRef({
progress: 0,
pointer: new THREE.Vector2(),
lastMove: -10,
mouse: new THREE.Vector2(),
tilt: new THREE.Vector2(),
frames: 0,
});
const helpers = useMemo(
() => ({
ray: new THREE.Raycaster(),
plane: new THREE.Plane(),
hit: new THREE.Vector3(),
anchor: new THREE.Vector3(),
zero: new THREE.Vector2(),
target: new THREE.Vector2(),
}),
[],
);
useEffect(() => {
const onMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
const s = state.current;
s.pointer.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
);
s.lastMove = performance.now() / 1000;
};
const onLost = (event: Event) => {
event.preventDefault();
onError();
};
window.addEventListener("pointermove", onMove, { passive: true });
gl.domElement.addEventListener("webglcontextlost", onLost);
return () => {
window.removeEventListener("pointermove", onMove);
gl.domElement.removeEventListener("webglcontextlost", onLost);
geometry.dispose();
material.dispose();
};
}, [gl, geometry, material, onError]);
useFrame((frame, delta) => {
const g = group.current;
if (!g) return;
const dt = Math.min(delta, 1 / 20);
const s = state.current;
const u = material.uniforms;
const time = frame.clock.elapsedTime;
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
s.progress +=
((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
const p = s.progress;
const aspect = size.width / size.height;
const wide = aspect > 1.1;
const tanHalf = Math.tan(
THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2),
);
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
const rest = Math.max(9, frameDist * 1.1);
u.uTime.value = time;
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
u.uScroll.value = p;
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
const face = smooth(0.02, 0.28, p);
u.uSpread.value = 1.05 + (spreadMax - 1.05) * face * face;
u.uFade.value = 1 - smooth(0.93, 1, p);
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
u.uScale.value =
((gl.getPixelRatio() * size.height * 0.5) / tanHalf) *
Math.sqrt(frameDist / FRAME);
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a tela
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
s.tilt.lerp(mouseActive ? s.pointer : helpers.zero, 1 - Math.exp(-dt * 3));
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
g.rotation.set(
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
s.tilt.x * 0.15 * (1 - face),
0,
);
const settle = 1 - smooth(0, 0.28, p);
// em repouso: à direita do texto na tela larga; no celular, mais abaixo, atrás dos botões
g.position.set(
wide ? 1.7 * settle : 0,
(wide ? -0.1 : -rest * tanHalf * 0.38) * settle,
0,
);
camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), p);
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
const spread = u.uSpread.value;
const ahead =
[spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
g.updateMatrixWorld();
if (mouseActive) {
helpers.ray.setFromCamera(s.pointer, camera);
helpers.plane
.set(new THREE.Vector3(0, 1, 0), -u.uSpread.value)
.applyMatrix4(g.matrixWorld);
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
g.worldToLocal(helpers.hit);
s.mouse.lerp(
helpers.target.set(helpers.hit.x, helpers.hit.z),
1 - Math.exp(-dt * 10),
);
}
} else {
s.mouse.lerp(
helpers.target.set(
Math.sin(time * 0.31) * 1.5,
Math.sin(time * 0.23 + 1.3) * 1.1,
),
1 - Math.exp(-dt * 1.5),
);
}
u.uMouse.value.copy(s.mouse);
const targetAmt = (mouseActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
u.uMouseAmt.value +=
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
// etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa
const labels = labelsRef.current ?? [];
const shown = smooth(0.75, 1, u.uIntro.value) * (1 - smooth(0.04, 0.16, p));
LABEL_ANCHORS.forEach(([x, layer, z], index) => {
const label = labels[index];
if (!label) return;
helpers.anchor
.set(x, layer * u.uSpread.value, z)
.applyMatrix4(g.matrixWorld)
.project(camera);
const px = ((helpers.anchor.x + 1) / 2) * size.width;
const py = ((1 - helpers.anchor.y) / 2) * size.height;
label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`;
label.style.opacity = shown.toFixed(3);
});
if (++s.frames === 2) onReady();
});
return (
<group ref={group}>
<points geometry={geometry} material={material} frustumCulled={false} />
</group>
);
}
class SceneBoundary extends Component<
{ children: ReactNode; onError: () => void },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch() {
this.props.onError();
}
render() {
return this.state.failed ? null : this.props.children;
}
}
export default function HeroFieldScene(props: HeroFieldSceneProps) {
return (
<SceneBoundary onError={props.onError}>
<Canvas
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
dpr={[1, 1.75]}
frameloop={props.visible ? "always" : "never"}
gl={{
alpha: true,
antialias: false,
powerPreference: "high-performance",
}}
>
<Field {...props} />
</Canvas>
</SceneBoundary>
);
}
+8 -248
View File
@@ -1,248 +1,9 @@
"use client";
import { Float, MeshDistortMaterial } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
type HeroSceneVariant =
| "command"
| "launch"
| "rescue"
| "signal"
| "diagnostic"
| "portrait";
function ParticleField({ count = 2000 }: { count?: number }) {
const mesh = useRef<THREE.Points>(null);
const mouseRef = useRef({ x: 0, y: 0 });
const [positions, velocities] = useMemo(() => {
const pos = new Float32Array(count * 3);
const vel = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
pos[i3] = (Math.random() - 0.5) * 20;
pos[i3 + 1] = (Math.random() - 0.5) * 20;
pos[i3 + 2] = (Math.random() - 0.5) * 15;
vel[i3] = (Math.random() - 0.5) * 0.002;
vel[i3 + 1] = (Math.random() - 0.5) * 0.002;
vel[i3 + 2] = (Math.random() - 0.5) * 0.001;
}
return [pos, vel];
}, [count]);
const sizes = useMemo(() => {
const s = new Float32Array(count);
for (let i = 0; i < count; i++) {
s[i] = Math.random() * 2 + 0.5;
}
return s;
}, [count]);
const handlePointerMove = useCallback(
(e: { clientX: number; clientY: number }) => {
mouseRef.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouseRef.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
},
[],
);
useFrame((state) => {
if (!mesh.current) return;
const geo = mesh.current.geometry;
const posAttr = geo.getAttribute("position") as THREE.BufferAttribute;
const arr = posAttr.array as Float32Array;
const time = state.clock.elapsedTime;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
arr[i3] += velocities[i3] + Math.sin(time * 0.3 + i) * 0.001;
arr[i3 + 1] += velocities[i3 + 1] + Math.cos(time * 0.2 + i) * 0.001;
arr[i3 + 2] += velocities[i3 + 2];
// Mouse influence
const dx = mouseRef.current.x * 5 - arr[i3];
const dy = mouseRef.current.y * 5 - arr[i3 + 1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 3) {
const force = (3 - dist) * 0.0003;
arr[i3] += dx * force;
arr[i3 + 1] += dy * force;
}
// Wrap around
if (arr[i3] > 10) arr[i3] = -10;
if (arr[i3] < -10) arr[i3] = 10;
if (arr[i3 + 1] > 10) arr[i3 + 1] = -10;
if (arr[i3 + 1] < -10) arr[i3 + 1] = 10;
if (arr[i3 + 2] > 7.5) arr[i3 + 2] = -7.5;
if (arr[i3 + 2] < -7.5) arr[i3 + 2] = 7.5;
}
posAttr.needsUpdate = true;
// Slow rotation
mesh.current.rotation.y = time * 0.02;
mesh.current.rotation.x = Math.sin(time * 0.01) * 0.1;
});
useEffect(() => {
window.addEventListener("pointermove", handlePointerMove);
return () => window.removeEventListener("pointermove", handlePointerMove);
}, [handlePointerMove]);
return (
<points ref={mesh}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
count={count}
/>
<bufferAttribute
attach="attributes-size"
args={[sizes, 1]}
count={count}
/>
</bufferGeometry>
<pointsMaterial
color="#ccff00"
size={0.03}
transparent
opacity={0.6}
sizeAttenuation
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
);
}
function FloatingGeometry({ variant }: { variant: HeroSceneVariant }) {
const meshRef = useRef<THREE.Mesh>(null);
const mesh2Ref = useRef<THREE.Mesh>(null);
const mesh3Ref = useRef<THREE.Mesh>(null);
const layout = {
command: {
a: [4, 1.5, -3] as [number, number, number],
b: [-5, -2, -4] as [number, number, number],
c: [2, -3, -5] as [number, number, number],
scale: [1.2, 0.8, 1.5] as [number, number, number],
},
launch: {
a: [3.8, 2.1, -3] as [number, number, number],
b: [0, -1.4, -4] as [number, number, number],
c: [-3.8, -2.6, -5] as [number, number, number],
scale: [0.9, 0.9, 1.8] as [number, number, number],
},
rescue: {
a: [-3.6, 1.2, -3] as [number, number, number],
b: [3.6, 1.2, -4] as [number, number, number],
c: [0, -2.8, -5] as [number, number, number],
scale: [1, 1, 1.4] as [number, number, number],
},
signal: {
a: [-4.2, -1, -3] as [number, number, number],
b: [0.2, 1.6, -4] as [number, number, number],
c: [4.2, -0.6, -5] as [number, number, number],
scale: [0.7, 0.7, 1.2] as [number, number, number],
},
diagnostic: {
a: [4.6, 0.6, -3] as [number, number, number],
b: [-3.5, 1.8, -4] as [number, number, number],
c: [-1.2, -2.8, -5] as [number, number, number],
scale: [1.4, 0.7, 1.1] as [number, number, number],
},
portrait: {
a: [3.2, 0.4, -3] as [number, number, number],
b: [4.8, 2.1, -4] as [number, number, number],
c: [-3.6, -2.4, -5] as [number, number, number],
scale: [1.8, 0.5, 1.1] as [number, number, number],
},
}[variant];
useFrame((state) => {
const t = state.clock.elapsedTime;
if (meshRef.current) {
meshRef.current.rotation.x = t * 0.15;
meshRef.current.rotation.y = t * 0.1;
meshRef.current.position.y = layout.a[1] + Math.sin(t * 0.4) * 0.3;
}
if (mesh2Ref.current) {
mesh2Ref.current.rotation.x = t * 0.1;
mesh2Ref.current.rotation.z = t * 0.12;
mesh2Ref.current.position.y = layout.b[1] + Math.cos(t * 0.3) * 0.5;
}
if (mesh3Ref.current) {
mesh3Ref.current.rotation.y = t * 0.08;
mesh3Ref.current.rotation.z = t * 0.06;
}
});
return (
<>
<Float speed={1.5} rotationIntensity={0.4} floatIntensity={0.6}>
<mesh ref={meshRef} position={layout.a} scale={layout.scale[0]}>
{variant === "diagnostic" ? (
<boxGeometry args={[1.4, 1.8, 0.08, 3, 3, 1]} />
) : variant === "portrait" ? (
<capsuleGeometry args={[0.55, 1.25, 6, 12]} />
) : (
<icosahedronGeometry args={[1, 1]} />
)}
<MeshDistortMaterial
color="#ccff00"
wireframe
transparent
opacity={variant === "portrait" ? 0.1 : 0.15}
distort={0.3}
speed={2}
/>
</mesh>
</Float>
<Float speed={1.2} rotationIntensity={0.3} floatIntensity={0.8}>
<mesh ref={mesh2Ref} position={layout.b} scale={layout.scale[1]}>
{variant === "launch" ? (
<coneGeometry args={[0.8, 1.6, 4]} />
) : variant === "signal" ? (
<sphereGeometry args={[0.55, 16, 16]} />
) : (
<octahedronGeometry args={[1, 0]} />
)}
<MeshDistortMaterial
color="#ffffff"
wireframe
transparent
opacity={0.08}
distort={0.2}
speed={1.5}
/>
</mesh>
</Float>
<Float speed={0.8} rotationIntensity={0.2} floatIntensity={0.5}>
<mesh ref={mesh3Ref} position={layout.c} scale={layout.scale[2]}>
{variant === "rescue" ? (
<boxGeometry args={[1.4, 1.4, 1.4, 2, 2, 2]} />
) : variant === "signal" ? (
<torusKnotGeometry args={[0.7, 0.16, 80, 8]} />
) : (
<torusGeometry args={[1, 0.3, 16, 32]} />
)}
<meshStandardMaterial
color="#ccff00"
wireframe
transparent
opacity={0.06}
/>
</mesh>
</Float>
</>
);
}
function GridFloor() {
const meshRef = useRef<THREE.Mesh>(null);
@@ -335,9 +96,9 @@ function CameraRig() {
}
export default function HeroScene({
variant = "command",
isVisible = true,
}: {
variant?: HeroSceneVariant;
isVisible?: boolean;
}) {
return (
<div
@@ -347,6 +108,7 @@ export default function HeroScene({
<Canvas
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
dpr={[1, 1.5]}
frameloop={isVisible ? "always" : "never"}
gl={{
antialias: false,
alpha: true,
@@ -362,18 +124,16 @@ export default function HeroScene({
display: "block",
}}
>
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
<ambientLight intensity={0.3} />
<fog attach="fog" args={["#0a0a0a", 8, 30]} />
<ambientLight intensity={0.25} />
<directionalLight
position={[5, 5, 5]}
intensity={0.5}
intensity={0.4}
color="#ccff00"
/>
<pointLight position={[-5, 3, -5]} intensity={0.3} color="#ffffff" />
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
<CameraRig />
<ParticleField count={1500} />
<FloatingGeometry variant={variant} />
<GridFloor />
</Canvas>
</div>
-66
View File
@@ -1,66 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import MagneticButton from "./MagneticButton";
export default function HomeButton() {
const pathname = usePathname();
const isHome = pathname === "/";
return (
<AnimatePresence>
{!isHome && (
<motion.div
className="fixed left-6 top-6 z-[9000]"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
>
<MagneticButton
as="a"
href="/"
strength={0.3}
aria-label="Voltar para o início"
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-full border border-[rgba(255,255,255,0.1)] bg-[rgba(10,10,10,0.72)] px-4 py-2.5 text-[11px] font-mono uppercase tracking-[0.1em] text-[rgba(244,242,237,0.76)] backdrop-blur-[16px] transition-all duration-300 hover:border-[rgba(204,255,0,0.5)] hover:text-[var(--slash)]"
>
{/* Glow effect */}
<span
className="pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{
boxShadow: "inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
}}
/>
{/* Arrow left icon */}
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="relative z-10 -translate-x-0 transition-transform duration-300 group-hover:-translate-x-0.5"
>
<path d="M7.5 1.5 L2.5 6 L7.5 10.5" />
</svg>
<span className="relative z-10">Home</span>
{/* Animated neon dot */}
<motion.span
className="relative z-10 ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--slash)]"
animate={{ opacity: [1, 0.3, 1], scale: [1, 0.8, 1] }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
/>
</MagneticButton>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,104 +0,0 @@
"use client";
import { useRef, useEffect, type ReactNode } from "react";
interface HorizontalScrollProps {
children: ReactNode;
className?: string;
}
export default function HorizontalScroll({
children,
className = "",
}: HorizontalScrollProps) {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
const track = trackRef.current;
if (!container || !track) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Disable on mobile for better UX
if (window.innerWidth < 768) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
const totalWidth = track.scrollWidth;
const viewportWidth = window.innerWidth;
const tween = gsap.to(track, {
x: -(totalWidth - viewportWidth),
ease: "none",
scrollTrigger: {
trigger: container,
pin: true,
scrub: 1,
end: () => `+=${totalWidth - viewportWidth}`,
invalidateOnRefresh: true,
},
});
// Animate individual items as they come into view
const items = track.querySelectorAll("[data-scroll-item]");
for (const item of items) {
gsap.fromTo(
item,
{
opacity: 0.3,
scale: 0.9,
rotateY: -8,
transformPerspective: 1000,
},
{
opacity: 1,
scale: 1,
rotateY: 0,
scrollTrigger: {
trigger: item,
containerAnimation: tween,
start: "left 80%",
end: "left 30%",
scrub: true,
},
}
);
}
cleanup = () => {
ScrollTrigger.getAll().forEach((st) => {
if (st.trigger === container || (st.vars as { containerAnimation?: unknown }).containerAnimation === tween) {
st.kill();
}
});
tween.kill();
};
};
void setup();
return () => {
cleanup?.();
};
}, []);
return (
<div ref={containerRef} className={`relative overflow-hidden ${className}`}>
<div
ref={trackRef}
className="flex items-stretch gap-sp-6 will-change-transform"
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import MagneticButton from "./MagneticButton";
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
animate: _animate,
transition: _transition,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
React.createElement(tag, props, children);
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return { motion };
});
describe("MagneticButton semantics", () => {
it("renders a list item directly when used inside a list", () => {
const { container } = render(
<ul>
<MagneticButton as="li" className="ds-tag">
TypeScript
</MagneticButton>
</ul>,
);
expect(container.querySelector("ul > li.ds-tag")).toBeInTheDocument();
expect(container.querySelector("ul > div > li")).not.toBeInTheDocument();
});
});
+48 -28
View File
@@ -1,7 +1,7 @@
"use client";
import { useRef, useState, type ReactNode } from "react";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { type MouseEvent, type ReactNode, useState } from "react";
interface MagneticButtonProps {
children: ReactNode;
@@ -13,7 +13,6 @@ interface MagneticButtonProps {
target?: string;
rel?: string;
download?: boolean | string;
"data-cursor"?: string;
type?: "button" | "submit" | "reset";
wrapperClassName?: string;
"aria-label"?: string;
@@ -29,19 +28,14 @@ export default function MagneticButton({
target,
rel,
download,
"data-cursor": dataCursor,
type,
wrapperClassName = "",
"aria-label": ariaLabel,
}: MagneticButtonProps) {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouse = (e: React.MouseEvent) => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const handleMouse = (e: MouseEvent<HTMLElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
@@ -55,31 +49,57 @@ export default function MagneticButton({
setPosition({ x: 0, y: 0 });
};
const Tag = as === "a" ? motion.a : as === "button" ? motion.button : as === "div" ? motion.div : as === "span" ? motion.span : motion.li;
const Tag =
as === "a"
? motion.a
: as === "button"
? motion.button
: as === "div"
? motion.div
: as === "span"
? motion.span
: motion.li;
const linkProps = as === "a" ? { href, target, rel, download } : {};
const buttonProps = as === "button" ? { type } : {};
const motionProps = {
animate: {
x: position.x,
y: position.y,
},
transition: {
type: "spring" as const,
stiffness: 350,
damping: 15,
mass: 0.5,
},
};
if (as === "li") {
return (
<Tag
className={`${className} ${wrapperClassName}`.trim()}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
{...motionProps}
{...linkProps}
{...buttonProps}
aria-label={ariaLabel}
>
{children}
</Tag>
);
}
return (
<div
ref={ref}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
className={`inline-block ${wrapperClassName}`.trim()}
>
<div className={`inline-block ${wrapperClassName}`.trim()}>
<Tag
className={className}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
data-cursor={dataCursor}
animate={{
x: position.x,
y: position.y,
}}
transition={{
type: "spring",
stiffness: 350,
damping: 15,
mass: 0.5,
}}
{...motionProps}
{...linkProps}
{...buttonProps}
aria-label={ariaLabel}
@@ -1,53 +0,0 @@
import { act, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import PageIntro from "./PageIntro";
jest.mock("react-dom", () => ({
...jest.requireActual("react-dom"),
createPortal: (children: ReactNode) => children,
}));
jest.mock("framer-motion", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get:
(_target, tag: string) =>
({ children, ...props }: { children?: ReactNode }) =>
React.createElement(tag, props, children),
},
);
return {
AnimatePresence: ({ children }: { children: ReactNode }) => children,
motion,
useReducedMotion: () => false,
};
});
describe("PageIntro", () => {
it("renders the compact signal loading state without a fake percentage", () => {
render(<PageIntro />);
expect(
screen.getByRole("heading", { name: /EVANDRO.*DEV/ }),
).toBeInTheDocument();
expect(screen.getByText("SINAL DE ENTRADA")).toBeInTheDocument();
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
expect(screen.queryByText("000")).not.toBeInTheDocument();
});
it("stays visible long enough to be perceived on a fast page load", () => {
jest.useFakeTimers();
render(<PageIntro />);
act(() => jest.advanceTimersByTime(900));
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
act(() => jest.runAllTimers());
expect(screen.queryByText("Preparando o portfólio")).not.toBeInTheDocument();
jest.useRealTimers();
});
});
-106
View File
@@ -1,106 +0,0 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
const EXIT_EASE: [number, number, number, number] = [0.76, 0, 0.24, 1];
export default function PageIntro() {
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(true);
const reduceMotion = useReducedMotion();
useEffect(() => {
setMounted(true);
const timeout = window.setTimeout(
() => setVisible(false),
reduceMotion ? 150 : 1350,
);
return () => window.clearTimeout(timeout);
}, [reduceMotion]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{visible && (
<motion.div
className="fixed inset-0 z-[99999] grid overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
initial={{ opacity: 1 }}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
clipPath: "inset(0 0 100% 0)",
transition: { duration: 0.65, ease: EXIT_EASE },
}
}
aria-live="polite"
aria-label="Preparando o portfólio"
>
<div className="pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:48px_48px]" />
<div className="pointer-events-none absolute inset-x-0 top-[42%] h-px bg-[var(--slash)] opacity-70" />
<div className="relative m-auto w-[min(88vw,680px)]">
<motion.p
className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--slash)]"
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
>
SINAL DE ENTRADA
</motion.p>
<motion.h1
className="mt-sp-4 font-display text-[clamp(3rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.08em]"
initial={reduceMotion ? false : { opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.48,
delay: 0.06,
ease: [0.16, 1, 0.3, 1],
}}
>
EVANDRO<span className="text-[var(--slash)]">.</span>DEV
</motion.h1>
<motion.div
className="mt-sp-8 flex items-center gap-sp-4"
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.28, delay: 0.18 }}
>
<span className="relative block h-2 w-2" aria-hidden="true">
<span className="absolute inset-0 bg-[var(--slash)]" />
{!reduceMotion && (
<motion.span
className="absolute inset-0 bg-[var(--slash)]"
animate={{ scale: [1, 2.8], opacity: [0.8, 0] }}
transition={{
duration: 0.8,
repeat: Number.POSITIVE_INFINITY,
}}
/>
)}
</span>
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.72)]">
Preparando o portfólio
</p>
<span
className="h-px flex-1 bg-[rgba(244,242,237,.22)]"
aria-hidden="true"
/>
</motion.div>
</div>
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[9px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.45)] lg:bottom-10 lg:left-12 lg:right-12">
<span>Engenharia de software</span>
<span>Garuva · Joinville · Curitiba</span>
</div>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}
@@ -1,112 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import {
PORTRAIT_CYBER_FEATURES,
PortraitCyberScene,
} from "./PortraitCyberScene";
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: jest.fn(),
}));
jest.mock("../animations/LazyRevealImage", () => ({
LazyRevealImage: ({
alt,
className,
}: {
alt: string;
className?: string;
}) => (
<div
aria-label={alt}
className={className}
data-testid="portrait-image"
role="img"
/>
),
}));
jest.mock("@react-three/fiber", () => ({
Canvas: ({ children: _children, ...props }: Record<string, unknown>) => (
<div data-testid="portrait-cyber-canvas" {...props} />
),
useFrame: jest.fn(),
}));
const mockedUseReducedMotion = jest.mocked(useReducedMotion);
describe("PortraitCyberScene", () => {
beforeEach(() => {
mockedUseReducedMotion.mockReturnValue(false);
});
it("keeps the accessible photo and mounts the cyber layers when visible", async () => {
render(
<PortraitCyberScene
src="/edev.jpg"
alt="Retrato profissional de Evandro Fonseca Junior"
/>,
);
const scene = screen.getByTestId("portrait-cyber");
await waitFor(() =>
expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(),
);
expect(
screen.getByRole("img", {
name: "Retrato profissional de Evandro Fonseca Junior",
}),
).toBeInTheDocument();
expect(scene).toHaveAttribute(
"data-cyber-effects",
PORTRAIT_CYBER_FEATURES.join(" "),
);
expect(screen.getByTestId("portrait-cyber-scanlines")).toBeInTheDocument();
});
it("exposes the RGB split state only while the pointer is over the portrait", async () => {
render(<PortraitCyberScene src="/edev.jpg" alt="Retrato profissional" />);
const scene = screen.getByTestId("portrait-cyber");
Object.defineProperty(scene, "getBoundingClientRect", {
configurable: true,
value: () => ({
bottom: 100,
height: 100,
left: 0,
right: 100,
top: 0,
width: 100,
x: 0,
y: 0,
}),
});
await waitFor(() =>
expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(),
);
fireEvent.pointerMove(scene, { clientX: 80, clientY: 20 });
expect(scene).toHaveAttribute("data-cyber-state", "hovered");
fireEvent.pointerLeave(scene);
expect(scene).toHaveAttribute("data-cyber-state", "idle");
});
it("keeps the photo static while showing the cyber layers when reduced motion is preferred", async () => {
mockedUseReducedMotion.mockReturnValue(true);
render(<PortraitCyberScene src="/edev.jpg" alt="Retrato profissional" />);
expect(screen.getByTestId("portrait-cyber")).toHaveAttribute(
"data-motion",
"reduced",
);
await waitFor(() =>
expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(),
);
expect(
screen.queryByRole("img", { name: "Retrato profissional" }),
).toBeInTheDocument();
});
});
@@ -1,386 +0,0 @@
"use client";
import { Canvas, useFrame } from "@react-three/fiber";
import type { StaticImageData } from "next/image";
import {
type PointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import * as THREE from "three";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { LazyRevealImage } from "../animations/LazyRevealImage";
export const PORTRAIT_CYBER_FEATURES = [
"particles",
"contour",
"scanlines",
"rgb-split",
] as const;
type PointerPosition = {
x: number;
y: number;
};
type PointerReference = {
current: PointerPosition;
};
const CYBER_OUTLINE: readonly [number, number][] = [
[-0.78, -0.92],
[-0.96, -0.64],
[-0.9, -0.28],
[-0.68, 0.02],
[-0.48, 0.22],
[-0.42, 0.58],
[-0.25, 0.88],
[0, 1.02],
[0.24, 0.94],
[0.43, 0.62],
[0.48, 0.24],
[0.72, 0.02],
[0.94, -0.3],
[0.9, -0.66],
[0.75, -0.92],
[0.35, -0.78],
[0, -0.68],
[-0.38, -0.78],
];
function createContourGeometry(scale = 1.65) {
return new THREE.BufferGeometry().setFromPoints(
CYBER_OUTLINE.map(([x, y]) => new THREE.Vector3(x * scale, y * scale, 0)),
);
}
function ContourLines({
hovered,
animate,
}: {
hovered: boolean;
animate: boolean;
}) {
const groupRef = useRef<THREE.Group>(null);
const geometry = useMemo(() => createContourGeometry(), []);
useFrame((state) => {
if (!groupRef.current || !animate) return;
groupRef.current.rotation.z =
Math.sin(state.clock.elapsedTime * 0.35) * 0.008;
});
return (
<group ref={groupRef} position={[0, 0, 0.2]}>
<lineLoop geometry={geometry}>
<lineBasicMaterial color="#ccff00" transparent opacity={0.46} />
</lineLoop>
<lineLoop geometry={geometry} position={[hovered ? 0.035 : 0, 0, 0.01]}>
<lineBasicMaterial
color="#ff3b81"
transparent
opacity={hovered ? 0.48 : 0}
/>
</lineLoop>
<lineLoop geometry={geometry} position={[hovered ? -0.035 : 0, 0, 0.02]}>
<lineBasicMaterial
color="#00e5ff"
transparent
opacity={hovered ? 0.42 : 0}
/>
</lineLoop>
</group>
);
}
function createParticlePositions(count: number, phase: number) {
const positions = new Float32Array(count * 3);
for (let index = 0; index < count; index += 1) {
const [x, y] = CYBER_OUTLINE[(index + phase) % CYBER_OUTLINE.length];
const jitterX = (((index * 17 + phase * 7) % 19) / 19 - 0.5) * 0.18;
const jitterY = (((index * 11 + phase * 5) % 17) / 17 - 0.5) * 0.18;
const radius = 1 + ((index * 13 + phase) % 9) / 26;
const index3 = index * 3;
positions[index3] = x * 1.65 * radius + jitterX;
positions[index3 + 1] = y * 1.65 * radius + jitterY;
positions[index3 + 2] = 0.24 + ((index + phase) % 7) * 0.025;
}
return positions;
}
function ParticleField({
animate,
color,
count,
phase,
}: {
animate: boolean;
color: string;
count: number;
phase: number;
}) {
const pointsRef = useRef<THREE.Points>(null);
const positions = useMemo(
() => createParticlePositions(count, phase),
[count, phase],
);
useFrame((state, delta) => {
if (!pointsRef.current || !animate) return;
const positionAttribute = pointsRef.current.geometry.getAttribute(
"position",
) as THREE.BufferAttribute;
const values = positionAttribute.array as Float32Array;
for (let index = 0; index < count; index += 1) {
const index3 = index * 3;
values[index3 + 1] += (0.012 + (index % 4) * 0.002) * delta;
values[index3] +=
Math.sin(state.clock.elapsedTime * 0.55 + index + phase) * 0.0007;
if (values[index3 + 1] > 1.9) values[index3 + 1] = -1.9;
}
positionAttribute.needsUpdate = true;
});
return (
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
count={count}
/>
</bufferGeometry>
<pointsMaterial
color={color}
size={0.026}
transparent
opacity={0.74}
sizeAttenuation
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</points>
);
}
function CyberScene({
animate,
hovered,
pointerRef,
}: {
animate: boolean;
hovered: boolean;
pointerRef: PointerReference;
}) {
const groupRef = useRef<THREE.Group>(null);
useFrame((_, delta) => {
if (!groupRef.current) return;
const targetX = animate ? pointerRef.current.x * 0.09 : 0;
const targetY = animate ? pointerRef.current.y * 0.06 : 0;
groupRef.current.position.x = THREE.MathUtils.damp(
groupRef.current.position.x,
targetX,
5,
delta,
);
groupRef.current.position.y = THREE.MathUtils.damp(
groupRef.current.position.y,
targetY,
5,
delta,
);
groupRef.current.rotation.y = THREE.MathUtils.damp(
groupRef.current.rotation.y,
targetX * 0.4,
5,
delta,
);
});
return (
<group ref={groupRef}>
<ContourLines animate={animate} hovered={hovered} />
<ParticleField animate={animate} color="#ccff00" count={128} phase={0} />
<ParticleField animate={animate} color="#00e5ff" count={48} phase={7} />
</group>
);
}
export interface PortraitCyberSceneProps {
src: string | StaticImageData;
alt: string;
className?: string;
}
export function PortraitCyberScene({
alt,
className = "",
src,
}: Readonly<PortraitCyberSceneProps>) {
const shouldReduceMotion = useReducedMotion();
const containerRef = useRef<HTMLDivElement>(null);
const pointerRef = useRef<PointerPosition>({ x: 0, y: 0 });
const [isVisible, setIsVisible] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [pointerPosition, setPointerPosition] = useState<PointerPosition>({
x: 0,
y: 0,
});
const [isCoarsePointer, setIsCoarsePointer] = useState(false);
useEffect(() => {
const element = containerRef.current;
if (!element) return;
if (typeof IntersectionObserver === "undefined") {
setIsVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
setIsVisible(true);
observer.disconnect();
},
{ rootMargin: "120px" },
);
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia("(pointer: coarse)");
const updatePointerMode = () => setIsCoarsePointer(mediaQuery.matches);
updatePointerMode();
mediaQuery.addEventListener?.("change", updatePointerMode);
return () => mediaQuery.removeEventListener?.("change", updatePointerMode);
}, []);
const handlePointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (shouldReduceMotion || isCoarsePointer) return;
const rect = event.currentTarget.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
const position = {
x: ((event.clientX - rect.left) / width) * 2 - 1,
y: -(((event.clientY - rect.top) / height) * 2 - 1),
};
pointerRef.current = position;
setPointerPosition(position);
setIsHovered(true);
},
[isCoarsePointer, shouldReduceMotion],
);
const handlePointerLeave = useCallback(() => {
pointerRef.current = { x: 0, y: 0 };
setPointerPosition({ x: 0, y: 0 });
setIsHovered(false);
}, []);
const imageTransform = shouldReduceMotion
? undefined
: {
transform: `translate3d(${pointerPosition.x * 3}px, ${
pointerPosition.y * 3
}px, 0) scale(1.012)`,
transition: isHovered
? "transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
: "transform 420ms cubic-bezier(0.16, 1, 0.3, 1)",
};
const motionMode = shouldReduceMotion
? "reduced"
: isCoarsePointer
? "static"
: "full";
const sceneClassName = [
"relative aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]",
className,
]
.filter(Boolean)
.join(" ");
return (
<div
ref={containerRef}
className={sceneClassName}
data-cyber-effects={PORTRAIT_CYBER_FEATURES.join(" ")}
data-cyber-state={isHovered ? "hovered" : "idle"}
data-motion={motionMode}
data-testid="portrait-cyber"
onPointerLeave={handlePointerLeave}
onPointerMove={handlePointerMove}
>
<div className="absolute inset-0 h-full w-full" style={imageTransform}>
<LazyRevealImage
src={src}
alt={alt}
className="h-full w-full"
imageClassName="object-contain"
/>
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 opacity-[0.1] mix-blend-screen"
data-testid="portrait-cyber-scanlines"
data-effect="scanlines"
style={{
backgroundImage:
"repeating-linear-gradient(0deg, transparent 0, transparent 3px, rgba(204,255,0,0.22) 4px, transparent 5px)",
}}
/>
{isVisible && (
<Canvas
aria-hidden="true"
camera={{ far: 20, fov: 45, near: 0.1, position: [0, 0, 5] }}
className="pointer-events-none absolute inset-0 z-20"
dpr={[1, 1.5]}
frameloop={shouldReduceMotion ? "demand" : "always"}
gl={{
alpha: true,
antialias: false,
powerPreference: "high-performance",
}}
style={{ background: "transparent" }}
>
<CyberScene
animate={!isCoarsePointer && !shouldReduceMotion}
hovered={isHovered}
pointerRef={pointerRef}
/>
</Canvas>
)}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-30 rounded-[6px] opacity-40"
data-effect="vignette"
style={{
background:
"radial-gradient(circle at center, transparent 48%, rgba(10,10,10,0.18) 100%)",
}}
/>
</div>
);
}
export default PortraitCyberScene;
+6 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { motion, useScroll, useSpring, useTransform } from "framer-motion";
import { motion, useScroll, useSpring, useTransform } from "motion/react";
export default function ScrollProgress() {
const { scrollYProgress } = useScroll();
@@ -10,7 +10,11 @@ export default function ScrollProgress() {
restDelta: 0.001,
});
const opacity = useTransform(scrollYProgress, [0, 0.02, 0.98, 1], [0, 1, 1, 0]);
const opacity = useTransform(
scrollYProgress,
[0, 0.02, 0.98, 1],
[0, 1, 1, 0],
);
return (
<>
@@ -1,98 +0,0 @@
"use client";
import { useRef, useEffect, useState } from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
useTransform,
useSpring,
useScroll,
} from "framer-motion";
interface ScrollVelocityTextProps {
text: string;
baseVelocity?: number;
className?: string;
}
function wrap(min: number, max: number, v: number) {
const rangeSize = max - min;
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
}
export default function ScrollVelocityText({
text,
baseVelocity = 2,
className = "",
}: ScrollVelocityTextProps) {
const baseX = useMotionValue(0);
const { scrollY } = useScroll();
const scrollVelocity = useMotionValue(0);
const smoothVelocity = useSpring(scrollVelocity, {
damping: 50,
stiffness: 300,
});
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 5], {
clamp: false,
});
const [repetitions, setRepetitions] = useState(4);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleResize = () => {
setRepetitions(Math.ceil(window.innerWidth / 400) + 2);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// Track scroll velocity
const prevScrollY = useRef(0);
useEffect(() => {
return scrollY.on("change", (latest) => {
const delta = latest - prevScrollY.current;
prevScrollY.current = latest;
scrollVelocity.set(Math.abs(delta));
});
}, [scrollY, scrollVelocity]);
const directionFactor = useRef(1);
const x = useTransform(baseX, (v) => `${wrap(-25, 0, v)}%`);
useAnimationFrame((_, delta) => {
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
const vf = velocityFactor.get();
if (vf < 0) {
directionFactor.current = -1;
} else if (vf > 0) {
directionFactor.current = 1;
}
moveBy += directionFactor.current * moveBy * vf;
baseX.set(baseX.get() + moveBy);
});
return (
<div
ref={containerRef}
className={`overflow-hidden whitespace-nowrap ${className}`}
>
<motion.div className="inline-flex" style={{ x }}>
{[...Array(repetitions)].map((_, i) => (
<span
key={i}
className="mr-8 inline-block font-display text-[clamp(3rem,10vw,8rem)] font-extrabold uppercase leading-none tracking-[-0.04em]"
>
{text}
<span className="mx-8 inline-block text-[var(--slash)]">&#x2022;</span>
</span>
))}
</motion.div>
</div>
);
}
-112
View File
@@ -1,112 +0,0 @@
"use client";
import { useRef, useEffect } from "react";
interface SectionDividerProps {
variant?: "gradient" | "pulse" | "waveform";
className?: string;
}
export default function SectionDivider({
variant = "gradient",
className = "",
}: SectionDividerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (variant !== "waveform") return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = 60;
};
resize();
window.addEventListener("resize", resize);
const render = () => {
animId = requestAnimationFrame(render);
const { width, height } = canvas;
const t = performance.now() * 0.001;
ctx.clearRect(0, 0, width, height);
// Draw multiple waveforms
for (let wave = 0; wave < 3; wave++) {
ctx.beginPath();
ctx.strokeStyle = `rgba(204, 255, 0, ${0.1 - wave * 0.03})`;
ctx.lineWidth = 1;
for (let x = 0; x < width; x++) {
const frequency = 0.005 + wave * 0.002;
const amplitude = 8 + wave * 4;
const speed = t * (0.5 + wave * 0.2);
const y =
height / 2 +
Math.sin(x * frequency + speed) * amplitude +
Math.sin(x * frequency * 2.3 + speed * 1.5) * (amplitude * 0.4);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
};
animId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
};
}, [variant]);
if (variant === "gradient") {
return (
<div className={`relative h-px w-full overflow-hidden ${className}`}>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-30" />
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-60"
style={{
animation: "shimmerDivider 3s ease-in-out infinite",
}}
/>
</div>
);
}
if (variant === "pulse") {
return (
<div className={`flex items-center justify-center gap-2 py-8 ${className}`}>
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-1 w-1 rounded-full bg-[var(--slash)]"
style={{
animation: `pulseDot 2s ease-in-out ${i * 0.15}s infinite`,
}}
/>
))}
</div>
);
}
// waveform
return (
<div className={`relative ${className}`}>
<canvas
ref={canvasRef}
className="w-full opacity-60"
style={{ height: 60 }}
aria-hidden="true"
/>
</div>
);
}
@@ -0,0 +1,378 @@
"use client";
import { type RefObject, useEffect } from "react";
type Transition = (
tl: gsap.core.Timeline,
prev: HTMLElement,
next: HTMLElement,
touch: boolean,
) => void;
/*
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
* da rolagem (rolar para cima volta o "vídeo").
*
* Quem segura as seções paradas durante uma passagem:
* - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim
* dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem;
* - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis
* rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento
* chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o
* dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala).
*
* Regras que mantêm tudo estável:
* - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
* - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não
* aparecer atrás de transparências e para o navegador não pintar o que está coberto.
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
*/
const vh = () => window.innerHeight;
// origem no meio da parte visível da seção que sai (a última altura de tela dela)
const visibleCenter = (el: HTMLElement) => () =>
`50% ${el.offsetHeight - vh() / 2}px`;
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
function pageTop(el: HTMLElement) {
let top = 0;
for (
let node: HTMLElement | null = el;
node;
node = node.offsetParent as HTMLElement | null
)
top += node.offsetTop;
return top;
}
// a seção que entra fica parada no topo (só desktop, ver o comentário acima)
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
// uma linguagem diferente por passagem, na ordem das seções da home
const TRANSITIONS: Transition[] = [
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
(tl, prev, next, touch) => {
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0);
if (touch) {
tl.fromTo(
next,
{
opacity: 0.3,
clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)",
},
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
0,
);
return;
}
holdNext(tl, next);
const band = (open: number) => () =>
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
tl.fromTo(
next,
{ opacity: 0, clipPath: band(0.36) },
{ opacity: 1, clipPath: band(1), duration: 0.65 },
0.35,
);
},
// projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera
(tl, prev, next, touch) => {
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 1.18, opacity: 0.2 },
0,
);
// no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo
const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`);
const radius = () =>
touch
? Math.hypot(window.innerWidth / 2, vh())
: Math.hypot(window.innerWidth / 2, vh() / 2);
if (!touch) holdNext(tl, next);
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
tl.fromTo(
next,
{ clipPath: () => `circle(0px ${center()})` },
{
clipPath: () => `circle(${radius()}px ${center()})`,
ease: "power1.in",
},
0,
);
},
// modalidades → IA: corte lateral, uma tela empurra a outra
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ xPercent: 0, opacity: 1 },
{ xPercent: -35, opacity: 0.3 },
0,
).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0);
},
// IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece.
// Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o
// desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha
// uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter).
(tl, prev, next, touch) => {
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0);
// no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo
if (!touch)
tl.fromTo(
next,
{ y: () => vh() * 0.35 },
{ y: 0, ease: "power2.out" },
0,
);
},
// qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform)
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ opacity: 1, xPercent: 0 },
{ opacity: 0.25, xPercent: 20 },
0,
).fromTo(
next,
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
{ xPercent: 0, rotation: 0, ease: "power2.out" },
0,
);
},
// sobre → contato: o contato cresce do centro, como um zoom de câmera
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 0.92, opacity: 0.2 },
0,
)
.fromTo(
next,
{
scale: touch ? 0.8 : 0.55,
clipPath: "inset(0% round 56px)",
transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`),
},
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
0,
)
.fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0);
},
];
const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const main = mainRef.current;
if (!main) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cancelled = false;
let cleanup = () => {};
(async () => {
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] =
await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
import("lenis"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
lenis.on("scroll", ScrollTrigger.update);
const tick = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
// toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript
const touch = window.matchMedia(
"(hover: none) and (pointer: coarse)",
).matches;
const scenes = Array.from(main.children) as HTMLElement[];
// Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o
// offsetTop de uma cena presa passa a incluir o deslocamento dela.
const sceneTops = () => {
let top = pageTop(main);
return scenes.map((scene) => {
const current = top;
top += scene.offsetHeight;
return current;
});
};
const stick = () => {
for (const scene of scenes)
scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`;
};
const context = gsap.context(() => {
scenes.forEach((scene, index) => {
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
gsap.set(scene, {
position: "sticky",
zIndex: index === 0 ? 30 : index + 1,
});
});
stick();
TRANSITIONS.forEach((transition, index) => {
const prev = scenes[index];
const next = scenes[index + 1];
if (!prev || !next) return;
const tl = gsap.timeline({
defaults: { ease: "none", duration: 1 },
scrollTrigger: {
trigger: next,
start: () => sceneTops()[index + 1] - vh(),
end: () => sceneTops()[index + 1],
scrub: true,
invalidateOnRefresh: true,
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
onLeave: () => gsap.set(next, { clipPath: "none" }),
// diagrama de qualidade: desenha uma vez quando a seção começa a entrar
onEnter: () => next.classList.add("is-drawing"),
},
});
transition(tl, prev, next, touch);
});
}, main);
// Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a
// passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto
// de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no
// desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa).
let frame = 0;
const syncVisibility = () => {
frame = 0;
const y = window.scrollY;
const tops = sceneTops();
scenes.forEach((scene, index) => {
const from =
index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1);
const to =
index === scenes.length - 1
? Infinity
: tops[index + 1] + vh() * 0.25;
const visibility = y >= from && y <= to ? "" : "hidden";
if (scene.style.visibility !== visibility)
scene.style.visibility = visibility;
});
};
const queueSync = () => {
if (!frame) frame = requestAnimationFrame(syncVisibility);
};
const onRefresh = () => {
stick();
queueSync();
};
syncVisibility();
ScrollTrigger.addEventListener("refresh", onRefresh);
window.addEventListener("scroll", queueSync, { passive: true });
// a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150);
});
resizeObserver.observe(main);
// botões e links internos: em vez do salto, a página "avança o vídeo" até o destino
const onClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
(link.target && link.target !== "_self")
)
return;
const url = new URL(link.href);
if (
url.origin !== location.origin ||
url.pathname !== location.pathname ||
!url.hash
)
return;
let target: HTMLElement | null = null;
try {
target = document.getElementById(
decodeURIComponent(url.hash.slice(1)),
);
} catch {}
if (!target) return;
event.preventDefault();
// nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo;
// alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa)
const index = scenes.findIndex((candidate) =>
candidate.contains(target),
);
const offset =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const sceneTop = index < 0 ? 0 : sceneTops()[index];
const top =
index <= 0
? 0
: Math.max(
sceneTop,
scenes[index] === target
? 0
: sceneTop +
pageTop(target) -
pageTop(scenes[index]) -
offset,
);
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
lenis.scrollTo(top, {
duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000),
easing: easeInOutCubic,
// o foco vai depois: antes de a cena entrar ela está invisível e não aceita foco
onComplete: () => {
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
target.focus({ preventScroll: true });
},
});
};
document.addEventListener("click", onClick, true);
cleanup = () => {
document.removeEventListener("click", onClick, true);
ScrollTrigger.removeEventListener("refresh", onRefresh);
window.removeEventListener("scroll", queueSync);
cancelAnimationFrame(frame);
clearTimeout(refreshTimer);
resizeObserver.disconnect();
context.revert();
for (const scene of scenes) {
scene.style.visibility = "";
scene.style.top = "";
}
gsap.ticker.remove(tick);
lenis.destroy();
};
})();
return () => {
cancelled = true;
cleanup();
};
}, [mainRef]);
}
@@ -0,0 +1,168 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
function fragmentTarget(hash: string) {
if (!hash) return null;
try {
return document.getElementById(decodeURIComponent(hash.slice(1)));
} catch {
return null;
}
}
function focusFragment(target: HTMLElement) {
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
target.focus({ preventScroll: true });
}
/**
* A document owns one outstanding homepage arrival. Later mounts are same-document
* history, where the browser restores its own position.
*/
let arrivalSettled = false;
/** Tests replay several documents inside one jsdom document. */
export function resetHomeArrivalState() {
arrivalSettled = false;
}
/** Correct only an outstanding arrival; ordinary links and history keep native scrolling. */
export function useHomeFragmentNavigation() {
const ready = useRef(false);
const resolveArrival = useRef<(() => void) | null>(null);
useEffect(() => {
const navigation = performance.getEntriesByType?.("navigation")[0] as
| PerformanceNavigationTiming
| undefined;
// A history restoration owns its saved position, not the URL's old fragment.
const restoration = navigation?.type === "back_forward";
const arrival = !restoration && !arrivalSettled;
let pending = restoration ? "" : window.location.hash;
let pendingTop = arrival && !window.location.hash;
if (arrival) window.history.scrollRestoration = "manual";
const resolve = () => {
if (!ready.current) return;
if (pendingTop) {
pendingTop = false;
if (!window.location.hash)
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}
if (!pending) return;
const hash = pending;
pending = "";
if (hash !== window.location.hash) return;
const target = fragmentTarget(hash);
if (!target) return;
focusFragment(target);
const padding =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const margin =
Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
window.scrollTo({
top: Math.max(
0,
window.scrollY +
target.getBoundingClientRect().top -
padding -
margin,
),
left: 0,
behavior: "instant",
});
};
const cancelArrival = () => {
pending = "";
pendingTop = false;
window.history.scrollRestoration = "auto";
};
const onKeyDown = (event: KeyboardEvent) => {
if (
[
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" ",
"Tab",
].includes(event.key)
) {
cancelArrival();
}
};
const onClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
link.hasAttribute("download") ||
(link.target && link.target !== "_self")
)
return;
const url = new URL(link.href);
if (
url.origin !== window.location.origin ||
url.pathname !== window.location.pathname ||
url.search !== window.location.search
)
return;
const target = fragmentTarget(url.hash);
if (!target) return;
pendingTop = false;
pending = ready.current ? "" : url.hash;
// Capture before a mobile menu removes its activated link. Do not cancel navigation.
focusFragment(target);
};
const onHashChange = () => {
pendingTop = false;
if (pending && pending !== window.location.hash) cancelArrival();
if (!ready.current) return;
const target = fragmentTarget(window.location.hash);
if (target) focusFragment(target);
};
resolveArrival.current = resolve;
resolve();
document.addEventListener("click", onClick, true);
window.addEventListener("hashchange", onHashChange);
window.addEventListener("wheel", cancelArrival, { passive: true });
window.addEventListener("touchmove", cancelArrival, { passive: true });
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", cancelArrival, { passive: true });
return () => {
resolveArrival.current = null;
window.history.scrollRestoration = "auto";
document.removeEventListener("click", onClick, true);
window.removeEventListener("hashchange", onHashChange);
window.removeEventListener("wheel", cancelArrival);
window.removeEventListener("touchmove", cancelArrival);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", cancelArrival);
};
}, []);
return useCallback(() => {
ready.current = true;
resolveArrival.current?.();
arrivalSettled = true;
window.history.scrollRestoration = "auto";
}, []);
}
@@ -0,0 +1,86 @@
import { render, screen } from "@testing-library/react";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
describe("displayUrl", () => {
it("shows the site address without the protocol, www or trailing slash", () => {
expect(displayUrl({ demoUrl: "https://www.example.com.br/" })).toEqual({
text: "example.com.br",
secure: true,
});
expect(displayUrl({ demoUrl: "https://app.example.com/painel/" })).toEqual({
text: "app.example.com/painel",
secure: true,
});
});
it("marks plain http as not secure", () => {
expect(displayUrl({ demoUrl: "http://localhost:3000" })).toEqual({
text: "localhost:3000",
secure: false,
});
});
it("falls back to the code address when there is no live demo", () => {
expect(
displayUrl({ demoUrl: null, githubUrl: "https://github.com/user/repo" }),
).toEqual({ text: "github.com/user/repo", secure: true });
});
it("skips an address that does not parse and uses the next one", () => {
expect(
displayUrl({
demoUrl: "isso não é uma url",
githubUrl: "https://github.com/a/b",
}),
).toEqual({ text: "github.com/a/b", secure: true });
});
it("returns null when there is nothing to show", () => {
expect(displayUrl({})).toBeNull();
expect(displayUrl({ demoUrl: "", githubUrl: null })).toBeNull();
});
});
describe("BrowserFrame", () => {
it("draws the three dots, the address and the screen content", () => {
const { container } = render(
<BrowserFrame url={{ text: "site.com", secure: true }}>
<p>conteúdo</p>
</BrowserFrame>,
);
expect(screen.getByText("site.com")).toBeInTheDocument();
expect(screen.getByText("conteúdo")).toBeInTheDocument();
expect(container.querySelectorAll(".ds-bframe-dots i")).toHaveLength(3);
expect(container.querySelector(".ds-bframe-url svg")).not.toBeNull();
});
it("keeps the decorative bar out of the accessibility tree", () => {
const { container } = render(
<BrowserFrame url={{ text: "site.com", secure: true }}>
<p>x</p>
</BrowserFrame>,
);
expect(container.querySelector(".ds-bframe-bar")).toHaveAttribute(
"aria-hidden",
"true",
);
});
it("shows a neutral label and no lock when the address is unknown or not secure", () => {
const { container, rerender } = render(
<BrowserFrame url={null}>
<p>x</p>
</BrowserFrame>,
);
expect(screen.getByText("pré-visualização")).toBeInTheDocument();
expect(container.querySelector(".ds-bframe-url svg")).toBeNull();
rerender(
<BrowserFrame url={{ text: "localhost", secure: false }}>
<p>x</p>
</BrowserFrame>,
);
expect(container.querySelector(".ds-bframe-url svg")).toBeNull();
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { CSSProperties, ReactNode } from "react";
/** Endereço mostrado na barra: o do site publicado, ou o do código quando não há demonstração. */
export function displayUrl(project: {
demoUrl?: string | null;
githubUrl?: string | null;
}): { text: string; secure: boolean } | null {
for (const raw of [project.demoUrl, project.githubUrl]) {
if (!raw) continue;
try {
const url = new URL(raw);
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
return {
text: `${url.host.replace(/^www\./, "")}${path}`,
secure: url.protocol === "https:",
};
} catch {}
}
return null;
}
/**
* Janela de navegador em volta da captura: três pontos, barra com o endereço real e a tela.
* A barra é decoração (aria-hidden); o endereço também aparece como texto no próprio card.
*/
export function BrowserFrame({
url,
children,
className = "",
style,
}: {
url: { text: string; secure: boolean } | null;
children: ReactNode;
className?: string;
style?: CSSProperties;
}) {
return (
<div className={`ds-bframe ${className}`.trim()} style={style}>
<div className="ds-bframe-bar" aria-hidden="true">
<span className="ds-bframe-dots">
<i />
<i />
<i />
</span>
<span className="ds-bframe-url">
{url?.secure && (
<svg
viewBox="0 0 12 12"
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
aria-hidden="true"
>
<rect x="2.5" y="5.5" width="7" height="5" />
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
</svg>
)}
<span>{url?.text ?? "pré-visualização"}</span>
</span>
</div>
{children}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { CSSProperties, ReactNode } from "react";
/** Moldura de celular para o vídeo mobile (proporção 390x844 do vídeo; o recorte da tela segue a mesma conta). */
export function PhoneFrame({
children,
poster,
className = "",
style,
}: {
children: ReactNode;
poster?: string;
className?: string;
style?: CSSProperties;
}) {
return (
<div className={`ds-phone ${className}`.trim()} style={style}>
<div
className="ds-phone-screen"
style={poster ? { backgroundImage: `url(${poster})` } : undefined}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,393 @@
import {
act,
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import { StrictMode } from "react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import {
chapterFraction,
getShowcaseConfig,
mobileFractionAt,
} from "@/lib/project-showcase";
import { ShowcaseProvider, useShowcaseCard } from "./ShowcaseProvider";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
function Card({ id }: { id: string }) {
const { frameRef, open } = useShowcaseCard(id);
return (
<article className="ds-card">
<div ref={frameRef} />
<button type="button" onClick={open}>
ampliar {id}
</button>
</article>
);
}
const projects = getFeaturedProjects(null);
const lavacao = getShowcaseConfig("lavacao-047");
function setup(id = "lavacao-047") {
render(
<ShowcaseProvider projects={projects}>
<Card id="sev-modaintima" />
<Card id="lavacao-047" />
<Card id="nutriscan" />
<Card id="as-servicos" />
</ShowcaseProvider>,
);
fireEvent.click(screen.getByRole("button", { name: `ampliar ${id}` }));
return screen.getByRole("dialog");
}
/** jsdom não carrega mídia: o teste dá ao <video> uma duração e deixa o componente reagir como no navegador. */
function loadVideo(dialog: HTMLElement, duration = 30) {
const video = dialog.querySelector("video") as HTMLVideoElement;
Object.defineProperty(video, "duration", {
configurable: true,
value: duration,
});
let time = 0;
Object.defineProperty(video, "currentTime", {
configurable: true,
get: () => time,
set: (value: number) => {
time = value;
},
});
act(() => {
video.dispatchEvent(new Event("loadedmetadata"));
});
return video;
}
beforeAll(() => {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener() {},
removeEventListener() {},
}));
// jsdom ainda não implementa o <dialog> modal
HTMLDialogElement.prototype.showModal = function showModal(
this: HTMLDialogElement,
) {
this.setAttribute("open", "");
};
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
this.removeAttribute("open");
this.dispatchEvent(new Event("close"));
};
});
beforeEach(() => {
reducedMotion = false;
jest
.spyOn(HTMLMediaElement.prototype, "play")
.mockImplementation(function play(this: HTMLMediaElement) {
// jsdom não toca nada: o teste mantém `paused` e os eventos como o navegador faria
Object.defineProperty(this, "paused", {
configurable: true,
value: false,
});
this.dispatchEvent(new Event("play"));
return Promise.resolve();
});
jest
.spyOn(HTMLMediaElement.prototype, "pause")
.mockImplementation(function pause(this: HTMLMediaElement) {
Object.defineProperty(this, "paused", {
configurable: true,
value: true,
});
this.dispatchEvent(new Event("pause"));
});
});
afterEach(() => {
jest.restoreAllMocks();
document.documentElement.classList.remove("ds-lb-open");
});
describe("ProjectLightbox", () => {
it("opens a modal dialog for the clicked project, locks page scroll and puts focus on close", () => {
const dialog = setup();
expect(dialog).toHaveAttribute("open");
expect(dialog).toHaveAccessibleName("Lavação 047");
expect(dialog).toHaveAttribute("data-lenis-prevent");
expect(document.documentElement).toHaveClass("ds-lb-open");
expect(
within(dialog).getByRole("button", { name: "Fechar" }),
).toHaveFocus();
expect(within(dialog).getByText("02 / 05")).toBeInTheDocument();
});
it("shows the desktop video in a browser frame by default and the phone version on request", () => {
const dialog = setup();
const toggle = within(dialog).getByRole("group", {
name: "Versão do site",
});
expect(
within(toggle).getByRole("button", { name: "Computador" }),
).toHaveAttribute("aria-pressed", "true");
expect(dialog.querySelector(".ds-lb-frame")).not.toBeNull();
expect(dialog.querySelector('source[src$="desktop.webm"]')).not.toBeNull();
fireEvent.click(within(toggle).getByRole("button", { name: "Celular" }));
expect(
within(toggle).getByRole("button", { name: "Celular" }),
).toHaveAttribute("aria-pressed", "true");
expect(dialog.querySelector(".ds-lb-phone")).not.toBeNull();
expect(dialog.querySelector('source[src$="mobile.webm"]')).not.toBeNull();
});
it("keeps the same point of the site when switching devices", () => {
if (!lavacao) throw new Error("config ausente");
const dialog = setup();
const desktop = loadVideo(dialog, lavacao.durations.desktop);
// meio do vídeo desktop
desktop.currentTime = lavacao.durations.desktop * 0.5;
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
const phone = loadVideo(dialog, lavacao.durations.mobile);
expect(phone.currentTime).toBeCloseTo(
mobileFractionAt(lavacao, 0.5) * lavacao.durations.mobile,
5,
);
});
it("jumps to a chapter and marks it as current", () => {
if (!lavacao) throw new Error("config ausente");
const dialog = setup();
const video = loadVideo(dialog, lavacao.durations.desktop);
fireEvent.click(
within(dialog).getByRole("button", { name: /Do sujo ao brilho/ }),
);
expect(video.currentTime).toBeCloseTo(
chapterFraction(lavacao, 2, "desktop") * lavacao.durations.desktop,
5,
);
expect(
within(dialog).getByRole("button", { name: /Do sujo ao brilho/ }),
).toHaveAttribute("aria-current", "true");
});
it("toggles play and pause with an accessible name", () => {
const dialog = setup();
loadVideo(dialog);
const play = within(dialog).getByRole("button", { name: "Reproduzir" });
fireEvent.click(play);
expect(
within(dialog).getByRole("button", { name: "Pausar" }),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Pausar" }));
expect(
within(dialog).getByRole("button", { name: "Reproduzir" }),
).toBeInTheDocument();
});
it("seeks with the range slider", () => {
const dialog = setup();
const video = loadVideo(dialog, 30);
fireEvent.change(
within(dialog).getByRole("slider", { name: "Posição do vídeo" }),
{ target: { value: "500" } },
);
expect(video.currentTime).toBeCloseTo(15, 5);
});
it("moves between projects and disables the arrows at both ends", () => {
const dialog = setup("sev-modaintima");
expect(
within(dialog).getByRole("button", { name: "Projeto anterior" }),
).toBeDisabled();
fireEvent.click(
within(dialog).getByRole("button", { name: "Próximo projeto" }),
);
expect(screen.getByRole("dialog")).toHaveAccessibleName("Lavação 047");
fireEvent.keyDown(screen.getByRole("dialog"), { key: "ArrowLeft" });
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
});
it("offers the site and the code when they exist", () => {
const dialog = setup("lavacao-047");
expect(
within(dialog).getByRole("link", { name: /Abrir projeto/ }),
).toHaveAttribute("href", "https://landing-page-lavacao047.vercel.app");
expect(
within(dialog).queryByRole("link", { name: /Ver codigo/ }),
).toBeNull();
});
it("closes with the close button, with the backdrop or with Esc, and unlocks the page", () => {
setup();
fireEvent.click(screen.getByRole("button", { name: "Fechar" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.documentElement).not.toHaveClass("ds-lb-open");
cleanup();
const dialog = setup();
fireEvent.pointerDown(dialog);
fireEvent.click(dialog); // clique no fundo (o próprio <dialog>)
expect(screen.queryByRole("dialog")).toBeNull();
cleanup();
const again = setup();
act(() => {
again.removeAttribute("open"); // o Esc nativo fecha o <dialog> e só então dispara `close`
again.dispatchEvent(new Event("close"));
});
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.documentElement).not.toHaveClass("ds-lb-open");
});
it("does not autoplay for users who prefer reduced motion", () => {
reducedMotion = true;
const dialog = setup();
expect(dialog.querySelector("video")).not.toHaveAttribute("autoplay");
});
});
describe("ProjectLightbox: revisão", () => {
it("survives React StrictMode in development: the double effect must not close the window it just opened", () => {
// o `close` real é assíncrono: o teste reproduz isso para pegar o evento atrasado
HTMLDialogElement.prototype.close = function close(
this: HTMLDialogElement,
) {
this.removeAttribute("open");
queueMicrotask(() => this.dispatchEvent(new Event("close")));
};
jest.useFakeTimers();
try {
render(
<StrictMode>
<ShowcaseProvider projects={projects}>
<Card id="lavacao-047" />
</ShowcaseProvider>
</StrictMode>,
);
fireEvent.click(
screen.getByRole("button", { name: "ampliar lavacao-047" }),
);
act(() => {
jest.advanceTimersByTime(50);
});
expect(screen.getByRole("dialog")).toHaveAttribute("open");
} finally {
jest.useRealTimers();
HTMLDialogElement.prototype.close = function close(
this: HTMLDialogElement,
) {
this.removeAttribute("open");
this.dispatchEvent(new Event("close"));
};
}
});
it("ignores a late close event from a dialog that is open again", () => {
const dialog = setup();
act(() => {
dialog.dispatchEvent(new Event("close"));
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("does not close when a press starts inside the panel and is released on the backdrop", () => {
const dialog = setup();
fireEvent.pointerDown(
within(dialog).getByRole("slider", { name: "Posição do vídeo" }),
);
fireEvent.click(dialog);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("stays paused after switching device when the visitor had paused", () => {
const dialog = setup();
loadVideo(dialog);
fireEvent.click(within(dialog).getByRole("button", { name: "Reproduzir" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Pausar" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
expect(dialog.querySelector("video")).not.toHaveAttribute("autoplay");
expect(
within(dialog).getByRole("button", { name: "Reproduzir" }),
).toBeInTheDocument();
});
it("keeps playing after switching device when the video was playing", () => {
const dialog = setup();
loadVideo(dialog);
fireEvent.click(within(dialog).getByRole("button", { name: "Reproduzir" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
expect(dialog.querySelector("video")).toHaveAttribute("autoplay");
});
it("keeps the position when the visitor flips the device twice before the new video loads", () => {
if (!lavacao) throw new Error("config ausente");
const dialog = setup();
const desktop = loadVideo(dialog, lavacao.durations.desktop);
desktop.currentTime = lavacao.durations.desktop * 0.5;
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
// o vídeo do celular ainda não carregou (sem duração): volta de novo para o computador
fireEvent.click(within(dialog).getByRole("button", { name: "Computador" }));
const back = loadVideo(dialog, lavacao.durations.desktop);
expect(back.currentTime / lavacao.durations.desktop).toBeCloseTo(0.5, 1);
});
it("moves focus to the opposite arrow when the pressed one reaches its end", () => {
const dialog = setup("nutriscan");
// anterior, anterior: chega ao primeiro e o botão "Anterior" fica desabilitado
let current = within(dialog).getByRole("button", {
name: "Projeto anterior",
});
current.focus();
fireEvent.click(current);
current = within(screen.getByRole("dialog")).getByRole("button", {
name: "Projeto anterior",
});
current.focus();
fireEvent.click(current);
const after = within(screen.getByRole("dialog"));
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
expect(
after.getByRole("button", { name: "Projeto anterior" }),
).toBeDisabled();
expect(
after.getByRole("button", { name: "Próximo projeto" }),
).toHaveFocus();
});
it("announces which project is on screen", () => {
const dialog = setup("nutriscan");
expect(dialog.querySelector("[aria-live='polite']")).toHaveTextContent(
"NutriScan, projeto 3 de 5",
);
});
it("ignores arrow keys combined with modifiers, which belong to the browser", () => {
const dialog = setup("lavacao-047");
fireEvent.keyDown(dialog, { key: "ArrowRight", altKey: true });
expect(screen.getByRole("dialog")).toHaveAccessibleName("Lavação 047");
});
});
+400
View File
@@ -0,0 +1,400 @@
"use client";
import {
type ChangeEvent,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import {
chapterFraction,
chapterIndexAt,
type Device,
desktopFractionAt,
getShowcaseAssets,
mobileFractionAt,
} from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { PhoneFrame } from "./PhoneFrame";
import type { ShowcaseItem } from "./ShowcaseProvider";
const formatTime = (seconds: number) => {
const whole = Math.max(0, Math.floor(seconds));
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
};
/**
* Vídeo ampliado: janela modal nativa (<dialog>: foco preso, Esc fecha, foco volta ao botão que abriu),
* com troca desktop/celular que mantém o ponto do site, capítulos clicáveis e controles próprios.
*/
export function ProjectLightbox({
items,
index,
onClose,
onNavigate,
}: {
items: ShowcaseItem[];
index: number;
onClose: () => void;
onNavigate: (index: number) => void;
}) {
const { config, project } = items[index];
const reducedMotion = useReducedMotion();
const titleId = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const previousRef = useRef<HTMLButtonElement>(null);
const nextRef = useRef<HTMLButtonElement>(null);
const pendingFraction = useRef<number | null>(null);
const pressedOnBackdrop = useRef(false);
const [device, setDevice] = useState<Device>(() =>
window.matchMedia("(max-width: 639px)").matches ? "mobile" : "desktop",
);
// o vídeo de cada aparelho/projeto nasce tocando só se o visitante não pausou e não pediu menos movimento
const [autoplay, setAutoplay] = useState(
() => !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
const wantsToPlay = useRef(autoplay);
const [playing, setPlaying] = useState(false);
const [fraction, setFraction] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
if (!reducedMotion) return;
wantsToPlay.current = false;
setAutoplay(false);
}, [reducedMotion]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
closeRef.current?.focus();
// travar a rolagem da página tira a barra de rolagem clássica: o espaço dela é devolvido para nada pular
const root = document.documentElement;
root.style.setProperty(
"--ds-scrollbar",
`${Math.max(0, window.innerWidth - root.clientWidth)}px`,
);
root.classList.add("ds-lb-open");
return () => {
root.classList.remove("ds-lb-open");
root.style.removeProperty("--ds-scrollbar");
// sem dialog.close() aqui: no StrictMode do dev a limpeza roda no meio da abertura e o evento `close`
// (assíncrono) chegaria depois de reaberto; sair do DOM já basta para soltar a janela modal
};
}, []);
// o marcador acompanha o vídeo quadro a quadro enquanto ele toca (timeupdate só chega umas 4 vezes por segundo)
useEffect(() => {
if (!playing) return;
let id = 0;
const tick = () => {
const current = videoRef.current;
if (current?.duration)
setFraction(current.currentTime / current.duration);
id = requestAnimationFrame(tick);
};
id = requestAnimationFrame(tick);
return () => cancelAnimationFrame(id);
}, [playing]);
const assets = getShowcaseAssets(config.id, device);
const desktopFraction =
device === "desktop" ? fraction : desktopFractionAt(config, fraction);
const chapter = chapterIndexAt(config, desktopFraction);
const url = displayUrl(project);
const canOpenSite =
project.demoUrl &&
(project.demoAvailability === "available" ||
project.demoAvailability === "unknown");
const switchDevice = (next: Device) => {
if (next === device) return;
const video = videoRef.current;
// se o novo vídeo ainda não tem duração (duplo clique), vale a posição já gravada no estado
const current = video?.duration
? video.currentTime / video.duration
: fraction;
pendingFraction.current =
next === "mobile"
? mobileFractionAt(config, current)
: desktopFractionAt(config, current);
setFraction(pendingFraction.current);
setPlaying(false);
setAutoplay(wantsToPlay.current);
setDevice(next);
};
const navigate = (next: number) => {
if (next < 0 || next >= items.length) return;
pendingFraction.current = null;
setFraction(0);
setPlaying(false);
setAutoplay(wantsToPlay.current);
// o botão que acabou de chegar ao limite fica desabilitado: o foco passa para o do outro lado
const active = document.activeElement;
if (active === previousRef.current && next === 0) nextRef.current?.focus();
if (active === nextRef.current && next === items.length - 1)
previousRef.current?.focus();
onNavigate(next);
};
const handleMetadata = () => {
const video = videoRef.current;
if (!video) return;
setDuration(video.duration);
if (pendingFraction.current !== null) {
video.currentTime = pendingFraction.current * video.duration;
pendingFraction.current = null;
}
};
const handleTime = () => {
const video = videoRef.current;
if (video?.duration) setFraction(video.currentTime / video.duration);
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
wantsToPlay.current = video.paused;
if (video.paused) video.play().catch(() => {});
else video.pause();
};
const seekTo = useCallback((next: number) => {
const video = videoRef.current;
if (!video?.duration) return;
video.currentTime = Math.min(1, Math.max(0, next)) * video.duration;
setFraction(next);
}, []);
const onKeyDown = (event: KeyboardEvent<HTMLDialogElement>) => {
if (event.target instanceof HTMLInputElement) return;
// Alt+Seta é "voltar/avançar" do navegador
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return;
if (event.key === "ArrowLeft") navigate(index - 1);
if (event.key === "ArrowRight") navigate(index + 1);
};
// clique no fundo escuro fecha, mas só se o toque também começou nele (arrastar a barra de posição e soltar fora não conta)
const onBackdropPointerDown = (event: PointerEvent<HTMLDialogElement>) => {
pressedOnBackdrop.current = event.target === event.currentTarget;
};
const onBackdropClick = (event: MouseEvent<HTMLDialogElement>) => {
if (event.target === event.currentTarget && pressedOnBackdrop.current)
dialogRef.current?.close();
pressedOnBackdrop.current = false;
};
const video = (
// biome-ignore lint/a11y/useMediaCaption: demonstração sem áudio; a descrição está no título e nos capítulos
<video
key={`${config.id}-${device}`}
ref={videoRef}
className="ds-lb-video"
autoPlay={autoplay}
muted
loop
playsInline
preload="auto"
poster={assets.poster}
width={assets.width}
height={assets.height}
onLoadedMetadata={handleMetadata}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={handleTime}
aria-label={`Vídeo de ${project.title}, versão ${device === "desktop" ? "para computador" : "para celular"}`}
>
{assets.sources.map((source) => (
<source key={source.src} src={source.src} type={source.type} />
))}
</video>
);
return (
<dialog
ref={dialogRef}
className="ds-lb"
aria-labelledby={titleId}
data-lenis-prevent
style={{ "--accent": config.accent } as CSSProperties}
onClose={(event) => {
// um `close` atrasado de uma janela que já foi reaberta (StrictMode) não pode fechar a nova
if (!event.currentTarget.open) onClose();
}}
onPointerDown={onBackdropPointerDown}
onClick={onBackdropClick}
onKeyDown={onKeyDown}
>
<div className="ds-lb-panel">
<p className="sr-only" aria-live="polite">
{`${project.title}, projeto ${index + 1} de ${items.length}`}
</p>
<header className="ds-lb-head">
<div className="ds-lb-heading">
<p className="ds-lb-count">
{String(index + 1).padStart(2, "0")} /{" "}
{String(items.length).padStart(2, "0")}
</p>
<h3 id={titleId} className="ds-lb-title">
{project.title}
</h3>
</div>
<fieldset className="ds-lb-toggle">
<legend className="sr-only">Versão do site</legend>
{(["desktop", "mobile"] as const).map((option) => (
<button
key={option}
type="button"
aria-pressed={device === option}
onClick={() => switchDevice(option)}
>
{option === "desktop" ? "Computador" : "Celular"}
</button>
))}
</fieldset>
<div className="ds-lb-nav">
<button
ref={previousRef}
type="button"
onClick={() => navigate(index - 1)}
disabled={index === 0}
aria-label="Projeto anterior"
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M10 3 5 8l5 5" />
</svg>
</button>
<button
ref={nextRef}
type="button"
onClick={() => navigate(index + 1)}
disabled={index === items.length - 1}
aria-label="Próximo projeto"
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="m6 3 5 5-5 5" />
</svg>
</button>
<button
ref={closeRef}
type="button"
className="ds-lb-close"
onClick={() => dialogRef.current?.close()}
aria-label="Fechar"
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="m3 3 10 10M13 3 3 13" />
</svg>
</button>
</div>
</header>
<div className="ds-lb-stage" data-device={device}>
{device === "desktop" ? (
<BrowserFrame url={url} className="ds-lb-frame">
<div
className="ds-lb-screen"
style={{ backgroundImage: `url(${assets.poster})` }}
>
{video}
</div>
</BrowserFrame>
) : (
<PhoneFrame className="ds-lb-phone" poster={assets.poster}>
{video}
</PhoneFrame>
)}
</div>
<div className="ds-lb-controls">
<button
type="button"
className="ds-lb-play"
onClick={togglePlay}
aria-label={playing ? "Pausar" : "Reproduzir"}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
{playing ? (
<path d="M4 3h3v10H4zM9 3h3v10H9z" />
) : (
<path d="M4 2.5v11l9-5.5z" />
)}
</svg>
</button>
<span className="ds-lb-time" aria-hidden="true">
{formatTime(fraction * duration)} / {formatTime(duration)}
</span>
<input
type="range"
className="ds-lb-range"
min={0}
max={1000}
step={1}
value={Math.round(fraction * 1000)}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
seekTo(Number(event.target.value) / 1000)
}
aria-label="Posição do vídeo"
aria-valuetext={`${formatTime(fraction * duration)} de ${formatTime(duration)}`}
/>
</div>
<ol className="ds-lb-chapters" aria-label="Trechos do site">
{config.chapters.map((entry, i) => (
<li key={entry.label}>
<button
type="button"
aria-current={i === chapter ? "true" : undefined}
onClick={() => seekTo(chapterFraction(config, i, device))}
>
<span aria-hidden="true">{String(i + 1).padStart(2, "0")}</span>{" "}
{entry.label}
</button>
</li>
))}
</ol>
<div className="ds-lb-links">
{canOpenSite && (
<a
className="ds-link ds-link-accent"
href={project.demoUrl ?? undefined}
target="_blank"
rel="noreferrer"
>
Abrir projeto
<span className="sr-only"> (abre em nova aba)</span>
</a>
)}
{project.githubUrl && (
<a
className="ds-link ds-link-muted"
href={project.githubUrl}
target="_blank"
rel="noreferrer"
>
Ver codigo
<span className="sr-only"> (abre em nova aba)</span>
</a>
)}
</div>
</div>
</dialog>
);
}
@@ -0,0 +1,177 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import { ProjectMedia } from "./ProjectMedia";
import { ShowcaseProvider } from "./ShowcaseProvider";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
jest.mock("./ProjectLightbox", () => ({
ProjectLightbox: () => <div role="dialog" aria-label="ampliado" />,
}));
const projects = getFeaturedProjects(null);
const nutriscan = projects.find((project) => project.id === "nutriscan");
if (!nutriscan) throw new Error("projeto ausente");
type Callback = IntersectionObserverCallback;
const callbacks: Callback[] = [];
const Original = global.IntersectionObserver;
beforeEach(() => {
jest.useFakeTimers();
reducedMotion = false;
callbacks.length = 0;
global.IntersectionObserver = class {
constructor(callback: Callback) {
callbacks.push(callback);
}
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
global.IntersectionObserver = Original;
});
function renderCard() {
const view = render(
<ShowcaseProvider projects={projects}>
<article className="ds-card">
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />
</article>
</ShowcaseProvider>,
);
const frame = view.container.querySelector(".ds-frame") as HTMLElement;
frame.getBoundingClientRect = () =>
({
top: 300,
height: 300,
bottom: 600,
left: 0,
right: 400,
width: 400,
x: 0,
y: 300,
}) as DOMRect;
return { ...view, frame };
}
function show(frame: HTMLElement) {
act(() => {
for (const callback of callbacks)
callback(
[
{
target: frame,
intersectionRatio: 1,
isIntersecting: true,
} as unknown as IntersectionObserverEntry,
],
{} as IntersectionObserver,
);
jest.advanceTimersByTime(40);
});
}
describe("ProjectMedia", () => {
it("frames the video with the project's address and a button that enlarges it", () => {
renderCard();
expect(screen.getByText("nutriscan.evandro.dev.br")).toBeInTheDocument();
const expand = screen.getByRole("button", {
name: "Ampliar o vídeo de NutriScan",
});
expect(expand).toHaveAttribute("aria-haspopup", "dialog");
fireEvent.click(expand);
expect(
screen.getByRole("dialog", { name: "ampliado" }),
).toBeInTheDocument();
});
it("plays only while the card is the active one", () => {
const { frame, container } = renderCard();
const video = container.querySelector("video") as HTMLVideoElement;
expect(container.querySelector(".ds-frame")).toHaveAttribute(
"data-active",
"false",
);
expect(video.querySelectorAll("source")).toHaveLength(0);
show(frame);
expect(container.querySelector(".ds-frame")).toHaveAttribute(
"data-active",
"true",
);
expect(video.querySelectorAll("source")).toHaveLength(2);
});
it("lets the visitor pause the videos that play on their own, and resume them", () => {
const { frame, container } = renderCard();
show(frame);
expect(container.querySelector(".ds-frame")).toHaveAttribute(
"data-active",
"true",
);
fireEvent.click(
screen.getByRole("button", { name: "Pausar os vídeos dos projetos" }),
);
act(() => {
jest.advanceTimersByTime(40);
});
expect(container.querySelector(".ds-frame")).toHaveAttribute(
"data-active",
"false",
);
fireEvent.click(
screen.getByRole("button", { name: "Retomar os vídeos dos projetos" }),
);
act(() => {
jest.advanceTimersByTime(40);
});
expect(container.querySelector(".ds-frame")).toHaveAttribute(
"data-active",
"true",
);
});
it("has no pause button when nothing plays on its own (reduced motion)", () => {
reducedMotion = true;
renderCard();
expect(
screen.queryByRole("button", { name: /vídeos dos projetos/ }),
).toBeNull();
});
it("renders nothing for a project without a recorded video", () => {
const { container } = render(
<ShowcaseProvider projects={projects}>
<ProjectMedia project={{ id: "twins-lab", title: "Twins Lab" }} />
</ShowcaseProvider>,
);
expect(container.querySelector(".ds-frame")).toBeNull();
});
it("does nothing, without crashing, when used outside the provider", () => {
const { container } = render(
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />,
);
fireEvent.click(
screen.getByRole("button", { name: "Ampliar o vídeo de NutriScan" }),
);
expect(container.querySelector(".ds-frame")).not.toBeNull();
});
});
+79
View File
@@ -0,0 +1,79 @@
"use client";
import type { CSSProperties } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { getProjectVideo, getShowcaseConfig } from "@/lib/project-showcase";
import { ProjectVideo } from "../ProjectVideo";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { useShowcaseCard } from "./ShowcaseProvider";
/**
* Capa do card com vídeo: o vídeo dentro de uma moldura de navegador, com brilho da cor do site quando é o
* card que toca, e o botão que amplia (a tela inteira é o botão).
*/
export function ProjectMedia({
project,
}: {
project: {
id: string;
title: string;
demoUrl?: string | null;
githubUrl?: string | null;
};
}) {
const { frameRef, active, open, userPaused, toggleAutoplay } =
useShowcaseCard(project.id);
const reducedMotion = useReducedMotion();
const video = getProjectVideo(project.id);
const config = getShowcaseConfig(project.id);
if (!video || !config) return null;
return (
<div
ref={frameRef}
className="ds-frame"
data-active={active}
style={{ "--accent": config.accent } as CSSProperties}
>
<BrowserFrame url={displayUrl(project)}>
<div
className="ds-frame-screen"
style={{ backgroundImage: `url(${video.poster})` }}
>
<ProjectVideo video={video} title={project.title} active={active} />
<button
type="button"
className="ds-frame-expand"
onClick={open}
aria-haspopup="dialog"
aria-label={`Ampliar o vídeo de ${project.title}`}
>
<span className="ds-frame-chip" aria-hidden="true">
Ampliar
</span>
</button>
{!reducedMotion && (
<button
type="button"
className="ds-frame-pause"
onClick={toggleAutoplay}
aria-label={
userPaused
? "Retomar os vídeos dos projetos"
: "Pausar os vídeos dos projetos"
}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
{userPaused ? (
<path d="M4 2.5v11l9-5.5z" />
) : (
<path d="M4 3h3v10H4zM9 3h3v10H9z" />
)}
</svg>
</button>
)}
</div>
</BrowserFrame>
</div>
);
}
@@ -0,0 +1,374 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
import { ProjectReel } from "./ProjectReel";
import { ShowcaseProvider } from "./ShowcaseProvider";
import { reelOffset, videoProgress } from "./showcase-math";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
const PIN = 800;
const CONTAINER = 7000;
const SCROLLABLE = CONTAINER - PIN;
const HEADER = 60;
let reelTop = HEADER;
const projects = getFeaturedProjects(null);
const weights = [
"sev-modaintima",
"lavacao-047",
"nutriscan",
"sc-plus",
"as-servicos",
].map((id) => getShowcaseConfig(id)?.weight ?? 1);
const originalRect = Element.prototype.getBoundingClientRect;
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetHeight",
);
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get(this: HTMLElement) {
if (this.classList.contains("ds-reel")) return CONTAINER;
if (this.classList.contains("ds-reel-pin")) return PIN;
return 0;
},
});
Element.prototype.getBoundingClientRect = function rect(this: Element) {
if (this.classList.contains("ds-reel"))
return {
top: reelTop,
bottom: reelTop + CONTAINER,
height: CONTAINER,
left: 0,
right: 1000,
width: 1000,
x: 0,
y: reelTop,
} as DOMRect;
return originalRect.call(this);
};
window.scrollTo = jest.fn();
});
afterAll(() => {
Element.prototype.getBoundingClientRect = originalRect;
if (originalOffsetHeight)
Object.defineProperty(
HTMLElement.prototype,
"offsetHeight",
originalOffsetHeight,
);
});
beforeEach(() => {
jest.useFakeTimers();
reducedMotion = false;
reelTop = HEADER;
(window.scrollTo as jest.Mock).mockClear();
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
function renderReel() {
const view = render(
<ShowcaseProvider projects={projects}>
<ProjectReel />
</ShowcaseProvider>,
);
act(() => {
jest.advanceTimersByTime(50);
});
return view;
}
/** jsdom não carrega mídia: cada <video> da vitrine ganha duração e prontidão para aceitar buscas. */
function readyVideos() {
for (const video of document.querySelectorAll("video")) {
Object.defineProperty(video, "readyState", {
configurable: true,
value: 4,
});
Object.defineProperty(video, "duration", { configurable: true, value: 30 });
let time = 0;
Object.defineProperty(video, "currentTime", {
configurable: true,
get: () => time,
set: (value: number) => {
time = value;
},
});
}
}
function scrollTo(progress: number) {
reelTop = HEADER - progress * SCROLLABLE;
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(1500);
});
}
const sources = () =>
[...document.querySelectorAll(".ds-reel-layer video")].map((video) =>
video.getAttribute("src"),
);
const activeLayer = () =>
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
describe("ProjectReel", () => {
it("renders nothing for users who prefer reduced motion", () => {
reducedMotion = true;
renderReel();
expect(
screen.queryByRole("region", { name: /Vitrine animada/ }),
).toBeNull();
});
it("lists every project by name, starting on the first with its chapters", () => {
renderReel();
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
for (const name of [
"SEV Moda Íntima",
"Lavação 047",
"NutriScan",
"SC Plus Aquicultura",
"AS Serviços",
])
expect(within(nav).getByRole("button", { name })).toBeInTheDocument();
expect(
within(nav).getByRole("button", { name: "SEV Moda Íntima" }),
).toHaveAttribute("aria-current", "true");
expect(
within(nav).getByRole("list", { name: "Trechos de SEV Moda Íntima" }),
).toBeInTheDocument();
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
});
it("hides the layers that are not on screen from assistive technology and from the keyboard", () => {
renderReel();
const layers = [...document.querySelectorAll(".ds-reel-layer")];
expect(layers).toHaveLength(5);
for (const layer of layers.slice(1)) {
expect(layer).toHaveAttribute("aria-hidden", "true");
expect(layer).toHaveAttribute("inert");
}
expect(layers[0]).not.toHaveAttribute("inert");
});
it("moves to the next project and syncs the laptop and the phone to the same point of the site", () => {
renderReel();
readyVideos();
// meio do trecho da Lavação (segundo projeto)
const t = reelOffset(weights, 1, 0.5);
scrollTo(t);
const layer = activeLayer();
expect(layer).toHaveTextContent("Lavação 047");
const lavacao = getShowcaseConfig("lavacao-047");
if (!lavacao) throw new Error("config ausente");
const p = videoProgress(0.5);
const desktop = layer.querySelector(
".ds-reel-screen video",
) as HTMLVideoElement;
const phone = layer.querySelector(
".ds-phone-screen video",
) as HTMLVideoElement;
expect(desktop.currentTime).toBeCloseTo(p * 30, 1);
expect(phone.currentTime).toBeCloseTo(mobileFractionAt(lavacao, p) * 30, 1);
});
it("names the chapter on screen and counts them", () => {
renderReel();
readyVideos();
scrollTo(reelOffset(weights, 1, 0.5)); // Lavação, a meio caminho
const layer = activeLayer();
const lavacao = getShowcaseConfig("lavacao-047");
expect(lavacao).toBeDefined();
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
/^0\d\/06$/,
);
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
expect(labels).toContain(
layer.querySelector(".ds-reel-chapter strong")?.textContent,
);
});
it("holds the first and last frame at the ends of the pinned range", () => {
renderReel();
readyVideos();
scrollTo(0);
const first = document.querySelector(
'.ds-reel-layer[data-state="active"] .ds-reel-screen video',
) as HTMLVideoElement;
expect(first.currentTime).toBe(0);
scrollTo(1);
expect(activeLayer()).toHaveTextContent("AS Serviços");
const last = activeLayer().querySelector(
".ds-reel-screen video",
) as HTMLVideoElement;
expect(last.currentTime).toBeCloseTo(30, 1);
});
it("downloads nothing while the page is far from the reel", () => {
reelTop = 6000;
renderReel();
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(400);
});
expect(sources().every((src) => src === null)).toBe(true);
});
it("downloads only the project on screen and its neighbours, and lets go of the rest", () => {
renderReel();
scrollTo(0);
act(() => {
jest.advanceTimersByTime(400);
});
let loaded = sources().filter(Boolean);
expect(
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
).toBe(true);
expect(
loaded.some((src) => src?.includes("lavacao-047/scrub-mobile.mp4")),
).toBe(true);
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
expect(loaded.some((src) => src?.includes("as-servicos"))).toBe(false);
// vai para o último projeto: o primeiro sai da janela e solta o arquivo
scrollTo(1);
act(() => {
jest.advanceTimersByTime(400);
});
loaded = sources().filter(Boolean);
expect(
loaded.some((src) => src?.includes("as-servicos/scrub-desktop.mp4")),
).toBe(true);
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
});
it("only downloads where the scroll settles, not every project it flies past", () => {
renderReel();
scrollTo(0);
// atravessa vários projetos em menos de 300 ms
for (const project of [1, 2, 3]) {
reelTop = HEADER - reelOffset(weights, project, 0.5) * SCROLLABLE;
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(60);
});
}
act(() => {
jest.advanceTimersByTime(400);
});
const loaded = sources().filter(Boolean);
expect(loaded.some((src) => src?.includes("sc-plus"))).toBe(true);
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(true); // vizinho do projeto onde parou
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
});
it("does not download the hidden laptop video on a phone-sized screen", () => {
const original = window.matchMedia;
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: query.includes("max-width: 639px"),
media: query,
addEventListener() {},
removeEventListener() {},
}));
try {
renderReel();
scrollTo(0);
act(() => {
jest.advanceTimersByTime(400);
});
const loaded = sources().filter(Boolean);
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(
true,
);
expect(loaded.some((src) => src?.includes("scrub-desktop.mp4"))).toBe(
false,
);
} finally {
window.matchMedia = original;
}
});
it("respects the data saver: no scroll video is downloaded", () => {
Object.defineProperty(navigator, "connection", {
configurable: true,
value: { saveData: true },
});
try {
renderReel();
scrollTo(0);
act(() => {
jest.advanceTimersByTime(400);
});
expect(sources().every((src) => src === null)).toBe(true);
} finally {
Object.defineProperty(navigator, "connection", {
configurable: true,
value: undefined,
});
}
});
it("holds the page height before it mounts, so a jump to #contact lands in the right place", () => {
const html = renderToString(
<ShowcaseProvider projects={projects}>
<ProjectReel />
</ShowcaseProvider>,
);
expect(html).toContain("ds-reel-placeholder");
expect(html).toContain("--reel-sum");
expect(html).toContain('aria-hidden="true"');
expect(html).not.toContain("<video");
});
it("scrolls to the start of a project when its button is pressed", () => {
renderReel();
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
fireEvent.click(within(nav).getByRole("button", { name: /NutriScan/ }));
act(() => {
jest.advanceTimersByTime(1200);
});
const calls = (window.scrollTo as jest.Mock).mock.calls;
const last = calls[calls.length - 1][0] as { top: number };
const expected =
HEADER - HEADER + reelOffset(weights, 2, 0) * SCROLLABLE + 2;
expect(last.top).toBeCloseTo(expected, 0);
});
it("scrolls to a chapter inside the project when its link is pressed", () => {
renderReel();
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
fireEvent.click(within(nav).getByRole("button", { name: /As favoritas/ }));
act(() => {
jest.advanceTimersByTime(1200);
});
const calls = (window.scrollTo as jest.Mock).mock.calls;
const last = calls[calls.length - 1][0] as { top: number };
expect(last.top).toBeGreaterThan(reelOffset(weights, 0, 0) * SCROLLABLE);
expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE);
});
});
+568
View File
@@ -0,0 +1,568 @@
"use client";
import {
type CSSProperties,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import {
chapterFraction,
chapterIndexAt,
getScrubSource,
getShowcaseAssets,
mobileFractionAt,
} from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { PhoneFrame } from "./PhoneFrame";
import { useShowcaseItems, useShowcaseOpen } from "./ShowcaseProvider";
import {
easeToward,
locateInReel,
REEL_HOLD,
reelOffset,
videoProgress,
} from "./showcase-math";
// a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro
let headerCache = { width: -1, height: 60 };
const headerHeight = () => {
if (headerCache.width !== window.innerWidth)
headerCache = {
width: window.innerWidth,
height:
Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
"--site-header-height",
),
) || 60,
};
return headerCache.height;
};
/** Verdadeiro enquanto a tela é estreita a ponto de a vitrine mostrar só o celular (o notebook fica escondido). */
function usePhoneOnly() {
const [phoneOnly, setPhoneOnly] = useState(false);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(max-width: 639px)");
const update = () => setPhoneOnly(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return phoneOnly;
}
/** "Economizar dados" ligado: a vitrine fica nos posters, sem baixar os vídeos de rolagem. */
const saveDataOn = () =>
(navigator as Navigator & { connection?: { saveData?: boolean } }).connection
?.saveData === true;
/** Põe o vídeo no instante `fraction` (0 a 1) sem repetir buscas enquanto a anterior ainda não terminou. */
function seekVideo(video: HTMLVideoElement | null, fraction: number) {
if (!video || video.readyState < 1 || !video.duration) return;
const target = Math.min(
video.duration - 0.001,
Math.max(0, fraction * video.duration),
);
if (!video.seeking && Math.abs(video.currentTime - target) > 1 / 48)
video.currentTime = target;
}
/** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */
function prime(video: HTMLVideoElement) {
if (video.dataset.primed) return;
video.dataset.primed = "true";
video
.play()
.then(() => video.pause())
.catch(() => {});
}
let stopScroll = () => {};
/** Rola até `top` em menos de um segundo; qualquer gesto do usuário interrompe. */
function scrollToSmoothly(top: number) {
stopScroll();
const from = window.scrollY;
const distance = top - from;
const duration = Math.min(900, 380 + Math.abs(distance) * 0.12);
const started = performance.now();
let id = 0;
const stop = () => {
cancelAnimationFrame(id);
window.removeEventListener("wheel", stop);
window.removeEventListener("touchstart", stop);
};
stopScroll = stop;
window.addEventListener("wheel", stop, { passive: true });
window.addEventListener("touchstart", stop, { passive: true });
const step = (now: number) => {
const t = Math.min(1, (now - started) / duration);
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
// "instant": o CSS global tem scroll-behavior: smooth e cada quadro já é um passo da animação
window.scrollTo({ top: from + distance * eased, behavior: "instant" });
if (t < 1) id = requestAnimationFrame(step);
else stop();
};
id = requestAnimationFrame(step);
}
/**
* Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a
* página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; notebook e celular
* mostram o mesmo trecho do site ao mesmo tempo.
*
* Só existe com JavaScript e sem prefers-reduced-motion; antes disso, e nesses casos, os cards fazem o papel.
*/
export function ProjectReel() {
const items = useShowcaseItems();
const openProject = useShowcaseOpen();
const reducedMotion = useReducedMotion();
const [mounted, setMounted] = useState(false);
const [active, setActive] = useState(0);
const [chapter, setChapter] = useState(0);
const [near, setNear] = useState(false);
const [settled, setSettled] = useState(0);
const [saveData, setSaveData] = useState(false);
const phoneOnly = usePhoneOnly();
const containerRef = useRef<HTMLDivElement>(null);
const pinRef = useRef<HTMLDivElement>(null);
const indexRef = useRef<HTMLOListElement>(null);
const desktopVideos = useRef<Array<HTMLVideoElement | null>>([]);
const phoneVideos = useRef<Array<HTMLVideoElement | null>>([]);
const bars = useRef<Array<HTMLElement | null>>([]);
const live = useRef({
target: [] as number[],
smooth: [] as number[],
active: 0,
chapter: 0,
raf: 0,
last: 0,
near: false,
});
const weights = useMemo(
() => items.map((item) => item.config.weight),
[items],
);
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
useEffect(() => {
setSaveData(saveDataOn());
setMounted(true);
}, []);
// só o projeto em cena e os vizinhos baixam vídeo; e só depois que a rolagem parou ali (passar voando por
// um projeto no clique do índice não pode baixar todos os do caminho)
useEffect(() => {
const timer = setTimeout(() => setSettled(active), 300);
return () => clearTimeout(timer);
}, [active]);
// na faixa horizontal (tablet e celular) o projeto em cena precisa continuar à vista
useEffect(() => {
const list = indexRef.current;
const button = list?.children[active]?.querySelector("button");
if (
!list ||
!button ||
list.scrollWidth <= list.clientWidth ||
typeof list.scrollTo !== "function"
)
return;
list.scrollTo({
left: Math.max(0, button.offsetLeft - 16),
behavior: "smooth",
});
}, [active]);
const isAttached = useCallback(
(index: number) => near && !saveData && Math.abs(index - settled) <= 1,
[near, saveData, settled],
);
// quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória)
useEffect(() => {
items.forEach((_, index) => {
if (isAttached(index)) return;
for (const video of [
desktopVideos.current[index],
phoneVideos.current[index],
])
if (video && video.networkState !== 0 && !video.getAttribute("src"))
video.load();
});
}, [items, isAttached]);
const frame = useCallback(
(now: number) => {
const state = live.current;
const seconds = state.last ? (now - state.last) / 1000 : 0;
state.last = now;
let moving = false;
items.forEach((item, index) => {
if (Math.abs(index - state.active) > 1) return;
const before = state.smooth[index] ?? 0;
const next = easeToward(before, state.target[index] ?? 0, seconds);
state.smooth[index] = next;
if (Math.abs(next - (state.target[index] ?? 0)) > 0.0004) moving = true;
seekVideo(desktopVideos.current[index], next);
seekVideo(
phoneVideos.current[index],
mobileFractionAt(item.config, next),
);
const bar = bars.current[index];
if (bar) bar.style.setProperty("--p", next.toFixed(4));
});
const current = items[state.active];
if (current) {
const index = chapterIndexAt(
current.config,
state.smooth[state.active] ?? 0,
);
if (index !== state.chapter) {
state.chapter = index;
setChapter(index);
}
}
state.raf = moving ? requestAnimationFrame(frame) : 0;
},
[items],
);
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
const syncVideos = useCallback(
(index: number) => {
const item = items[index];
const at = live.current.smooth[index] ?? live.current.target[index] ?? 0;
seekVideo(desktopVideos.current[index], at);
if (item)
seekVideo(
phoneVideos.current[index],
mobileFractionAt(item.config, at),
);
},
[items],
);
const measure = useCallback(() => {
const container = containerRef.current;
const pin = pinRef.current;
// escondida por CSS (tela baixa, por exemplo): sem altura, não há o que medir nem o que carregar
if (!container || !pin || items.length === 0 || !container.offsetHeight)
return;
const state = live.current;
const rect = container.getBoundingClientRect();
const scrollable = rect.height - pin.offsetHeight;
const progress =
scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0;
const { index, local } = locateInReel(weights, progress);
state.target = items.map((_, i) =>
i < index ? 1 : i === index ? videoProgress(local) : 0,
);
const near =
rect.top < window.innerHeight * 2 && rect.bottom > -window.innerHeight;
if (near !== state.near) {
state.near = near;
setNear(near);
}
if (index !== state.active) {
state.active = index;
state.chapter = chapterIndexAt(
items[index].config,
state.smooth[index] ?? 0,
);
setActive(index);
setChapter(state.chapter);
}
if (state.near && !state.raf) {
state.last = 0;
state.raf = requestAnimationFrame(frame);
}
}, [frame, items, weights]);
useEffect(() => {
if (!mounted || reducedMotion || items.length === 0) return;
let pending = 0;
const schedule = () => {
if (!pending)
pending = requestAnimationFrame(() => {
pending = 0;
measure();
});
};
schedule();
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
const state = live.current;
return () => {
window.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
cancelAnimationFrame(pending);
cancelAnimationFrame(state.raf);
state.raf = 0;
};
}, [mounted, reducedMotion, items.length, measure]);
/** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
const scrollToReel = (index: number, local: number) => {
const container = containerRef.current;
const pin = pinRef.current;
if (!container || !pin) return;
const scrollable = container.offsetHeight - pin.offsetHeight;
const top = container.getBoundingClientRect().top + window.scrollY;
// 2px além: garante que a conta caia dentro do trecho
scrollToSmoothly(
top - headerHeight() + reelOffset(weights, index, local) * scrollable + 2,
);
};
const goTo = (index: number) => scrollToReel(index, 0);
const goToChapter = (index: number, chapterIndex: number) => {
const fraction = chapterFraction(
items[index].config,
chapterIndex,
"desktop",
);
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
};
if (items.length === 0 || (mounted && reducedMotion)) return null;
// antes de montar, um bloco da mesma altura segura o lugar: a página não muda de tamanho quando a vitrine
// aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS o esconde sem JavaScript, com
// prefers-reduced-motion e em telas baixas
if (!mounted)
return (
<div
className="ds-reel ds-reel-placeholder"
aria-hidden="true"
style={{ "--reel-sum": weightSum } as CSSProperties}
/>
);
const current = items[active];
return (
<section
className="ds-reel"
aria-label="Vitrine animada dos projetos"
style={{ "--reel-sum": weightSum } as CSSProperties}
ref={containerRef}
>
<div className="ds-reel-pin" ref={pinRef}>
<div className="ds-reel-grid">
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
<p className="ds-reel-hint">
<span aria-hidden="true" className="ds-reel-hint-line" />
Em movimento
<span className="ds-reel-hint-sub">Role para avançar</span>
</p>
<ol ref={indexRef}>
{items.map((item, index) => (
<li key={item.config.id}>
<button
type="button"
aria-current={index === active ? "true" : undefined}
aria-label={item.project.title}
onClick={() => goTo(index)}
>
<span className="ds-reel-num">
{String(index + 1).padStart(2, "0")}
</span>
<span className="ds-reel-name">{item.project.title}</span>
<i
className="ds-reel-bar"
aria-hidden="true"
ref={(element) => {
bars.current[index] = element;
}}
/>
</button>
{index === active && (
<ol
className="ds-reel-chapters"
aria-label={`Trechos de ${item.project.title}`}
>
{item.config.chapters.map((entry, i) => (
<li key={entry.label}>
<button
type="button"
aria-current={i === chapter ? "true" : undefined}
onClick={() => goToChapter(index, i)}
>
<span aria-hidden="true">
{String(i + 1).padStart(2, "0")}
</span>
{entry.label}
</button>
</li>
))}
</ol>
)}
</li>
))}
</ol>
</nav>
<div className="ds-reel-stage">
{items.map((item, index) => {
const { config, project } = item;
const isActive = index === active;
const attach = isAttached(index);
const desktop = getShowcaseAssets(config.id, "desktop");
const phone = getShowcaseAssets(config.id, "mobile");
return (
<div
key={config.id}
className="ds-reel-layer"
data-state={
isActive ? "active" : index < active ? "before" : "after"
}
style={{ "--accent": config.accent } as CSSProperties}
aria-hidden={isActive ? undefined : true}
inert={!isActive}
>
<div className="ds-reel-set">
<div className="ds-reel-devices">
<BrowserFrame
url={displayUrl(project)}
className="ds-reel-laptop"
>
<div
className="ds-reel-screen"
style={{ backgroundImage: `url(${desktop.poster})` }}
>
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
<video
ref={(element) => {
desktopVideos.current[index] = element;
}}
src={
attach && !phoneOnly
? getScrubSource(config.id, "desktop")
: undefined
}
poster={desktop.poster}
muted
playsInline
preload={attach && !phoneOnly ? "auto" : "none"}
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={() => syncVideos(index)}
onLoadedData={(event) => prime(event.currentTarget)}
/>
</div>
</BrowserFrame>
<PhoneFrame
className="ds-reel-phone"
poster={phone.poster}
>
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
<video
ref={(element) => {
phoneVideos.current[index] = element;
}}
src={
attach
? getScrubSource(config.id, "mobile")
: undefined
}
poster={phone.poster}
muted
playsInline
preload={attach ? "auto" : "none"}
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={() => syncVideos(index)}
onLoadedData={(event) => prime(event.currentTarget)}
/>
</PhoneFrame>
</div>
<div className="ds-reel-caption">
<p className="ds-kicker">
{project.category} · {project.status}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && (
<p className="ds-reel-sub">{project.subtitle}</p>
)}
<div className="ds-reel-chapter">
<span className="ds-reel-chapter-count">
{String((isActive ? chapter : 0) + 1).padStart(
2,
"0",
)}
/{String(config.chapters.length).padStart(2, "0")}
</span>
<strong>
{config.chapters[isActive ? chapter : 0].label}
</strong>
<ol className="ds-reel-ticks" aria-hidden="true">
{config.chapters.map((entry, tick) => (
<li
key={entry.label}
data-state={
!isActive || tick > chapter
? "todo"
: tick === chapter
? "now"
: "done"
}
/>
))}
</ol>
</div>
<div className="ds-reel-actions">
<button
type="button"
className="ds-link ds-link-accent"
onClick={() => openProject(config.id)}
aria-haspopup="dialog"
>
Ampliar vídeo
</button>
{project.demoUrl &&
project.demoAvailability !== "unavailable" && (
<a
className="ds-link ds-link-muted"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
>
Abrir projeto
<span className="sr-only">
{" "}
(abre em nova aba)
</span>
</a>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
<p className="sr-only" aria-live="polite">
{current
? `${current.project.title}: ${current.config.chapters[chapter]?.label ?? ""}`
: ""}
</p>
</div>
</section>
);
}
@@ -0,0 +1,277 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import {
buildShowcaseItems,
ShowcaseProvider,
useShowcaseCard,
} from "./ShowcaseProvider";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
jest.mock("./ProjectLightbox", () => ({
ProjectLightbox: ({
index,
onClose,
}: {
index: number;
onClose: () => void;
}) => (
<div role="dialog" aria-label="ampliado">
aberto:{index}
<button type="button" onClick={onClose}>
fechar
</button>
</div>
),
}));
type FakeObserver = {
callback: IntersectionObserverCallback;
targets: Set<Element>;
};
let observers: FakeObserver[] = [];
const OriginalObserver = global.IntersectionObserver;
class FakeIntersectionObserver {
private readonly state: FakeObserver;
constructor(callback: IntersectionObserverCallback) {
this.state = { callback, targets: new Set() };
observers.push(this.state);
}
observe(element: Element) {
this.state.targets.add(element);
}
unobserve(element: Element) {
this.state.targets.delete(element);
}
disconnect() {
this.state.targets.clear();
}
takeRecords() {
return [];
}
}
function setVisibility(element: Element, ratio: number) {
for (const observer of observers) {
if (!observer.targets.has(element)) continue;
act(() => {
observer.callback(
[
{
target: element,
intersectionRatio: ratio,
isIntersecting: ratio > 0,
} as IntersectionObserverEntry,
],
observer as unknown as IntersectionObserver,
);
});
}
}
function place(element: Element, top: number) {
element.getBoundingClientRect = () =>
({
top,
height: 300,
bottom: top + 300,
left: 0,
right: 400,
width: 400,
x: 0,
y: top,
}) as DOMRect;
}
function flush() {
act(() => {
jest.advanceTimersByTime(40);
});
}
function Card({ id }: { id: string }) {
const { frameRef, active, open } = useShowcaseCard(id);
return (
<article className="ds-card">
<div ref={frameRef} data-testid={id} data-active={active} />
<button type="button" onClick={open}>
ampliar {id}
</button>
</article>
);
}
const projects = getFeaturedProjects(null);
function setup() {
const view = render(
<ShowcaseProvider projects={projects}>
<Card id="sev-modaintima" />
<Card id="lavacao-047" />
<Card id="nutriscan" />
</ShowcaseProvider>,
);
const frames = {
sev: screen.getByTestId("sev-modaintima"),
lavacao: screen.getByTestId("lavacao-047"),
nutri: screen.getByTestId("nutriscan"),
};
// tela de 768px de altura (jsdom): o meio fica em 384
place(frames.sev, 100); // centro 250
place(frames.lavacao, 230); // centro 380
place(frames.nutri, 900); // fora da tela
return { view, frames };
}
beforeEach(() => {
jest.useFakeTimers();
reducedMotion = false;
observers = [];
global.IntersectionObserver =
FakeIntersectionObserver as unknown as typeof IntersectionObserver;
});
afterEach(() => {
jest.useRealTimers();
global.IntersectionObserver = OriginalObserver;
});
const isActive = (element: Element) =>
element.getAttribute("data-active") === "true";
describe("ShowcaseProvider", () => {
it("lists only projects that have a recorded showcase, in showcase order", () => {
const items = buildShowcaseItems(projects);
expect(items.map((item) => item.config.id)).toEqual([
"sev-modaintima",
"lavacao-047",
"nutriscan",
"sc-plus",
"as-servicos",
]);
expect(
buildShowcaseItems(projects.filter((p) => p.id === "twins-lab")),
).toEqual([]);
});
it("plays nothing until a card is on screen, then the one closest to the middle", () => {
const { frames } = setup();
flush();
expect(Object.values(frames).some(isActive)).toBe(false);
setVisibility(frames.sev, 0.8);
setVisibility(frames.lavacao, 0.8);
flush();
expect(isActive(frames.lavacao)).toBe(true);
expect(isActive(frames.sev)).toBe(false);
});
it("does not count a card with less than a third on screen", () => {
const { frames } = setup();
setVisibility(frames.sev, 0.2);
setVisibility(frames.lavacao, 0.1);
flush();
expect(Object.values(frames).some(isActive)).toBe(false);
});
it("hands the video to the card under the mouse and takes it back when the mouse leaves", () => {
const { frames } = setup();
setVisibility(frames.sev, 0.8);
setVisibility(frames.lavacao, 0.8);
flush();
const sevCard = frames.sev.closest(".ds-card") as HTMLElement;
fireEvent.pointerEnter(sevCard, { pointerType: "mouse" });
flush();
expect(isActive(frames.sev)).toBe(true);
expect(isActive(frames.lavacao)).toBe(false);
fireEvent.pointerLeave(sevCard, { pointerType: "mouse" });
flush();
expect(isActive(frames.lavacao)).toBe(true);
});
it("ignores touch pointers: there is no hover on a phone", () => {
const { frames } = setup();
setVisibility(frames.sev, 0.8);
setVisibility(frames.lavacao, 0.8);
flush();
fireEvent.pointerEnter(frames.sev.closest(".ds-card") as HTMLElement, {
pointerType: "touch",
});
flush();
expect(isActive(frames.sev)).toBe(false);
expect(isActive(frames.lavacao)).toBe(true);
});
it("follows keyboard focus inside a card", () => {
const { frames } = setup();
setVisibility(frames.sev, 0.8);
setVisibility(frames.lavacao, 0.8);
flush();
fireEvent.focusIn(
screen.getByRole("button", { name: "ampliar sev-modaintima" }),
);
flush();
expect(isActive(frames.sev)).toBe(true);
});
it("re-evaluates on scroll", () => {
const { frames } = setup();
setVisibility(frames.sev, 0.8);
setVisibility(frames.lavacao, 0.8);
flush();
expect(isActive(frames.lavacao)).toBe(true);
place(frames.sev, 230);
place(frames.lavacao, 600);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
flush();
expect(isActive(frames.sev)).toBe(true);
});
it("pauses every card while the enlarged window is open, and resumes after", () => {
const { frames } = setup();
setVisibility(frames.lavacao, 0.8);
flush();
expect(isActive(frames.lavacao)).toBe(true);
fireEvent.click(
screen.getByRole("button", { name: "ampliar lavacao-047" }),
);
flush();
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
"aberto:1",
);
expect(isActive(frames.lavacao)).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
flush();
expect(screen.queryByRole("dialog")).toBeNull();
expect(isActive(frames.lavacao)).toBe(true);
});
it("never plays a card for users who prefer reduced motion", () => {
reducedMotion = true;
const { frames } = setup();
setVisibility(frames.lavacao, 1);
flush();
expect(isActive(frames.lavacao)).toBe(false);
});
it("stops listening when it unmounts", () => {
const { view, frames } = setup();
setVisibility(frames.lavacao, 1);
view.unmount();
expect(observers.every((observer) => observer.targets.size === 0)).toBe(
true,
);
});
});
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff. Show more