Compare commits
65
Commits
No files matched your search
@@ -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
|
||||||
+19
@@ -39,3 +39,22 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# local agent/editor state
|
||||||
|
/.augment/
|
||||||
|
.opencode/
|
||||||
|
.claude/settings.local.json
|
||||||
|
.superpowers/
|
||||||
|
/.worktrees/
|
||||||
|
/.impeccable/
|
||||||
|
/.serena/
|
||||||
|
|
||||||
|
# generated analysis artifacts
|
||||||
|
/graphify-out/
|
||||||
|
*.graphify.json
|
||||||
|
*.analysis.json
|
||||||
|
*.tmp.json
|
||||||
|
*.debug.json
|
||||||
|
|
||||||
|
.omo/
|
||||||
|
.playwright-mcp/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
coverage/
|
||||||
|
.git/
|
||||||
|
.pnpm-store/
|
||||||
|
.cache/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
target/
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Credits / Third-party assets
|
||||||
|
|
||||||
|
## Project screenshots
|
||||||
|
- `public/assets/prints/*.png` are captures taken with headless Chrome from
|
||||||
|
production deployments owned by the site author (evandro.dev.br), used as
|
||||||
|
on-screen content inside the notebook browser surface.
|
||||||
|
|
||||||
|
## WhatsApp icon
|
||||||
|
- The WhatsApp silhouette in `app/components/Contact.tsx` comes from
|
||||||
|
[Simple Icons](https://github.com/simple-icons/simple-icons/blob/develop/icons/whatsapp.svg).
|
||||||
|
It identifies the contact channel; it does not imply endorsement by WhatsApp.
|
||||||
@@ -1,36 +1,194 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
<div align="center">
|
||||||
|
|
||||||
## Getting Started
|
# Evandro.dev.br: software sob medida
|
||||||
|
|
||||||
First, run the development server:
|
Site comercial de Evandro Fonseca Junior para projetos de software sob medida: sistemas empresariais, aplicativos e integrações, com cases e contato direto por WhatsApp.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Node.js `22.x`
|
||||||
|
- pnpm `9.15.9`, fixado em `package.json#packageManager`
|
||||||
|
|
||||||
|
O projeto usa Next.js `16.3.3`, patch de segurança que corrige [GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36) e [GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4). A atualização substitui `16.3.0`, afetada por esses avisos. As auditorias de produção e desenvolvimento continuam sendo verificadas na CI.
|
||||||
|
|
||||||
|
## Início rápido
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
corepack [email protected] install --frozen-lockfile
|
||||||
# or
|
corepack [email protected] dev
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
O servidor de desenvolvimento usa Turbopack. Para executar o artefato de produção:
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
```bash
|
||||||
|
corepack [email protected] build
|
||||||
|
corepack [email protected] start
|
||||||
|
```
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
## Scripts
|
||||||
|
|
||||||
## Learn More
|
| Comando | Comportamento |
|
||||||
|
| --- | --- |
|
||||||
|
| `pnpm dev` | Inicia o Next.js em desenvolvimento com Turbopack. |
|
||||||
|
| `pnpm build` | Gera o build de produção com Turbopack. |
|
||||||
|
| `pnpm start` | Serve um build já gerado. |
|
||||||
|
| `pnpm lint` | Executa `biome check`. |
|
||||||
|
| `pnpm typecheck` | Gera os tipos de rota com `next typegen` e executa TypeScript sem emitir arquivos. |
|
||||||
|
| `pnpm format` | Formata o repositório com Biome. |
|
||||||
|
| `pnpm test` | Executa a suíte Jest. |
|
||||||
|
| `pnpm test:watch` | Executa Jest em modo watch. |
|
||||||
|
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
## Stack
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| Área | Pacotes usados |
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| --- | --- |
|
||||||
|
| 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. |
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
Todas as dependências diretas estão fixadas em versões exatas no manifesto; o lockfile é instalado sem atualização implícita.
|
||||||
|
|
||||||
## Deploy on Vercel
|
## Rotas
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 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`. |
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI e deploy
|
||||||
|
|
||||||
|
O workflow único em `.github/workflows/ci.yml` roda em cada `push` e `pull_request` com permissão `contents: read`. Ele instala Node 22 e pnpm conforme `packageManager`, restaura o cache do pnpm e executa, nesta ordem: frozen install, lint, typecheck, Jest serial, build e auditorias de produção e desenvolvimento com limite high. O passo de typecheck gera os tipos de rota antes do `tsc`: `next-env.d.ts` não é versionado e, em uma árvore recém-clonada, é ele que declara os módulos de imagem importados pelo código.
|
||||||
|
|
||||||
|
O Nixpacks usa `nodejs_22`, `pnpm-9_x`, `pnpm install --frozen-lockfile`, `pnpm run build` e `pnpm run start`. Não há etapa de deploy nem secret de CI neste repositório.
|
||||||
|
|
||||||
|
## Validação local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack [email protected] install --frozen-lockfile
|
||||||
|
corepack [email protected] lint
|
||||||
|
corepack [email protected] typecheck
|
||||||
|
corepack [email protected] test -- --runInBand
|
||||||
|
corepack [email protected] build
|
||||||
|
corepack [email protected] audit --prod --audit-level=high
|
||||||
|
corepack [email protected] audit --dev --audit-level=high
|
||||||
|
```
|
||||||
@@ -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 |
@@ -0,0 +1,52 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } 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}
|
||||||
|
/>,
|
||||||
|
OG_SIZE,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: CasePageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const caseStudy = caseStudies.find((item) => item.id === slug);
|
||||||
|
|
||||||
|
if (!caseStudy) return { title: "Case não encontrado" };
|
||||||
|
|
||||||
|
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||||
|
return {
|
||||||
|
title: `${caseStudy.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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CasePage({ params }: CasePageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const caseStudy = caseStudies.find((item) => item.id === slug);
|
||||||
|
if (!caseStudy) notFound();
|
||||||
|
|
||||||
|
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
|
||||||
|
const relatedServices = getRelatedServices(caseStudy.id);
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
buildBreadcrumbList(pageUrl, [
|
||||||
|
["Início", siteUrl],
|
||||||
|
["Cases", `${siteUrl}/cases`],
|
||||||
|
[caseStudy.title, pageUrl],
|
||||||
|
] as const),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<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.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 ? (
|
||||||
|
<a
|
||||||
|
className="ds-link ds-link-accent"
|
||||||
|
href={caseStudy.demoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Abrir demonstração
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{caseStudy.githubUrl ? (
|
||||||
|
<a
|
||||||
|
className="ds-link ds-link-muted"
|
||||||
|
href={caseStudy.githubUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Ver código
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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-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={intentCtas.consulting.href}
|
||||||
|
>
|
||||||
|
{intentCtas.consulting.label}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
+78
-36
@@ -1,21 +1,27 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
|
|
||||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||||
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
||||||
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
|
title: pageTitle,
|
||||||
description:
|
description: pageDescription,
|
||||||
"Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos. Portfolio tecnico com foco em clareza, performance e execucao.",
|
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${siteUrl}/cases`,
|
canonical: pageUrl,
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
|
locale: "pt_BR",
|
||||||
description:
|
title: pageTitle,
|
||||||
"Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos.",
|
description: pageDescription,
|
||||||
url: `${siteUrl}/cases`,
|
url: pageUrl,
|
||||||
siteName,
|
siteName,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
@@ -27,11 +33,10 @@ export default function CasesPage() {
|
|||||||
"@graph": [
|
"@graph": [
|
||||||
{
|
{
|
||||||
"@type": "CollectionPage",
|
"@type": "CollectionPage",
|
||||||
"@id": `${siteUrl}/cases#page`,
|
"@id": `${pageUrl}#page`,
|
||||||
name: "Cases de engenharia de software",
|
name: pageTitle,
|
||||||
url: `${siteUrl}/cases`,
|
url: pageUrl,
|
||||||
description:
|
description: pageDescription,
|
||||||
"Colecao de cases em Next.js, IoT, integracoes e software para operacoes criticas.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "ItemList",
|
"@type": "ItemList",
|
||||||
@@ -39,30 +44,49 @@ export default function CasesPage() {
|
|||||||
"@type": "ListItem",
|
"@type": "ListItem",
|
||||||
position: index + 1,
|
position: index + 1,
|
||||||
name: caseStudy.title,
|
name: caseStudy.title,
|
||||||
url: caseStudy.demoUrl ?? caseStudy.githubUrl ?? `${siteUrl}/cases`,
|
url: `${pageUrl}/${caseStudy.id}`,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
buildBreadcrumbList(pageUrl, [
|
||||||
|
["Início", siteUrl],
|
||||||
|
["Cases", pageUrl],
|
||||||
|
] as const),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<script
|
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
|
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
|
||||||
<HeroBackground />
|
<HeroBackground />
|
||||||
<div className="ds-section-shell relative z-10">
|
<div className="ds-section-shell relative z-10">
|
||||||
<div className="max-w-[860px]">
|
<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>
|
<p className="ds-eyebrow">Selected work</p>
|
||||||
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
|
<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>
|
</h1>
|
||||||
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
<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,
|
{pageDescription}
|
||||||
contexto tecnico e tipo de problema resolvido.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,9 +94,9 @@ export default function CasesPage() {
|
|||||||
|
|
||||||
<section className="ds-section">
|
<section className="ds-section">
|
||||||
<div className="ds-section-shell">
|
<div className="ds-section-shell">
|
||||||
<div className="ds-grid-2">
|
<div className="ds-grid-2 items-start">
|
||||||
{caseStudies.map((caseStudy) => (
|
{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>
|
<div className="ds-kicker">{caseStudy.title}</div>
|
||||||
<h2 className="ds-card-title">{caseStudy.subtitle}</h2>
|
<h2 className="ds-card-title">{caseStudy.subtitle}</h2>
|
||||||
<p className="ds-copy">{caseStudy.summary}</p>
|
<p className="ds-copy">{caseStudy.summary}</p>
|
||||||
@@ -81,33 +105,51 @@ export default function CasesPage() {
|
|||||||
</p>
|
</p>
|
||||||
<ul className="ds-tags mt-sp-6">
|
<ul className="ds-tags mt-sp-6">
|
||||||
{caseStudy.stack.map((item) => (
|
{caseStudy.stack.map((item) => (
|
||||||
<MagneticButton key={item} as="li" className="ds-tag" strength={0.15}>
|
<li key={item} className="ds-tag">
|
||||||
{item}
|
{item}
|
||||||
</MagneticButton>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||||
|
<Link
|
||||||
|
href={`/cases/${caseStudy.id}`}
|
||||||
|
className="ds-link ds-link-accent"
|
||||||
|
>
|
||||||
|
Ler case
|
||||||
|
</Link>
|
||||||
{caseStudy.demoUrl ? (
|
{caseStudy.demoUrl ? (
|
||||||
<MagneticButton as="a" href={caseStudy.demoUrl} className="ds-link ds-link-accent" target="_blank" rel="noreferrer" strength={0.25}>
|
<MagneticButton
|
||||||
|
as="a"
|
||||||
|
href={caseStudy.demoUrl}
|
||||||
|
className="ds-link ds-link-muted"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
strength={0.25}
|
||||||
|
>
|
||||||
Abrir projeto
|
Abrir projeto
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
) : null}
|
) : null}
|
||||||
{caseStudy.githubUrl ? (
|
|
||||||
<MagneticButton as="a" href={caseStudy.githubUrl} className="ds-link ds-link-muted" target="_blank" rel="noreferrer" strength={0.2}>
|
|
||||||
Ver codigo
|
|
||||||
</MagneticButton>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
|
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
|
||||||
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-accent" strength={0.25}>
|
<MagneticButton
|
||||||
Ver servicos
|
as="a"
|
||||||
|
href={intentCtas.project.href}
|
||||||
|
className="ds-link ds-link-accent"
|
||||||
|
strength={0.25}
|
||||||
|
>
|
||||||
|
{intentCtas.project.label}
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
<MagneticButton as="a" href="/#contact" className="ds-link ds-link-muted" strength={0.2}>
|
<MagneticButton
|
||||||
Ir para contato
|
as="a"
|
||||||
|
href={intentCtas.consulting.href}
|
||||||
|
className="ds-link ds-link-muted"
|
||||||
|
strength={0.2}
|
||||||
|
>
|
||||||
|
{intentCtas.consulting.label}
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+41
-34
@@ -1,41 +1,48 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { motion, type Variants } from "motion/react";
|
||||||
import profilePhoto from "@/public/edev.jpeg";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { Section } from "./Section";
|
import profilePhoto from "@/public/edev.jpg";
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
||||||
|
import { Section } from "./Section";
|
||||||
|
|
||||||
const STORY_DATA = [
|
const STORY_DATA = [
|
||||||
{
|
{
|
||||||
label: "Hardware + software",
|
label: "Hardware + software",
|
||||||
title: "Do sinal fisico ao produto digital",
|
title: "Do sinal físico ao produto digital",
|
||||||
content:
|
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",
|
label: "Decisões técnicas",
|
||||||
title: "Entrega clara mesmo sob restricao",
|
title: "Entrega clara mesmo sob restrição",
|
||||||
content:
|
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: "Operação",
|
||||||
|
title: "Do código ao sistema no ar",
|
||||||
|
content:
|
||||||
|
"Publicação, diagnóstico de falhas, documentação e manutenção entram no planejamento, junto com as funcionalidades.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const PROFILE_POINTS = [
|
const PROFILE_POINTS = [
|
||||||
{
|
{
|
||||||
title: "Arquitetura pragmatica",
|
title: "Arquitetura pragmática",
|
||||||
description:
|
description:
|
||||||
"Escolhas guiadas por contexto, operabilidade e impacto real no produto.",
|
"Escolhas guiadas por contexto, operabilidade e impacto real no produto.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Integracao profunda",
|
title: "Integração profunda",
|
||||||
description:
|
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:
|
description:
|
||||||
"Responsabilidade que cobre discovery, implementacao, qualidade e operacao.",
|
"Contato direto com quem define a arquitetura e desenvolve o sistema.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -79,19 +86,19 @@ export default function About() {
|
|||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
id="about"
|
id="about"
|
||||||
eyebrow="Operating context"
|
eyebrow="Quem conduz o projeto"
|
||||||
title="Engenharia de software, arquitetura e integracoes complexas"
|
title="Evandro Fonseca Junior. Engenharia com contato direto."
|
||||||
description="Atuo na intersecao entre arquitetura de software, integracoes de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
|
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
|
disableChildrenAnimation
|
||||||
aside={
|
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" />
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="ds-grid-2"
|
className="ds-grid-2 items-start"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true, amount: 0.2 }}
|
viewport={{ once: true, amount: 0.2 }}
|
||||||
@@ -113,7 +120,9 @@ export default function About() {
|
|||||||
<div className="ds-kicker">{step.label}</div>
|
<div className="ds-kicker">{step.label}</div>
|
||||||
<DrawLine delay={0.03} />
|
<DrawLine delay={0.03} />
|
||||||
<h3 className="ds-card-title">{step.title}</h3>
|
<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.article>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -127,20 +136,20 @@ export default function About() {
|
|||||||
>
|
>
|
||||||
<LazyRevealImage
|
<LazyRevealImage
|
||||||
src={profilePhoto}
|
src={profilePhoto}
|
||||||
alt="Retrato de Evandro"
|
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC"
|
||||||
className="mb-sp-6 h-[220px] w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
|
className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
|
||||||
imageClassName="object-cover"
|
imageClassName="object-contain"
|
||||||
/>
|
/>
|
||||||
<div className="ds-kicker">Technical profile</div>
|
<div className="ds-kicker">Da arquitetura à implementação</div>
|
||||||
<DrawLine delay={0.05} />
|
<DrawLine delay={0.05} />
|
||||||
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
|
<h3 className="ds-card-title">Uma visão do sistema inteiro</h3>
|
||||||
<p className="ds-copy">
|
<p className="ds-copy" data-reel="top">
|
||||||
Sou engenheiro de software full-stack com foco em integrar camadas
|
Meu trabalho conecta interface, serviços, dados e, quando
|
||||||
que normalmente ficam separadas: hardware, servicos, operacao e
|
necessário, equipamentos. Entender essas partes em conjunto ajuda a
|
||||||
interface. Meu trabalho e dar forma clara ao que costuma nascer
|
definir o que construir, o que integrar e o que pode ser
|
||||||
fragmentado.
|
aproveitado.
|
||||||
</p>
|
</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) => (
|
{PROFILE_POINTS.map((point) => (
|
||||||
<div
|
<div
|
||||||
key={point.title}
|
key={point.title}
|
||||||
@@ -158,5 +167,3 @@ export default function About() {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
+163
-127
@@ -1,127 +1,151 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
|
||||||
import { useState } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { useEffect, useState } from "react";
|
||||||
import { Section } from "./Section";
|
import { trackWhatsAppOpen } from "@/lib/analytics";
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
|
||||||
const WHATSAPP_NUMBER = "5547992742365";
|
// 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() {
|
export default function Contact() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||||
|
useEffect(() => {
|
||||||
|
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fieldClassName =
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
subject: "full-stack",
|
subject: "Sistemas web sob medida",
|
||||||
message: "",
|
message: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!e.currentTarget.reportValidity()) return;
|
||||||
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 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(
|
||||||
const encodedMessage = encodeURIComponent(messageTemplate);
|
buildWhatsAppUrl(messageTemplate),
|
||||||
const whatsappUrl = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodedMessage}`;
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
|
);
|
||||||
};
|
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
|
||||||
|
|
||||||
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] },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<section
|
||||||
id="contact"
|
id="contact"
|
||||||
eyebrow="Contact"
|
aria-labelledby="contact-title"
|
||||||
title="Consultoria tecnica para software, arquitetura e integracoes criticas"
|
className="ds-section ds-section-dark"
|
||||||
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
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<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">
|
||||||
className="ds-grid-2"
|
<div className="min-w-0">
|
||||||
initial="hidden"
|
<p className="ds-eyebrow">Contato</p>
|
||||||
whileInView="visible"
|
<h2
|
||||||
viewport={{ once: true, amount: 0.2 }}
|
id="contact-title"
|
||||||
variants={gridVariants}
|
className="ds-section-title mt-sp-4 max-w-[19ch] text-balance"
|
||||||
>
|
>
|
||||||
<motion.div
|
Vamos conversar sobre o que sua empresa precisa.
|
||||||
className="ds-card"
|
</h2>
|
||||||
variants={cardVariants}
|
<p className="mt-sp-5 max-w-[44ch] font-display text-base leading-8 text-white/70">
|
||||||
whileHover={
|
Conte o que você quer resolver. Pode ser um projeto novo, uma
|
||||||
shouldReduceMotion
|
melhoria ou uma integração. Não precisa chegar com a solução
|
||||||
? undefined
|
definida.
|
||||||
: {
|
</p>
|
||||||
y: -4,
|
<div className="mt-sp-8 border-l-2 border-[var(--slash)] pl-sp-5">
|
||||||
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
|
<h3 className="font-display text-lg font-semibold text-white">
|
||||||
}
|
Você fala direto com quem desenvolve.
|
||||||
}
|
</h3>
|
||||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
|
<p className="mt-sp-2 max-w-[44ch] font-display text-sm leading-7 text-white/65">
|
||||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
Sou Evandro. A primeira conversa é para entender sua necessidade;
|
||||||
>
|
escopo, prazo e proposta vêm depois.
|
||||||
<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>
|
</p>
|
||||||
<div className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]">
|
|
||||||
<div>WhatsApp: +55 47 99274-2365</div>
|
|
||||||
<div>E-mail: evandrofonsecajunior@gmail.com</div>
|
|
||||||
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
|
|
||||||
</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)]">
|
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
|
||||||
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
|
<WhatsAppLink
|
||||||
conversa tecnica objetiva.
|
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>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="ds-card space-y-sp-5"
|
aria-labelledby="contact-form-title"
|
||||||
variants={cardVariants}
|
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
|
||||||
whileHover={
|
|
||||||
shouldReduceMotion
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
y: -4,
|
|
||||||
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
|
||||||
>
|
>
|
||||||
|
<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>
|
<div>
|
||||||
<label
|
<h3
|
||||||
htmlFor="contact-name"
|
id="contact-form-title"
|
||||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
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
|
Nome completo
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -130,6 +154,7 @@ export default function Contact() {
|
|||||||
required
|
required
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
|
placeholder="Como posso te chamar?"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, name: e.target.value })
|
setFormData({ ...formData, name: e.target.value })
|
||||||
@@ -138,10 +163,7 @@ export default function Contact() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="contact-email" className={labelClassName}>
|
||||||
htmlFor="contact-email"
|
|
||||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
|
||||||
>
|
|
||||||
E-mail
|
E-mail
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -150,6 +172,7 @@ export default function Contact() {
|
|||||||
required
|
required
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
|
placeholder="[email protected]"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, email: e.target.value })
|
setFormData({ ...formData, email: e.target.value })
|
||||||
@@ -157,11 +180,9 @@ export default function Contact() {
|
|||||||
className={fieldClassName}
|
className={fieldClassName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="contact-subject" className={labelClassName}>
|
||||||
htmlFor="contact-subject"
|
|
||||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
|
||||||
>
|
|
||||||
Tipo de projeto
|
Tipo de projeto
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -171,46 +192,61 @@ export default function Contact() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, subject: e.target.value })
|
setFormData({ ...formData, subject: e.target.value })
|
||||||
}
|
}
|
||||||
className={fieldClassName}
|
className={`${fieldClassName} [color-scheme:dark]`}
|
||||||
style={{ color: "#fff", backgroundColor: "#1a1a1a" }}
|
|
||||||
>
|
>
|
||||||
<option value="full-stack">Desenvolvimento Full-Stack</option>
|
<option value="Sistemas web sob medida">
|
||||||
<option value="cloud">Cloud & Infrastructure</option>
|
Sistemas web sob medida
|
||||||
<option value="iot">Internet of Things (IoT)</option>
|
</option>
|
||||||
<option value="consultancy">Consultoria Tecnica Senior</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="contact-message" className={labelClassName}>
|
||||||
htmlFor="contact-message"
|
|
||||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
|
||||||
>
|
|
||||||
Contexto do projeto
|
Contexto do projeto
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="contact-message"
|
id="contact-message"
|
||||||
name="message"
|
name="message"
|
||||||
required
|
required
|
||||||
rows={6}
|
rows={4}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
|
||||||
|
aria-describedby="contact-privacy-note"
|
||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, message: e.target.value })
|
setFormData({ ...formData, message: e.target.value })
|
||||||
}
|
}
|
||||||
className={`${fieldClassName} resize-none`}
|
className={`${fieldClassName} min-h-32 resize-y`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
<MagneticButton
|
|
||||||
as="button"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
className="ds-link ds-link-accent min-h-12 border-0"
|
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"
|
||||||
strength={0.2}
|
|
||||||
>
|
>
|
||||||
Enviar briefing pelo WhatsApp
|
<WhatsAppMark className="h-6 w-6 shrink-0" />
|
||||||
</MagneticButton>
|
Continuar no WhatsApp
|
||||||
</motion.form>
|
<ArrowUpRight className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||||
</motion.div>
|
</button>
|
||||||
</Section>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,913 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
|
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
|
|
||||||
interface ContactFormProps {
|
|
||||||
formType: "simple" | "advanced";
|
|
||||||
setFormType: (type: "simple" | "advanced") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContactForm({ formType, setFormType }: ContactFormProps) {
|
|
||||||
const { data } = usePortfolioData();
|
|
||||||
const [contactMethod, setContactMethod] = useState<"whatsapp" | "email">(
|
|
||||||
"whatsapp",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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 = () => {
|
|
||||||
const message = encodeURIComponent(
|
|
||||||
formType === "advanced"
|
|
||||||
? generateAdvancedMessage()
|
|
||||||
: formData.customMessage || getGreetingMessageWithUserName(),
|
|
||||||
);
|
|
||||||
const phone = data.contact.whatsapp.replace(/\D/g, ""); // Remove caracteres não numéricos
|
|
||||||
return `https://wa.me/${phone}?text=${message}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateEmailUrl = () => {
|
|
||||||
const subject = encodeURIComponent("Proposta de Projeto - Portfólio");
|
|
||||||
const body = encodeURIComponent(
|
|
||||||
formType === "advanced"
|
|
||||||
? generateAdvancedMessage()
|
|
||||||
: `Nome: ${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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { Mail, MessageCircle } from "lucide-react";
|
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
|
||||||
|
|
||||||
interface ContactInfoProps {
|
|
||||||
onWhatsAppClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
|
|
||||||
const { data } = usePortfolioData();
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null; // Ou um componente de carregamento/erro
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -30 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold mb-6 text-blue-400">
|
|
||||||
Entre em Contato
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<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">{data.contact.whatsapp}</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,35 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { motion, type Variants } from "motion/react";
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||||
import { Section } from "./Section";
|
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
import { Section } from "./Section";
|
||||||
import { SectionLoading } from "./SectionLoading";
|
|
||||||
|
|
||||||
export default function Experience() {
|
export default function Experience({
|
||||||
const { data, loading, error } = usePortfolioData();
|
experiences = [],
|
||||||
|
}: {
|
||||||
|
experiences?: PortfolioExperience[];
|
||||||
|
}) {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const sortedExperiences = [...experiences]
|
||||||
const sortedExperiences = useMemo(() => {
|
.filter((experience) => experience.featured)
|
||||||
if (!data?.experiences) return [];
|
|
||||||
return [...data.experiences]
|
|
||||||
.filter((exp) => exp.featured)
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
|
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||||
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
|
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||||
return yearB - yearA;
|
return yearB - yearA;
|
||||||
});
|
});
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <SectionLoading message="Loading selected experience" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return <div className="ds-error">Error loading selected experience</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const listVariants: Variants = {
|
const listVariants: Variants = {
|
||||||
hidden: {},
|
hidden: {},
|
||||||
@@ -59,8 +48,8 @@ export default function Experience() {
|
|||||||
<Section
|
<Section
|
||||||
id="experience"
|
id="experience"
|
||||||
eyebrow="Selected experience"
|
eyebrow="Selected experience"
|
||||||
title="Experiencia em engenharia de software e sistemas criticos"
|
title="Experiência em engenharia de software e sistemas críticos"
|
||||||
description="Projetos e contextos em que combinei arquitetura de software, integracoes complexas e entrega com responsabilidade operacional."
|
description="Projetos e contextos em que combinei arquitetura de software, integrações complexas e entrega com responsabilidade operacional."
|
||||||
disableChildrenAnimation
|
disableChildrenAnimation
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -90,7 +79,9 @@ export default function Experience() {
|
|||||||
<div className="max-w-[720px]">
|
<div className="max-w-[720px]">
|
||||||
<div className="ds-kicker">{exp.period}</div>
|
<div className="ds-kicker">{exp.period}</div>
|
||||||
<DrawLine delay={0.04} />
|
<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)]">
|
<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}
|
{exp.company} • {exp.type}
|
||||||
</p>
|
</p>
|
||||||
@@ -98,7 +89,9 @@ export default function Experience() {
|
|||||||
<div className="ds-metric">{exp.location}</div>
|
<div className="ds-metric">{exp.location}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="ds-copy">{exp.description}</p>
|
<p className="ds-copy" data-reel="right">
|
||||||
|
{exp.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
<ul className="ds-list">
|
<ul className="ds-list">
|
||||||
{exp.achievements?.map((achievement) => (
|
{exp.achievements?.map((achievement) => (
|
||||||
@@ -106,11 +99,11 @@ export default function Experience() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</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) => (
|
{exp.technologies?.slice(0, 6).map((tech) => (
|
||||||
<MagneticButton key={tech} as="li" className="ds-tag" strength={0.15}>
|
<li key={tech} className="ds-tag">
|
||||||
{tech}
|
{tech}
|
||||||
</MagneticButton>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
@@ -119,5 +112,3 @@ export default function Experience() {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+49
-297
@@ -1,323 +1,75 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
|
|
||||||
describe("Footer Component - Contact Information", () => {
|
describe("Footer contact and navigation", () => {
|
||||||
it("should render contact section heading", () => {
|
it("offers a direct mailto link without repeating the contact call to action", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
const contactHeading = screen.getByText("Contato direto");
|
// o convite principal fica na secao Contato, logo acima do rodape
|
||||||
expect(contactHeading).toBeInTheDocument();
|
expect(
|
||||||
expect(contactHeading.tagName).toBe("H3");
|
screen.queryByRole("link", { name: "Conversar sobre meu projeto" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const emailLink = screen.getByRole("link", {
|
||||||
|
name: "[email protected]",
|
||||||
|
});
|
||||||
|
expect(emailLink).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"mailto:[email protected]",
|
||||||
|
);
|
||||||
|
expect(emailLink).not.toHaveAttribute("target", "_blank");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render email address", () => {
|
it("renders quick links as same-tab navigation", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
const emailLink = screen.getByText("[email protected]");
|
const servicesLink = screen.getByRole("link", { name: "Hub de serviços" });
|
||||||
expect(emailLink).toBeInTheDocument();
|
expect(servicesLink).toHaveAttribute("href", "/servicos");
|
||||||
expect(emailLink.tagName).toBe("A");
|
expect(servicesLink).not.toHaveAttribute("target", "_blank");
|
||||||
|
expect(screen.getByRole("link", { name: "Privacidade" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/privacidade",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have mailto link for email", () => {
|
it("keeps the résumé page and PDF download as the final secondary links", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
const emailLink = screen.getByText("[email protected]");
|
const résuméLink = screen.getByRole("link", { name: "Currículo" });
|
||||||
expect(emailLink).toHaveAttribute("href", "mailto:[email protected]");
|
const downloadLink = screen.getByRole("link", {
|
||||||
|
name: "Baixar currículo (PDF)",
|
||||||
});
|
});
|
||||||
|
expect(résuméLink).toHaveAttribute("href", "/curriculo");
|
||||||
it("should apply correct styling classes to email link", () => {
|
expect(downloadLink).toHaveAttribute("href", "/Profile.pdf");
|
||||||
render(<Footer />);
|
expect(downloadLink).toHaveAttribute(
|
||||||
|
"download",
|
||||||
const emailLink = screen.getByText("evandro@dev.br");
|
"Evandro_Fonseca_Junior_CV.pdf",
|
||||||
const classes = emailLink.className;
|
);
|
||||||
|
expect(screen.getAllByRole("link").slice(-2)).toEqual([
|
||||||
expect(classes).toContain("text-[12px]");
|
résuméLink,
|
||||||
expect(classes).toContain("hover:text-[var(--slash)]");
|
downloadLink,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Footer Component - Social Links", () => {
|
describe("Footer social links", () => {
|
||||||
it("should render social links section heading", () => {
|
it("opens social links securely in a new tab", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
const connectHeading = screen.getByText("Redes profissionais");
|
const socialLinks = screen.getAllByRole("link", { name: /profile/i });
|
||||||
expect(connectHeading).toBeInTheDocument();
|
|
||||||
expect(connectHeading.tagName).toBe("H3");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render GitHub social link", () => {
|
for (const link of socialLinks) {
|
||||||
render(<Footer />);
|
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(screen.getByLabelText("Visit GitHub profile")).toHaveAttribute(
|
||||||
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(
|
|
||||||
"href",
|
"href",
|
||||||
"https://github.com/evandrodevbr",
|
"https://github.com/evandrodevbr",
|
||||||
);
|
);
|
||||||
});
|
expect(
|
||||||
|
screen.getByLabelText("Visit LinkedIn profile").getAttribute("href"),
|
||||||
it("should have correct href for LinkedIn link", () => {
|
).toContain("https://www.linkedin.com/in/evandrodevbr/");
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+40
-20
@@ -16,27 +16,30 @@ const socialLinks: SocialLink[] = [
|
|||||||
name: "LinkedIn",
|
name: "LinkedIn",
|
||||||
href: "https://www.linkedin.com/in/evandrodevbr/?skipRedirect=true",
|
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",
|
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",
|
name: "Instagram",
|
||||||
href: "https://www.instagram.com/evandrodevbr/",
|
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",
|
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 = [
|
const quickLinks = [
|
||||||
{ label: "Hub de servicos", href: "/servicos" },
|
{ label: "Hub de serviços", href: "/servicos" },
|
||||||
{ label: "Cases", href: "/cases" },
|
{ label: "Cases", href: "/cases" },
|
||||||
|
{ label: "Privacidade", href: "/privacidade" },
|
||||||
{ label: "Consultoria Next.js", href: "/servicos/consultoria-nextjs" },
|
{ label: "Consultoria Next.js", href: "/servicos/consultoria-nextjs" },
|
||||||
{
|
{
|
||||||
label: "Integracao hardware + software",
|
label: "Integração hardware + software",
|
||||||
href: "/servicos/integracao-hardware-software-iot",
|
href: "/servicos/integracao-hardware-software-iot",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
import { CV_DOWNLOAD_NAME, CV_URL } from "@/lib/site-navigation";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -49,44 +52,43 @@ export default function Footer() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="ds-kicker">Contato</div>
|
<div className="ds-kicker">Contato</div>
|
||||||
<span className="ds-draw-line" />
|
<span className="ds-draw-line" />
|
||||||
<h3 className="ds-card-title">
|
<div className="mt-sp-5">
|
||||||
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">
|
|
||||||
<p className="mb-2 text-[12px] tracking-[0.02em] text-[var(--meta)]">
|
<p className="mb-2 text-[12px] tracking-[0.02em] text-[var(--meta)]">
|
||||||
E-mail direto
|
E-mail direto
|
||||||
</p>
|
</p>
|
||||||
<MagneticButton
|
<MagneticButton
|
||||||
as="a"
|
as="a"
|
||||||
href="mailto:evandro@dev.br"
|
href="mailto:evandro[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}
|
strength={0.2}
|
||||||
>
|
>
|
||||||
evandro@dev.br
|
evandrofonsecajunior@gmail.com
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-sp-8">
|
<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">
|
<div className="ds-metric">
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-slash" />
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-slash" />
|
||||||
Garuva, SC | Remote worldwide
|
Garuva, SC | Remote worldwide
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-sp-4 text-[12px] text-[var(--meta)]">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ds-card flex flex-col justify-between">
|
<div className="ds-card flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="ds-kicker">Presenca digital</div>
|
<div className="ds-kicker">Presença digital</div>
|
||||||
<span className="ds-draw-line" />
|
<span className="ds-draw-line" />
|
||||||
<h3 className="ds-card-title">Canais profissionais</h3>
|
<h3 className="ds-card-title">Canais profissionais</h3>
|
||||||
|
|
||||||
@@ -141,10 +143,28 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</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>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,33 +1,326 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
type AnalyticsConsent,
|
||||||
|
CONSENT_STORAGE_KEY,
|
||||||
|
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() {
|
const deniedConsent = {
|
||||||
if (process.env.NODE_ENV !== "production") {
|
analytics_storage: "denied",
|
||||||
return null;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Script src="/sys/libs/vendor-utils.js" strategy="lazyOnload" />
|
{consent === "granted" && (
|
||||||
<Script id="google-analytics" strategy="lazyOnload">
|
<Script
|
||||||
{`
|
id="google-analytics-script"
|
||||||
window.dataLayer = window.dataLayer || [];
|
data-testid="google-analytics-script"
|
||||||
function gtag(){dataLayer.push(arguments);}
|
src={GA_SCRIPT_SRC}
|
||||||
gtag('js', new Date());
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
gtag('config', '${GA_MEASUREMENT_ID}', {
|
)}
|
||||||
transport_url: window.location.origin + '/sys/api/vitals',
|
<button
|
||||||
cookie_flags: 'SameSite=None;Secure',
|
type="button"
|
||||||
allow_ad_personalization_signals: false,
|
ref={preferencesButtonRef}
|
||||||
anonymize_ip: true,
|
aria-expanded={preferencesOpen}
|
||||||
page_location: window.location.href,
|
aria-controls="analytics-preferences"
|
||||||
page_title: document.title
|
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)}
|
||||||
`}
|
>
|
||||||
</Script>
|
<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 />;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
+85
-53
@@ -1,68 +1,94 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||||
import { Logo } from "./Logo";
|
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
const navItems = [
|
const MENU_ID = "site-navigation-menu";
|
||||||
{ 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
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 (
|
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">
|
<header
|
||||||
<div className="ds-section-shell flex h-full items-center justify-between">
|
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 ${
|
||||||
<a href="#home" className="flex items-center gap-3 no-underline">
|
scrolled
|
||||||
<Logo variant="mark" size="sm" theme="dark" className="shrink-0" />
|
? "border-white/12 bg-[var(--ink)]/95 backdrop-blur-md"
|
||||||
<span className="font-display text-sm font-extrabold tracking-tight text-white">
|
: "border-transparent bg-[var(--ink)]"
|
||||||
evandro<span className="text-[var(--slash)]">.dev.br</span>
|
}`}
|
||||||
</span>
|
>
|
||||||
|
<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>
|
</a>
|
||||||
|
|
||||||
<nav className="hidden items-center lg:flex">
|
<nav
|
||||||
<ul className="flex list-none items-center gap-sp-2">
|
aria-label="Navegação principal"
|
||||||
{navItems.map((item) => (
|
className="hidden items-center xl:flex"
|
||||||
|
>
|
||||||
|
<ul className="flex list-none items-center gap-sp-4">
|
||||||
|
{NAVIGATION_ITEMS.map((item) => (
|
||||||
<li key={item.href}>
|
<li key={item.href}>
|
||||||
<MagneticButton
|
<a
|
||||||
as="a"
|
|
||||||
href={item.href}
|
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"
|
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"
|
||||||
strength={0.15}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</MagneticButton>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-sp-4">
|
<div className="flex shrink-0 items-center">
|
||||||
<span className="ds-metric hidden rounded-full border border-white/10 px-[10px] py-1 text-white/40 lg:inline-flex">
|
<div className="hidden xl:block">
|
||||||
v1.0 / 2026
|
<a
|
||||||
</span>
|
href="/#contact"
|
||||||
<MagneticButton
|
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)]"
|
||||||
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
|
Falar sobre meu projeto
|
||||||
</MagneticButton>
|
</a>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
|
ref={menuButtonRef}
|
||||||
type="button"
|
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)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
aria-label="Menu"
|
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
|
||||||
|
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
|
||||||
aria-expanded={mobileMenuOpen}
|
aria-expanded={mobileMenuOpen}
|
||||||
|
onKeyDown={handleMenuKeyDown}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[6px] rotate-45" : ""}`}
|
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[6px] rotate-45" : ""}`}
|
||||||
@@ -77,20 +103,25 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{mobileMenuOpen ? (
|
||||||
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="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">
|
<div className="ds-section-shell flex flex-col py-sp-6">
|
||||||
<nav className="mb-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">
|
<ul className="flex list-none flex-col gap-sp-2">
|
||||||
{navItems.map((item) => (
|
{NAVIGATION_ITEMS.map((item) => (
|
||||||
<li key={item.href}>
|
<li key={item.href}>
|
||||||
<MagneticButton
|
<MagneticButton
|
||||||
as="a"
|
as="a"
|
||||||
|
strength={0}
|
||||||
|
wrapperClassName="w-full"
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="ds-link block rounded-sm py-sp-3 text-white/85 transition-all hover:pl-sp-2 hover:text-white"
|
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={() => setMobileMenuOpen(false)}
|
onClick={closeMobileMenu}
|
||||||
strength={0.15}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
@@ -99,18 +130,19 @@ export function Header() {
|
|||||||
<li>
|
<li>
|
||||||
<MagneticButton
|
<MagneticButton
|
||||||
as="a"
|
as="a"
|
||||||
href="/Profile.pdf"
|
strength={0}
|
||||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
href="/#contact"
|
||||||
className="ds-link ds-link-accent mt-sp-2 inline-flex rounded-sm font-semibold"
|
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)]"
|
||||||
strength={0.15}
|
onClick={closeMobileMenu}
|
||||||
>
|
>
|
||||||
Download CV
|
Falar sobre meu projeto
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,101 +1,52 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { act } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import { useReducedMotion } from "motion/react";
|
||||||
|
import { hydrateRoot, type Root } from "react-dom/client";
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
import Hero from "./Hero";
|
import Hero from "./Hero";
|
||||||
|
|
||||||
let mockReducedMotion = false;
|
jest.mock("motion/react", () => ({
|
||||||
|
...jest.requireActual("motion/react"),
|
||||||
|
useReducedMotion: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock("framer-motion", () => {
|
|
||||||
const React = require("react");
|
|
||||||
|
|
||||||
const motion = new Proxy(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
get: (_, tag: string) => {
|
|
||||||
const Component = ({
|
|
||||||
children,
|
|
||||||
initial,
|
|
||||||
animate,
|
|
||||||
whileHover,
|
|
||||||
whileTap,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
children?: ReactNode;
|
|
||||||
initial?: unknown;
|
|
||||||
animate?: unknown;
|
|
||||||
whileHover?: unknown;
|
|
||||||
whileTap?: unknown;
|
|
||||||
} & Record<string, unknown>) => {
|
|
||||||
const dataProps = {
|
|
||||||
...(initial !== undefined
|
|
||||||
? { "data-initial": JSON.stringify(initial) }
|
|
||||||
: {}),
|
|
||||||
...(animate !== undefined
|
|
||||||
? { "data-animate": JSON.stringify(animate) }
|
|
||||||
: {}),
|
|
||||||
...(whileHover !== undefined
|
|
||||||
? { "data-while-hover": JSON.stringify(whileHover) }
|
|
||||||
: {}),
|
|
||||||
...(whileTap !== undefined
|
|
||||||
? { "data-while-tap": JSON.stringify(whileTap) }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return React.createElement(tag, { ...props, ...dataProps }, children);
|
|
||||||
};
|
|
||||||
Component.displayName = `motion.${tag}`;
|
|
||||||
return Component;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
motion,
|
|
||||||
useInView: () => true,
|
|
||||||
useReducedMotion: () => mockReducedMotion,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Hero reduced motion", () => {
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mockReducedMotion = false;
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes hover/tap scale animations when reduced motion is enabled", () => {
|
it.each([false, true])(
|
||||||
mockReducedMotion = 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);
|
||||||
|
|
||||||
const { container } = render(<Hero />);
|
preference.mockReturnValue(reducedMotion);
|
||||||
|
const consoleErrors = jest.spyOn(console, "error").mockImplementation();
|
||||||
const primaryAction = screen.getByText(/Iniciar projeto/i).closest("a");
|
const recoverableErrors: unknown[] = [];
|
||||||
const secondaryAction = screen.getByText(/Ver projetos/i).closest("a");
|
let root: Root | undefined;
|
||||||
|
try {
|
||||||
expect(primaryAction).not.toHaveAttribute("data-while-hover");
|
await act(async () => {
|
||||||
expect(primaryAction).not.toHaveAttribute("data-while-tap");
|
root = hydrateRoot(container, <Hero />, {
|
||||||
expect(secondaryAction).not.toHaveAttribute("data-while-hover");
|
onRecoverableError: (error) => recoverableErrors.push(error),
|
||||||
expect(secondaryAction).not.toHaveAttribute("data-while-tap");
|
});
|
||||||
|
});
|
||||||
const rootMotionBlock = container.querySelector(
|
expect(recoverableErrors).toEqual([]);
|
||||||
"div[data-initial][data-animate]",
|
expect(consoleErrors.mock.calls).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root?.unmount());
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
expect(rootMotionBlock).toBeInTheDocument();
|
|
||||||
expect(rootMotionBlock?.getAttribute("data-initial")).toContain(
|
|
||||||
'"opacity":0',
|
|
||||||
);
|
|
||||||
expect(rootMotionBlock?.getAttribute("data-animate")).toContain(
|
|
||||||
'"opacity":1',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps essential hero content visible with reduced motion enabled", () => {
|
|
||||||
mockReducedMotion = true;
|
|
||||||
|
|
||||||
render(<Hero />);
|
|
||||||
|
|
||||||
expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText(/Available for selected builds/i),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import Hero from "./Hero";
|
import Hero from "./Hero";
|
||||||
|
|
||||||
jest.mock("framer-motion", () => {
|
jest.mock("motion/react", () => {
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
|
|
||||||
const motion = new Proxy(
|
const motion = new Proxy(
|
||||||
@@ -12,6 +12,9 @@ jest.mock("framer-motion", () => {
|
|||||||
get: (_, tag: string) => {
|
get: (_, tag: string) => {
|
||||||
const Component = ({
|
const Component = ({
|
||||||
children,
|
children,
|
||||||
|
initial: _initial,
|
||||||
|
animate: _animate,
|
||||||
|
transition: _transition,
|
||||||
...props
|
...props
|
||||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||||
React.createElement(tag, props, children);
|
React.createElement(tag, props, children);
|
||||||
@@ -21,42 +24,61 @@ jest.mock("framer-motion", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return { motion, useReducedMotion: () => false };
|
||||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
motion,
|
|
||||||
useInView: () => true,
|
|
||||||
useReducedMotion: () => false,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Hero", () => {
|
describe("Hero", () => {
|
||||||
it("renders the upgraded hero content and primary actions", () => {
|
beforeEach(() => {
|
||||||
|
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes project conversations to contact without opening an external service", () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument();
|
const contactLink = screen.getByRole("link", {
|
||||||
expect(
|
name: /conversar/i,
|
||||||
screen.getByText(
|
});
|
||||||
/Engenharia de software para produtos conectados, interfaces de alta performance e sistemas criticos/i,
|
expect(contactLink).toHaveAttribute("href", "/#contact");
|
||||||
),
|
expect(contactLink).not.toHaveAttribute("target", "_blank");
|
||||||
).toBeInTheDocument();
|
fireEvent.click(contactLink);
|
||||||
|
expect(window.open).not.toHaveBeenCalled();
|
||||||
expect(screen.getByText(/Iniciar projeto/i)).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: /ver projetos/i })).toHaveAttribute(
|
||||||
expect(screen.getByText(/Ver projetos/i)).toBeInTheDocument();
|
"href",
|
||||||
expect(screen.getByText(/Download CV/i)).toBeInTheDocument();
|
"/cases",
|
||||||
expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument();
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders social links and proof content", () => {
|
it("keeps the headline as one readable sentence despite the per-word animation", () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
expect(screen.getAllByLabelText("GitHub").length).toBeGreaterThan(0);
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||||
expect(screen.getAllByLabelText("LinkedIn").length).toBeGreaterThan(0);
|
"Sistemas sob medida para operações que não cabem em soluções prontas.",
|
||||||
expect(screen.getAllByLabelText("Email").length).toBeGreaterThan(0);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument();
|
it("keeps social destinations with isolated external tabs", () => {
|
||||||
expect(screen.getByText(/Especialidade/i)).toBeInTheDocument();
|
render(<Hero />);
|
||||||
expect(screen.getByText(/Escopo/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Formato/i)).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute(
|
||||||
expect(screen.getByText(/Scroll/i)).toBeInTheDocument();
|
"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");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+140
-375
@@ -1,300 +1,161 @@
|
|||||||
"use client";
|
import Link from "next/link";
|
||||||
|
import { type CSSProperties, Fragment } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import HeroField from "./cinematic/HeroField";
|
||||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
|
||||||
|
|
||||||
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const SOCIAL_LINKS = [
|
const SOCIAL_LINKS = [
|
||||||
{
|
{
|
||||||
href: "https://github.com/evandrodevbr",
|
href: "https://github.com/evandrodevbr",
|
||||||
label: "GitHub",
|
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/",
|
href: "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
||||||
label: "LinkedIn",
|
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]",
|
href: "mailto:[email protected]",
|
||||||
label: "Email",
|
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_POINTS = [
|
/** Números reais, calculados em app/page.tsx a partir de public/portfolio-data.json (nada digitado à mão). */
|
||||||
"Produtos conectados, integracoes complexas e operacoes criticas.",
|
export type HeroStats = {
|
||||||
"Arquitetura mais clara para evoluir sem aumentar o caos tecnico.",
|
years?: number;
|
||||||
"Execucao ponta a ponta, do discovery a entrega em producao.",
|
projects?: number;
|
||||||
];
|
demos?: number;
|
||||||
|
|
||||||
const PROOF_ITEMS = [
|
|
||||||
{
|
|
||||||
label: "Quem eu ajudo",
|
|
||||||
value:
|
|
||||||
"Empresas que dependem de integracoes, hardware ou fluxos criticos para operar com seguranca e previsibilidade.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Como entrego",
|
|
||||||
value:
|
|
||||||
"Traduzo complexidade tecnica em backlog claro, arquitetura legivel e decisao mais rapida para o time.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Por que conversar",
|
|
||||||
value:
|
|
||||||
"Voce ganha um parceiro tecnico capaz de conectar produto, backend, interface e operacao sem perder contexto.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Hero() {
|
|
||||||
const shouldReduceMotion = useReducedMotion();
|
|
||||||
|
|
||||||
const hour = new Date().getHours();
|
|
||||||
const greeting =
|
|
||||||
hour >= 8 && hour < 12
|
|
||||||
? "Ola! Vi seu portfolio. Podemos conversar?"
|
|
||||||
: hour >= 13 && hour < 18
|
|
||||||
? "Boa tarde! Vi seu portfolio. Podemos conversar?"
|
|
||||||
: "Ola! Vi seu portfolio. Podemos conversar quando estiver disponivel?";
|
|
||||||
|
|
||||||
// Cinematic stagger variants
|
|
||||||
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];
|
function statItems(stats?: HeroStats) {
|
||||||
|
if (!stats) return [];
|
||||||
|
return [
|
||||||
|
stats.years ? [String(stats.years), "anos de experiência"] : null,
|
||||||
|
stats.projects ? [String(stats.projects), "projetos publicados"] : null,
|
||||||
|
stats.demos ? [String(stats.demos), "com demonstração online"] : null,
|
||||||
|
].filter((item): item is [string, string] => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
const heroItem: Variants = {
|
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
|
||||||
hidden: shouldReduceMotion
|
const TITLE =
|
||||||
? { opacity: 0 }
|
"Sistemas sob medida para operações que não cabem em soluções prontas.".split(
|
||||||
: { opacity: 0, y: 40, filter: "blur(10px)" },
|
" ",
|
||||||
visible: {
|
);
|
||||||
opacity: 1,
|
const ACCENT = new Set([1, 2]);
|
||||||
y: 0,
|
|
||||||
filter: "blur(0px)",
|
|
||||||
transition: {
|
|
||||||
duration: 1,
|
|
||||||
ease: easeOut,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const heroItemSlow: Variants = {
|
/** O que cada camada da cena significa, em linguagem simples. */
|
||||||
hidden: shouldReduceMotion
|
const CAPTIONS = [
|
||||||
? { opacity: 0 }
|
["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
|
||||||
: { opacity: 0, y: 60, filter: "blur(12px)" },
|
[
|
||||||
visible: {
|
"As integrações",
|
||||||
opacity: 1,
|
"Os programas que você já usa conversando entre si, sem retrabalho.",
|
||||||
y: 0,
|
],
|
||||||
filter: "blur(0px)",
|
["Os dados", "Suas informações guardadas, organizadas e seguras."],
|
||||||
transition: {
|
] as const;
|
||||||
duration: 1.2,
|
|
||||||
ease: easeOut,
|
export default function Hero({ stats }: { stats?: HeroStats }) {
|
||||||
},
|
const items = statItems(stats);
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="home"
|
id="home"
|
||||||
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
|
||||||
>
|
>
|
||||||
{/* 3D Scene Background */}
|
<div className="hero-stage">
|
||||||
{!shouldReduceMotion && <HeroScene />}
|
<div className="hero-glow" aria-hidden="true" />
|
||||||
|
<HeroField />
|
||||||
{/* Cinematic overlays */}
|
<div className="hero-scrim" aria-hidden="true" />
|
||||||
<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%)]" />
|
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
||||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
|
<ol className="hero-captions">
|
||||||
|
{CAPTIONS.map(([title, text], index) => (
|
||||||
{/* Scanlines effect */}
|
<li key={title} style={{ "--i": index } as CSSProperties}>
|
||||||
<div
|
<span className="hero-caption-title">{title}</span>
|
||||||
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.015]"
|
{text}
|
||||||
style={{
|
</li>
|
||||||
backgroundImage:
|
|
||||||
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Vignette */}
|
|
||||||
<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="grid items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10"
|
|
||||||
>
|
|
||||||
<div className="max-w-[760px]">
|
|
||||||
{/* Status badge with glow */}
|
|
||||||
<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)]">
|
|
||||||
Available for selected builds
|
|
||||||
</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)]">
|
|
||||||
Produtos conectados, integracoes criticas e entrega ponta a ponta
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Eyebrow */}
|
|
||||||
<motion.p
|
|
||||||
className="mb-sp-4 max-w-[540px] font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]"
|
|
||||||
variants={heroItem}
|
|
||||||
>
|
|
||||||
Evandro Fonseca Junior | Engenheiro de software full-stack
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
{/* Main heading with cinematic reveal */}
|
|
||||||
<motion.h1
|
|
||||||
className="max-w-[920px] text-balance font-display text-[clamp(3rem,7vw,6.2rem)] font-extrabold leading-[0.9] tracking-[-0.055em]"
|
|
||||||
variants={heroItemSlow}
|
|
||||||
>
|
|
||||||
<span className="cinematic-text-gradient">
|
|
||||||
Engenharia de software para sistemas que nao podem falhar.
|
|
||||||
</span>
|
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
{/* Subtitle */}
|
|
||||||
<motion.p
|
|
||||||
className="mt-sp-6 max-w-[680px] text-[16px] leading-8 text-[rgba(244,242,237,.88)] lg:text-[17px]"
|
|
||||||
variants={heroItem}
|
|
||||||
>
|
|
||||||
Ajudo empresas com produtos conectados, integracoes complexas e
|
|
||||||
operacoes criticas a transformar arquitetura confusa em software
|
|
||||||
estavel, mais rapido de evoluir e muito mais facil de operar.
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
{/* Key points with staggered reveal */}
|
|
||||||
<motion.ul
|
|
||||||
className="mt-sp-8 grid gap-sp-3 sm:grid-cols-3"
|
|
||||||
variants={heroItem}
|
|
||||||
>
|
|
||||||
{HERO_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],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
</ol>
|
||||||
|
|
||||||
{/* CTAs with magnetic effect */}
|
<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">
|
||||||
<motion.div
|
<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]">
|
||||||
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
|
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
||||||
variants={heroItem}
|
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}
|
||||||
>
|
>
|
||||||
<MagneticButton
|
{word}
|
||||||
as="a"
|
</span>{" "}
|
||||||
href={`https://wa.me/5547992742365?text=${encodeURIComponent(greeting)}`}
|
</Fragment>
|
||||||
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"
|
</h1>
|
||||||
strength={0.25}
|
<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
|
||||||
<span className="relative z-10">Agendar conversa tecnica</span>
|
entendimento do negócio à arquitetura e à entrega em produção.
|
||||||
</MagneticButton>
|
</p>
|
||||||
|
|
||||||
<MagneticButton
|
<div className="hero-in mt-sp-6 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
as="a"
|
<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]"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
href="/cases"
|
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"
|
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]"
|
||||||
data-cursor="View"
|
|
||||||
strength={0.25}
|
|
||||||
>
|
>
|
||||||
Ver casos selecionados
|
Ver projetos entregues
|
||||||
</MagneticButton>
|
</Link>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Footer info with reveal */}
|
|
||||||
<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}
|
|
||||||
>
|
|
||||||
<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>
|
||||||
<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>
|
||||||
<div className="flex items-center gap-sp-2">
|
|
||||||
|
<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(([value, label]) => (
|
||||||
|
<div key={label} className="flex flex-col-reverse">
|
||||||
|
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-mono text-[clamp(22px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
|
||||||
|
{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) => (
|
{SOCIAL_LINKS.map((link) => (
|
||||||
<MagneticButton
|
<a
|
||||||
key={link.label}
|
key={link.label}
|
||||||
as="a"
|
|
||||||
href={link.href}
|
href={link.href}
|
||||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||||
rel={
|
rel={
|
||||||
@@ -302,122 +163,26 @@ export default function Hero() {
|
|||||||
? "noopener noreferrer"
|
? "noopener noreferrer"
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className="cinematic-social-link inline-flex h-11 w-11 items-center justify-center rounded-full"
|
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"
|
||||||
strength={0.4}
|
|
||||||
>
|
>
|
||||||
<svg
|
{link.label}
|
||||||
width="20"
|
</a>
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
|
||||||
</motion.div>
|
Santa Catarina · Atendimento remoto
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Aside card with cinematic entrance */}
|
|
||||||
<motion.aside
|
|
||||||
className="cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8"
|
|
||||||
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],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Top glow line */}
|
|
||||||
<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] }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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)]">
|
|
||||||
Why this positioning works
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-sp-3 font-display text-[clamp(1.35rem,3vw,2rem)] font-extrabold leading-[1.04] tracking-[-0.03em] text-[#f7f4ee]">
|
|
||||||
Menos atrito entre produto, engenharia e operacao.
|
|
||||||
</h2>
|
|
||||||
<p className="mt-sp-4 text-[14px] leading-7 text-[rgba(244,242,237,.78)]">
|
|
||||||
Entro onde a complexidade cresce e ajudo a devolver clareza,
|
|
||||||
ritmo de entrega e confiabilidade para o sistema e para o time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-sp-6 space-y-sp-5">
|
|
||||||
{PROOF_ITEMS.map((item, index) => (
|
|
||||||
<motion.article
|
|
||||||
key={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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll indicator */}
|
|
||||||
<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>
|
</span>
|
||||||
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Role para mergulhar
|
||||||
|
<span className="hero-cue-line" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -1,109 +1,337 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { useState } from "react";
|
||||||
import { Section } from "./Section";
|
|
||||||
|
|
||||||
const METRICS = [
|
const METRICS = [
|
||||||
{
|
{
|
||||||
score: 100,
|
id: "performance",
|
||||||
label: "Performance",
|
label: "Performance",
|
||||||
|
code: "LCP / INP / CLS",
|
||||||
|
title: "O essencial chega primeiro.",
|
||||||
description:
|
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",
|
label: "Acessibilidade",
|
||||||
|
code: "TECLADO / FOCO / SEMÂNTICA",
|
||||||
|
title: "A interface inclui mais pessoas.",
|
||||||
description:
|
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,
|
id: "practices",
|
||||||
label: "Best Practices",
|
label: "Boas práticas",
|
||||||
|
code: "HTTPS / PRIVACIDADE / DEPENDÊNCIAS",
|
||||||
|
title: "Confiança também se constrói.",
|
||||||
description:
|
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",
|
label: "SEO",
|
||||||
|
code: "HTML / METADATA / INDEXAÇÃO",
|
||||||
|
title: "Conteúdo pronto para ser encontrado.",
|
||||||
description:
|
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">
|
||||||
|
<h1>
|
||||||
|
</text>
|
||||||
|
<path d="M331 317h213v29H331z" />
|
||||||
|
<text x="350" y="337">
|
||||||
|
<article> / <nav>
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function LighthouseMetrics() {
|
export default function LighthouseMetrics() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const [active, setActive] = useState(METRICS[0]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<section
|
||||||
id="lighthouse"
|
id="lighthouse"
|
||||||
eyebrow="Quality & Performance"
|
className="ds-section ds-section-dark quality-lab"
|
||||||
title="Engenharia de ponta a ponta: 100/100 não é vaidade."
|
aria-labelledby="quality-title"
|
||||||
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."
|
data-inspection={active.id}
|
||||||
dark
|
|
||||||
disableChildrenAnimation={false}
|
|
||||||
>
|
>
|
||||||
<div className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="ds-section-shell">
|
||||||
{METRICS.map((metric, i) => (
|
<div className="quality-intro">
|
||||||
<motion.article
|
<header className="quality-copy">
|
||||||
key={metric.label}
|
<p className="ds-eyebrow">Critérios de entrega</p>
|
||||||
className="ds-card flex flex-col items-center gap-sp-6 text-center lg:items-start lg:text-left"
|
<h2 id="quality-title" className="ds-section-title">
|
||||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
|
Engenharia de ponta a ponta.
|
||||||
whileInView={
|
<span>Qualidade em cada camada.</span>
|
||||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
</h2>
|
||||||
}
|
<p>
|
||||||
viewport={{ once: true, amount: 0.2 }}
|
Sistemas precisam mais do que uma boa aparência. Explore as
|
||||||
transition={{ duration: 0.6, delay: i * 0.1 }}
|
decisões que tornam uma interface rápida, acessível, confiável e
|
||||||
>
|
fácil de encontrar.
|
||||||
{/* The Ring Container */}
|
</p>
|
||||||
<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">
|
<a className="ds-link ds-link-accent" href="/#contact">
|
||||||
<svg
|
Conversar sobre a qualidade do seu projeto
|
||||||
className="absolute inset-0 h-full w-full rotate-[-90deg] drop-shadow-[0_0_12px_rgba(12,206,107,0.35)]"
|
</a>
|
||||||
viewBox="0 0 36 36"
|
</header>
|
||||||
aria-hidden="true"
|
|
||||||
>
|
<figure className="quality-visual bg-grid-light">
|
||||||
{/* Background Track */}
|
<div className="quality-visual-label">
|
||||||
<path
|
<span>Laboratório de qualidade</span>
|
||||||
className="text-[rgba(255,255,255,0.1)]"
|
<span>Modelo interativo</span>
|
||||||
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>
|
||||||
|
{BLUEPRINT}
|
||||||
|
<figcaption>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<span key={metric.id} aria-hidden={active.id !== metric.id}>
|
||||||
|
{metric.caption}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Details */}
|
<fieldset className="quality-controls" aria-describedby="quality-hint">
|
||||||
<div className="flex-1">
|
<legend className="sr-only">Escolha um aspecto da qualidade</legend>
|
||||||
<h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]">
|
{METRICS.map((metric) => (
|
||||||
{metric.label}
|
<label key={metric.id} className="quality-control">
|
||||||
</h3>
|
<input
|
||||||
<p className="mt-sp-3 text-[13px] leading-[1.8] text-[rgba(255,255,255,.75)]">
|
type="radio"
|
||||||
{metric.description}
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.article>
|
</section>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+13
-172
@@ -1,184 +1,25 @@
|
|||||||
"use client";
|
import Image from "next/image";
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
variant?: "mark" | "wordmark" | "icon";
|
theme?: "dark" | "light";
|
||||||
size?: "sm" | "md" | "lg" | "xl";
|
|
||||||
theme?: "dark" | "light" | "accent";
|
|
||||||
className?: string;
|
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({
|
export function Logo({
|
||||||
variant = "mark",
|
|
||||||
size = "md",
|
|
||||||
theme = "dark",
|
theme = "dark",
|
||||||
className = "",
|
className = "w-48",
|
||||||
showTagline = false,
|
loading = "lazy",
|
||||||
}: Readonly<LogoProps>) {
|
}: 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 (
|
return (
|
||||||
<motion.svg
|
<Image
|
||||||
width={dimensions.width}
|
src={`/brand/wordmark-${theme}.webp`}
|
||||||
height={dimensions.height}
|
alt="Evandro.dev.br"
|
||||||
viewBox="0 0 64 64"
|
width={1600}
|
||||||
fill="none"
|
height={330}
|
||||||
className={className}
|
sizes="240px"
|
||||||
role="img"
|
loading={loading}
|
||||||
aria-label="Evandro icon logo"
|
className={`block h-auto max-w-full ${className}`}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
type CoverProject = { id: string; title: string };
|
||||||
|
|
||||||
|
/** 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 src = SCREENSHOTS[project.id];
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+95
-63
@@ -1,48 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { motion, type Variants } from "motion/react";
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||||
import { Section } from "./Section";
|
import { caseStudies } from "@/lib/site-content";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import { SectionLoading } from "./SectionLoading";
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
|
import { ProjectCover } from "./ProjectCover";
|
||||||
|
import { Section } from "./Section";
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects({
|
||||||
const { data, loading, error } = usePortfolioData();
|
projects,
|
||||||
|
}: {
|
||||||
|
projects: PortfolioProjectView[];
|
||||||
|
}) {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
const projects = useMemo(
|
|
||||||
() => data?.projects.filter((p) => p.featured) || [],
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <SectionLoading dark message="Loading selected work" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className="ds-error">Error loading selected work</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const gridVariants: Variants = {
|
|
||||||
hidden: {},
|
|
||||||
visible: {
|
|
||||||
transition: {
|
|
||||||
delayChildren: 0.08,
|
|
||||||
staggerChildren: 0.08,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const cardVariants: Variants = {
|
const cardVariants: Variants = {
|
||||||
hidden: shouldReduceMotion
|
hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 24 },
|
||||||
? { opacity: 0 }
|
|
||||||
: { opacity: 0, y: 24, clipPath: "inset(100% 0 0 0)" },
|
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
clipPath: "inset(0% 0 0 0)",
|
|
||||||
transition: shouldReduceMotion
|
transition: shouldReduceMotion
|
||||||
? { duration: 0.25 }
|
? { duration: 0.25 }
|
||||||
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
|
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
|
||||||
@@ -52,23 +30,24 @@ export default function Projects() {
|
|||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
id="projects"
|
id="projects"
|
||||||
eyebrow="Selected work"
|
eyebrow="Projetos"
|
||||||
title="Projetos em Next.js, Tauri, Rust e integracao de sistemas"
|
title="Veja o que já saiu do papel."
|
||||||
description="Cases e produtos que mostram minha atuacao em engenharia de software, consultoria tecnica e construcao de sistemas de alta exigencia."
|
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
|
dark
|
||||||
disableChildrenAnimation
|
disableChildrenAnimation
|
||||||
>
|
>
|
||||||
<motion.div
|
<div className="ds-grid-fluid">
|
||||||
className="ds-grid-2"
|
{projects.map((project) => {
|
||||||
initial="hidden"
|
const caseStudy = caseStudies.find(
|
||||||
whileInView="visible"
|
(entry) => entry.id === project.id,
|
||||||
viewport={{ once: true, amount: 0.2 }}
|
);
|
||||||
variants={gridVariants}
|
return (
|
||||||
>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<motion.article
|
<motion.article
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className="ds-card h-full"
|
className="ds-card h-full"
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, amount: "some" }}
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
whileHover={
|
whileHover={
|
||||||
shouldReduceMotion
|
shouldReduceMotion
|
||||||
@@ -81,7 +60,8 @@ export default function Projects() {
|
|||||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||||
transition={{ type: "spring", stiffness: 170, damping: 18 }}
|
transition={{ type: "spring", stiffness: 170, damping: 18 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-sp-6">
|
<ProjectCover project={project} />
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-sp-6">
|
||||||
<div className="max-w-[560px]">
|
<div className="max-w-[560px]">
|
||||||
<div className="ds-kicker">{project.category}</div>
|
<div className="ds-kicker">{project.category}</div>
|
||||||
<DrawLine delay={0.05} />
|
<DrawLine delay={0.05} />
|
||||||
@@ -92,20 +72,51 @@ export default function Projects() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<MagneticButton as="span" className="ds-tag" strength={0.15}>{project.status}</MagneticButton>
|
<MagneticButton
|
||||||
|
as="span"
|
||||||
|
className="ds-tag"
|
||||||
|
strength={0.15}
|
||||||
|
wrapperClassName="shrink-0 max-sm:ml-auto"
|
||||||
|
>
|
||||||
|
{project.status}
|
||||||
|
</MagneticButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="ds-copy">{project.description}</p>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<ul className="ds-tags mt-sp-6">
|
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
|
||||||
{project.technologies.slice(0, 5).map((tech: string) => (
|
{project.technologies.slice(0, 5).map((tech) => (
|
||||||
<MagneticButton key={tech} as="li" className="ds-tag" strength={0.15}>
|
<li key={tech} className="ds-tag">
|
||||||
{tech}
|
{tech}
|
||||||
</MagneticButton>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
<div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
|
||||||
|
{caseStudy && (
|
||||||
|
<MagneticButton
|
||||||
|
as="a"
|
||||||
|
href={`/cases/${caseStudy.id}`}
|
||||||
|
className="ds-link ds-link-accent"
|
||||||
|
strength={0.2}
|
||||||
|
>
|
||||||
|
Entender a solução
|
||||||
|
</MagneticButton>
|
||||||
|
)}
|
||||||
{project.githubUrl && (
|
{project.githubUrl && (
|
||||||
<MagneticButton
|
<MagneticButton
|
||||||
as="a"
|
as="a"
|
||||||
@@ -118,7 +129,9 @@ export default function Projects() {
|
|||||||
Ver codigo
|
Ver codigo
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
)}
|
)}
|
||||||
{project.demoUrl && (
|
{project.demoUrl &&
|
||||||
|
(project.demoAvailability === "available" ||
|
||||||
|
project.demoAvailability === "unknown") && (
|
||||||
<MagneticButton
|
<MagneticButton
|
||||||
as="a"
|
as="a"
|
||||||
href={project.demoUrl}
|
href={project.demoUrl}
|
||||||
@@ -128,24 +141,43 @@ export default function Projects() {
|
|||||||
strength={0.25}
|
strength={0.25}
|
||||||
>
|
>
|
||||||
Abrir projeto
|
Abrir projeto
|
||||||
|
{project.demoAvailability === "unknown" && (
|
||||||
|
<span className="sr-only">
|
||||||
|
{" "}
|
||||||
|
Disponibilidade não verificada
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
)}
|
)}
|
||||||
|
{project.demoAvailability === "unavailable" && (
|
||||||
|
<span className="ds-link ds-link-accent" aria-disabled="true">
|
||||||
|
Demo indisponível na última verificação
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
))}
|
);
|
||||||
</motion.div>
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
|
||||||
<MagneticButton as="a" href="/cases" className="ds-link ds-link-accent" strength={0.2}>
|
<MagneticButton
|
||||||
|
as="a"
|
||||||
|
href="/cases"
|
||||||
|
className="ds-link ds-link-accent"
|
||||||
|
strength={0.2}
|
||||||
|
>
|
||||||
Explorar todos os cases
|
Explorar todos os cases
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-muted" strength={0.2}>
|
<MagneticButton
|
||||||
|
as="a"
|
||||||
|
href="/servicos"
|
||||||
|
className="ds-link ds-link-muted"
|
||||||
|
strength={0.2}
|
||||||
|
>
|
||||||
Ver servicos relacionados
|
Ver servicos relacionados
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "motion/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface RevealProps {
|
interface RevealProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
|
|
||||||
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,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion, type Variants } from "motion/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import { TextDiffuseReveal } from "./animations/TextDiffuseReveal";
|
import { TextDiffuseReveal } from "./animations/TextDiffuseReveal";
|
||||||
import { Reveal } from "./Reveal";
|
import { Reveal } from "./Reveal";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
import { TopProgressBar } from "./animations/TopProgressBar";
|
import { TopProgressBar } from "./animations/TopProgressBar";
|
||||||
|
|
||||||
interface SectionLoadingProps {
|
interface SectionLoadingProps {
|
||||||
@@ -14,6 +15,11 @@ export function SectionLoading({
|
|||||||
skeletons = 2,
|
skeletons = 2,
|
||||||
}: Readonly<SectionLoadingProps>) {
|
}: Readonly<SectionLoadingProps>) {
|
||||||
const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section";
|
const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section";
|
||||||
|
const skeletonId = useId();
|
||||||
|
const skeletonKeys = Array.from(
|
||||||
|
{ length: skeletons },
|
||||||
|
(_, index) => `${skeletonId}-${index}`,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={sectionClass}>
|
<section className={sectionClass}>
|
||||||
@@ -21,8 +27,8 @@ export function SectionLoading({
|
|||||||
<div className="ds-section-shell">
|
<div className="ds-section-shell">
|
||||||
<div className="ds-loading">{message}</div>
|
<div className="ds-loading">{message}</div>
|
||||||
<div className="ds-grid-2 mt-sp-6" aria-hidden="true">
|
<div className="ds-grid-2 mt-sp-6" aria-hidden="true">
|
||||||
{Array.from({ length: skeletons }).map((_, index) => (
|
{skeletonKeys.map((skeletonKey) => (
|
||||||
<div key={`skeleton-${index}`} className="ds-card ds-skeleton-card">
|
<div key={skeletonKey} className="ds-card ds-skeleton-card">
|
||||||
<div className="ds-skeleton ds-skeleton-kicker" />
|
<div className="ds-skeleton ds-skeleton-kicker" />
|
||||||
<div className="ds-skeleton ds-skeleton-title" />
|
<div className="ds-skeleton ds-skeleton-title" />
|
||||||
<div className="ds-skeleton ds-skeleton-line" />
|
<div className="ds-skeleton ds-skeleton-line" />
|
||||||
|
|||||||
+19
-25
@@ -1,24 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { motion, type Variants } from "motion/react";
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { Section } from "./Section";
|
import type { PortfolioSkillCategory } from "@/lib/portfolio-data";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import { SectionLoading } from "./SectionLoading";
|
import { Section } from "./Section";
|
||||||
|
|
||||||
export default function Skills() {
|
export default function Skills({
|
||||||
const { data, loading, error } = usePortfolioData();
|
categories,
|
||||||
|
}: {
|
||||||
|
categories: PortfolioSkillCategory[];
|
||||||
|
}) {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <SectionLoading dark message="Loading core stack" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return <div className="ds-error">Error loading core stack</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const gridVariants: Variants = {
|
const gridVariants: Variants = {
|
||||||
hidden: {},
|
hidden: {},
|
||||||
visible: {
|
visible: {
|
||||||
@@ -47,8 +41,8 @@ export default function Skills() {
|
|||||||
<Section
|
<Section
|
||||||
id="skills"
|
id="skills"
|
||||||
eyebrow="Core stack"
|
eyebrow="Core stack"
|
||||||
title="Next.js, Tauri, Rust e stack para produtos criticos"
|
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 integracoes complexas."
|
description="Tecnologias e fundamentos que uso em engenharia de software, arquitetura de software, IoT e integrações complexas."
|
||||||
dark
|
dark
|
||||||
disableChildrenAnimation
|
disableChildrenAnimation
|
||||||
>
|
>
|
||||||
@@ -59,7 +53,7 @@ export default function Skills() {
|
|||||||
viewport={{ once: true, amount: 0.2 }}
|
viewport={{ once: true, amount: 0.2 }}
|
||||||
variants={gridVariants}
|
variants={gridVariants}
|
||||||
>
|
>
|
||||||
{data.skills.technical.map((category) => (
|
{categories.map((category) => (
|
||||||
<motion.article
|
<motion.article
|
||||||
key={category.category}
|
key={category.category}
|
||||||
className="ds-card h-full"
|
className="ds-card h-full"
|
||||||
@@ -75,17 +69,19 @@ export default function Skills() {
|
|||||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
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} />
|
<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
|
Ferramentas que uso para construir com clareza tecnica e ritmo de
|
||||||
entrega.
|
entrega.
|
||||||
</p>
|
</p>
|
||||||
<ul className="ds-tags">
|
<ul className="ds-tags" data-reel-stagger="fade">
|
||||||
{category.skills.map((skill) => (
|
{category.skills.map((skill) => (
|
||||||
<MagneticButton key={skill} as="li" className="ds-tag" strength={0.15}>
|
<li key={skill} className="ds-tag">
|
||||||
{skill}
|
{skill}
|
||||||
</MagneticButton>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
@@ -94,5 +90,3 @@ export default function Skills() {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion, type Variants } from "motion/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { serviceHubLinks } from "@/lib/site-content";
|
import { serviceHubLinks } from "@/lib/site-content";
|
||||||
import { Section } from "./Section";
|
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
|
import { Section } from "./Section";
|
||||||
|
|
||||||
interface Tier {
|
interface Tier {
|
||||||
id: string;
|
id: string;
|
||||||
badge: string;
|
badge: string;
|
||||||
level: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
tagline: string;
|
tagline: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -24,64 +24,83 @@ interface Tier {
|
|||||||
|
|
||||||
const TIERS: Tier[] = [
|
const TIERS: Tier[] = [
|
||||||
{
|
{
|
||||||
id: "discovery",
|
id: "build",
|
||||||
badge: "Entrada / Baixo Risco",
|
badge: "Do zero à produção",
|
||||||
level: "01",
|
title: "Um sistema para a sua operação",
|
||||||
title: "Auditoria & Diagnostico Tecnico",
|
tagline: "Quando as ferramentas prontas já não atendem.",
|
||||||
tagline: "Fotografia clara do problema antes de qualquer comprometimento.",
|
|
||||||
description:
|
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: [
|
deliverables: [
|
||||||
"Relatorio de gargalos e divida tecnica",
|
"Definição de escopo e arquitetura",
|
||||||
"Mapeamento de riscos e dependencias",
|
"Interface, backend e banco de dados",
|
||||||
"Sugestao de stack e cronograma de mitigacao",
|
"Integrações e permissões de acesso",
|
||||||
"Plano de acao priorizado",
|
"Validação, publicação e documentação",
|
||||||
],
|
],
|
||||||
pricing: "Valor Fixo",
|
pricing: "Proposta por projeto",
|
||||||
pricingNote: "Contrato unico. Entrega em ate 5 dias uteis.",
|
pricingNote: "Escopo, etapas e investimento definidos antes da execução.",
|
||||||
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.",
|
|
||||||
featured: true,
|
featured: true,
|
||||||
ctaLabel: "Iniciar ciclo de engenharia",
|
ctaLabel: "Conversar sobre meu sistema",
|
||||||
ctaHref: "#contact",
|
ctaHref: "/#contact",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "fractional",
|
id: "discovery",
|
||||||
badge: "Premium / High-Ticket",
|
badge: "Antes de investir",
|
||||||
level: "03",
|
title: "Diagnóstico de um sistema existente",
|
||||||
title: "Fractional Tech Lead",
|
tagline: "Entenda o que aproveitar e o que precisa mudar.",
|
||||||
tagline: "Lideranca tecnica senior. Sem o custo de um CTO full-time.",
|
|
||||||
description:
|
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: [
|
deliverables: [
|
||||||
"SLA prioritario Zero-Risk",
|
"Levantamento de riscos e dependências",
|
||||||
"Code review e lideranca do time interno",
|
"Revisão de acesso aos dados",
|
||||||
"Decisoes de infra: Cloud, VPS, CI/CD",
|
"Análise dos gargalos da aplicação",
|
||||||
"Consultoria estrategica recorrente",
|
"Plano de correção por prioridade",
|
||||||
],
|
],
|
||||||
pricing: "Retainer Mensal",
|
pricing: "Escopo de análise definido",
|
||||||
pricingNote: "Renovacao mensal. Vagas limitadas.",
|
pricingNote: "Profundidade e prazo combinados conforme o sistema.",
|
||||||
featured: false,
|
ctaLabel: "Avaliar meu projeto",
|
||||||
ctaLabel: "Agendar conversa estrategica",
|
ctaHref: "/#contact",
|
||||||
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.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -111,15 +130,12 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
|||||||
<span className="ds-kicker max-md:text-[10px] max-md:leading-[1.6]">
|
<span className="ds-kicker max-md:text-[10px] max-md:leading-[1.6]">
|
||||||
{tier.badge}
|
{tier.badge}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] tracking-[0.08em] text-[var(--meta)]">
|
|
||||||
{tier.level}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DrawLine delay={0.04} />
|
<DrawLine delay={0.04} />
|
||||||
|
|
||||||
<div className="mt-sp-4 min-w-0">
|
<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}
|
{tier.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-sp-2 text-[12px] leading-[1.7] text-[var(--meta)] md:text-[11px]">
|
<p className="mt-sp-2 text-[12px] leading-[1.7] text-[var(--meta)] md:text-[11px]">
|
||||||
@@ -127,11 +143,17 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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}
|
{tier.description}
|
||||||
</p>
|
</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) => (
|
{tier.deliverables.map((deliverable) => (
|
||||||
<li key={deliverable} className="pl-sp-1">
|
<li key={deliverable} className="pl-sp-1">
|
||||||
{deliverable}
|
{deliverable}
|
||||||
@@ -143,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">
|
<div className="font-display text-[clamp(17px,4.6vw,18px)] font-extrabold leading-none tracking-tight">
|
||||||
{tier.pricing}
|
{tier.pricing}
|
||||||
</div>
|
</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}
|
{tier.pricingNote}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,37 +214,49 @@ export function WorkModalities() {
|
|||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
id="modalities"
|
id="modalities"
|
||||||
eyebrow="Working models"
|
eyebrow="Soluções"
|
||||||
title="Servicos de engenharia de software e consultoria tecnica."
|
title="Software feito a partir do seu negócio."
|
||||||
description="Formatos de contratacao para arquitetura de software, integracoes criticas, produtos conectados e evolucao de sistemas em producao."
|
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
|
disableChildrenAnimation
|
||||||
>
|
>
|
||||||
<div className="mb-sp-8 ds-card bg-[rgba(10,10,10,0.02)]">
|
<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="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className="max-w-[760px]">
|
<div className="max-w-[760px]">
|
||||||
<div className="ds-kicker">Paginas de servico</div>
|
<div className="ds-kicker">Reconhece algum destes cenários?</div>
|
||||||
<h3 className="ds-card-title !mt-sp-3">Especialidades organizadas por intencao de busca</h3>
|
<h3 className="ds-card-title !mt-sp-3" data-reel="left">
|
||||||
<p className="ds-copy !mt-sp-3 max-w-[62ch]">
|
Planilhas demais. Sistemas isolados. Trabalho repetido.
|
||||||
Se voce chegou buscando uma stack, um tipo de consultoria ou um contexto tecnico especifico,
|
</h3>
|
||||||
aqui esta o caminho mais direto para a pagina certa.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-accent" strength={0.2}>
|
<MagneticButton
|
||||||
Ver hub de servicos
|
as="a"
|
||||||
|
href="/servicos"
|
||||||
|
className="ds-link ds-link-accent"
|
||||||
|
strength={0.2}
|
||||||
|
>
|
||||||
|
Conhecer todos os serviços
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
</div>
|
</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) => (
|
{serviceHubLinks.map((service) => (
|
||||||
<Link
|
<Link
|
||||||
key={service.href}
|
key={service.href}
|
||||||
href={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}
|
{service.label}
|
||||||
</div>
|
</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}
|
{service.description}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -243,7 +277,28 @@ export function WorkModalities() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useInView } from "motion/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useInView, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface CounterValueProps {
|
interface CounterValueProps {
|
||||||
value: number;
|
value: number;
|
||||||
@@ -41,7 +42,7 @@ export function CounterValue({
|
|||||||
const frame = (now: number) => {
|
const frame = (now: number) => {
|
||||||
const elapsed = now - start;
|
const elapsed = now - start;
|
||||||
const progress = Math.min(elapsed / duration, 1);
|
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));
|
setDisplayValue(Math.round(value * eased));
|
||||||
|
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "motion/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface DrawLineProps {
|
interface DrawLineProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { motion } from "motion/react";
|
||||||
import Image, { type StaticImageData } from "next/image";
|
import Image, { type StaticImageData } from "next/image";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { useState } from "react";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface LazyRevealImageProps {
|
interface LazyRevealImageProps {
|
||||||
src: string | StaticImageData;
|
src: string | StaticImageData;
|
||||||
@@ -26,9 +27,7 @@ export function LazyRevealImage({
|
|||||||
<motion.div
|
<motion.div
|
||||||
className={`relative ${className}`.trim()}
|
className={`relative ${className}`.trim()}
|
||||||
initial={
|
initial={
|
||||||
shouldReduceMotion
|
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20, scale: 1.02 }
|
||||||
? { opacity: 0 }
|
|
||||||
: { opacity: 0, y: 20, scale: 1.02 }
|
|
||||||
}
|
}
|
||||||
animate={
|
animate={
|
||||||
loaded
|
loaded
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useInView, useReducedMotion } from "framer-motion";
|
import { useInView } from "motion/react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { type CSSProperties, useRef } from "react";
|
||||||
|
|
||||||
interface TextDiffuseRevealProps {
|
interface TextDiffuseRevealProps {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -11,8 +11,12 @@ interface TextDiffuseRevealProps {
|
|||||||
amount?: number;
|
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({
|
export function TextDiffuseReveal({
|
||||||
text,
|
text,
|
||||||
className = "",
|
className = "",
|
||||||
@@ -21,113 +25,36 @@ export function TextDiffuseReveal({
|
|||||||
amount = 0.3,
|
amount = 0.3,
|
||||||
}: Readonly<TextDiffuseRevealProps>) {
|
}: Readonly<TextDiffuseRevealProps>) {
|
||||||
const ref = useRef<HTMLSpanElement | null>(null);
|
const ref = useRef<HTMLSpanElement | null>(null);
|
||||||
const shouldReduceMotion = useReducedMotion();
|
|
||||||
const isInView = useInView(ref, { once, amount });
|
const isInView = useInView(ref, { once, amount });
|
||||||
const [staggerDelay, setStaggerDelay] = useState(0.03);
|
let word = 0;
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={className}
|
className={`text-reveal ${className}`.trim()}
|
||||||
aria-label={text}
|
data-shown={isInView ? "" : undefined}
|
||||||
style={{
|
style={{ "--d": `${startDelay}s` } as CSSProperties}
|
||||||
willChange: hasActiveWillChange
|
|
||||||
? "filter, opacity, transform"
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{tokens.map((token, tokenIndex) => {
|
<span className="sr-only">{text}</span>
|
||||||
if (!token) {
|
<span aria-hidden="true">
|
||||||
return null;
|
{text
|
||||||
}
|
.split(/(\n|\s+)/)
|
||||||
|
.filter(Boolean)
|
||||||
if (token === "\n") {
|
.map((token, index) => {
|
||||||
return <br key={`line-${tokenIndex}`} />;
|
const key = `${index}:${token}`;
|
||||||
}
|
if (token === "\n") return <br key={key} />;
|
||||||
|
if (/^\s+$/.test(token)) return " ";
|
||||||
if (/^\s+$/.test(token)) {
|
|
||||||
return (
|
|
||||||
<span key={`space-${tokenIndex}`} aria-hidden="true">
|
|
||||||
{token.replace(/ /g, "\u00A0")}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={`word-${tokenIndex}`}
|
key={key}
|
||||||
className="inline-block whitespace-nowrap"
|
className="text-reveal-word"
|
||||||
|
style={{ "--i": word++ } as CSSProperties}
|
||||||
>
|
>
|
||||||
{Array.from(token).map((char, charIndex) => {
|
{token}
|
||||||
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
|
|
||||||
? shouldReduceMotion
|
|
||||||
? { opacity: 1 }
|
|
||||||
: { filter: "blur(0px)", opacity: 1, scale: 1 }
|
|
||||||
: shouldReduceMotion
|
|
||||||
? { opacity: 0 }
|
|
||||||
: { 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion } from "motion/react";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface TopProgressBarProps {
|
interface TopProgressBarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
export default function PointCloudScene() {
|
export default function PointCloudScene() {
|
||||||
return <div data-testid="point-cloud-mock"></div>;
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,90 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import { useEffect, useRef } from "react";
|
||||||
import { Suspense } from "react";
|
import type {
|
||||||
import Hero from "../Hero";
|
PortfolioExperience,
|
||||||
import { SectionLoading } from "../SectionLoading";
|
PortfolioProjectView,
|
||||||
|
PortfolioSkillCategory,
|
||||||
|
} from "@/lib/portfolio-data";
|
||||||
|
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 Skills from "../Skills";
|
||||||
|
import { WorkModalities } from "../WorkModalities";
|
||||||
|
import ExperienceDisclosure from "./ExperienceDisclosure";
|
||||||
|
import { useCinematicScroll } from "./useCinematicScroll";
|
||||||
|
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||||
|
|
||||||
const About = dynamic(() => import("../About"), { loading: () => <SectionLoading message="Carregando..." /> });
|
export default function CinematicHome({
|
||||||
const Skills = dynamic(() => import("../Skills"), { loading: () => <SectionLoading message="Carregando..." /> });
|
projects,
|
||||||
const Experience = dynamic(() => import("../Experience"), { loading: () => <SectionLoading message="Carregando..." /> });
|
experiences,
|
||||||
const WorkModalities = dynamic(() => import("../WorkModalities").then(mod => mod.WorkModalities), { loading: () => <SectionLoading message="Carregando..." /> });
|
skillCategories,
|
||||||
const Contact = dynamic(() => import("../Contact"), { loading: () => <SectionLoading message="Carregando..." /> });
|
heroStats,
|
||||||
const Projects = dynamic(() => import("../Projects"));
|
}: {
|
||||||
const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics"), { loading: () => <SectionLoading message="Carregando..." /> });
|
heroStats?: HeroStats;
|
||||||
const Footer = dynamic(() => import("../Footer"));
|
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]);
|
||||||
|
|
||||||
const CinematicSection = dynamic(() => import("./CinematicSection"), { ssr: false });
|
|
||||||
const ScrollVelocityText = dynamic(() => import("./ScrollVelocityText"), { ssr: false });
|
|
||||||
const SectionDivider = dynamic(() => import("./SectionDivider"), { ssr: false });
|
|
||||||
|
|
||||||
export default function CinematicHome() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col overflow-x-hidden">
|
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||||
<main className="ds-main">
|
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. */}
|
||||||
<Hero />
|
<main ref={mainRef} className="ds-main relative z-0">
|
||||||
|
<Hero stats={heroStats} />
|
||||||
|
|
||||||
{/* Marquee divider after hero */}
|
<Projects projects={projects} />
|
||||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
|
|
||||||
<ScrollVelocityText
|
|
||||||
text="SOFTWARE ENGINEERING"
|
|
||||||
baseVelocity={-1.5}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CinematicSection effect="parallax-depth">
|
|
||||||
<About />
|
|
||||||
</CinematicSection>
|
|
||||||
|
|
||||||
<SectionDivider variant="waveform" />
|
|
||||||
|
|
||||||
<CinematicSection effect="clip-reveal">
|
|
||||||
<Skills />
|
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CinematicSection effect="fade-scale">
|
|
||||||
<Experience />
|
|
||||||
</CinematicSection>
|
|
||||||
|
|
||||||
<SectionDivider variant="gradient" />
|
|
||||||
|
|
||||||
<CinematicSection effect="rotate-in">
|
|
||||||
<Suspense
|
|
||||||
fallback={<SectionLoading message="Carregando projetos..." />}
|
|
||||||
>
|
|
||||||
<Projects />
|
|
||||||
</Suspense>
|
|
||||||
</CinematicSection>
|
|
||||||
|
|
||||||
<CinematicSection effect="clip-reveal">
|
|
||||||
<LighthouseMetrics />
|
|
||||||
</CinematicSection>
|
|
||||||
|
|
||||||
<SectionDivider variant="pulse" />
|
|
||||||
|
|
||||||
<CinematicSection effect="parallax-depth">
|
|
||||||
<WorkModalities />
|
<WorkModalities />
|
||||||
</CinematicSection>
|
|
||||||
|
|
||||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
|
<EngineeringWithAI />
|
||||||
<ScrollVelocityText
|
|
||||||
text="VAMOS CONVERSAR"
|
<LighthouseMetrics />
|
||||||
baseVelocity={-2}
|
|
||||||
/>
|
<div className="bg-[var(--paper)]">
|
||||||
|
<About />
|
||||||
|
|
||||||
|
<ExperienceDisclosure>
|
||||||
|
<Skills categories={skillCategories} />
|
||||||
|
<Experience experiences={experiences} />
|
||||||
|
</ExperienceDisclosure>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CinematicSection effect="fade-scale">
|
|
||||||
<Contact />
|
<Contact />
|
||||||
</CinematicSection>
|
|
||||||
</main>
|
</main>
|
||||||
|
<div className="relative z-10">
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
|
|
||||||
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
|
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
|
||||||
const PageIntro = dynamic(() => import("./PageIntro"), { ssr: false });
|
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
|
||||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false });
|
ssr: false,
|
||||||
const HomeButton = dynamic(() => import("./HomeButton"), { ssr: false });
|
});
|
||||||
|
|
||||||
export default function CinematicOverlays() {
|
export default function CinematicOverlays() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isHome = pathname === "/";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageIntro />
|
{isHome ? (
|
||||||
<CustomCursor />
|
<>
|
||||||
<FilmGrain />
|
<FilmGrain />
|
||||||
<ScrollProgress />
|
<ScrollProgress />
|
||||||
<HomeButton />
|
</>
|
||||||
|
) : 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
|
||||||
import { motion, useMotionValue, useSpring } 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 springConfig = { damping: 25, stiffness: 250, mass: 0.5 };
|
|
||||||
const x = useSpring(cursorX, springConfig);
|
|
||||||
const y = useSpring(cursorY, springConfig);
|
|
||||||
|
|
||||||
const ringSpringConfig = { damping: 20, stiffness: 150, mass: 0.8 };
|
|
||||||
const ringX = useSpring(cursorX, ringSpringConfig);
|
|
||||||
const ringY = useSpring(cursorY, ringSpringConfig);
|
|
||||||
|
|
||||||
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,
|
|
||||||
y,
|
|
||||||
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: ringX,
|
|
||||||
y: ringY,
|
|
||||||
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,65 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
export default function FilmGrain() {
|
export default function FilmGrain() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
return <div aria-hidden="true" className="film-grain" />;
|
||||||
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
|
const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,180 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useMemo, useCallback, useEffect } from "react";
|
|
||||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||||
import { Float, MeshDistortMaterial } from "@react-three/drei";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
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() {
|
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
|
||||||
const mesh2Ref = useRef<THREE.Mesh>(null);
|
|
||||||
const mesh3Ref = useRef<THREE.Mesh>(null);
|
|
||||||
|
|
||||||
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 = 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 = 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={[4, 1.5, -3]} scale={1.2}>
|
|
||||||
<icosahedronGeometry args={[1, 1]} />
|
|
||||||
<MeshDistortMaterial
|
|
||||||
color="#ccff00"
|
|
||||||
wireframe
|
|
||||||
transparent
|
|
||||||
opacity={0.15}
|
|
||||||
distort={0.3}
|
|
||||||
speed={2}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
</Float>
|
|
||||||
|
|
||||||
<Float speed={1.2} rotationIntensity={0.3} floatIntensity={0.8}>
|
|
||||||
<mesh ref={mesh2Ref} position={[-5, -2, -4]} scale={0.8}>
|
|
||||||
<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={[2, -3, -5]} scale={1.5}>
|
|
||||||
<torusGeometry args={[1, 0.3, 16, 32]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#ccff00"
|
|
||||||
wireframe
|
|
||||||
transparent
|
|
||||||
opacity={0.06}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
</Float>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GridFloor() {
|
function GridFloor() {
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
@@ -226,7 +55,7 @@ function GridFloor() {
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
}),
|
}),
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -266,7 +95,11 @@ function CameraRig() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeroScene() {
|
export default function HeroScene({
|
||||||
|
isVisible = true,
|
||||||
|
}: {
|
||||||
|
isVisible?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-0"
|
className="absolute inset-0 z-0"
|
||||||
@@ -275,6 +108,7 @@ export default function HeroScene() {
|
|||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
|
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
|
frameloop={isVisible ? "always" : "never"}
|
||||||
gl={{
|
gl={{
|
||||||
antialias: false,
|
antialias: false,
|
||||||
alpha: true,
|
alpha: true,
|
||||||
@@ -290,14 +124,16 @@ export default function HeroScene() {
|
|||||||
display: "block",
|
display: "block",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
|
<fog attach="fog" args={["#0a0a0a", 8, 30]} />
|
||||||
<ambientLight intensity={0.3} />
|
<ambientLight intensity={0.25} />
|
||||||
<directionalLight position={[5, 5, 5]} intensity={0.5} color="#ccff00" />
|
<directionalLight
|
||||||
<pointLight position={[-5, 3, -5]} intensity={0.3} color="#ffffff" />
|
position={[5, 5, 5]}
|
||||||
|
intensity={0.4}
|
||||||
|
color="#ccff00"
|
||||||
|
/>
|
||||||
|
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
|
||||||
|
|
||||||
<CameraRig />
|
<CameraRig />
|
||||||
<ParticleField count={1500} />
|
|
||||||
<FloatingGeometry />
|
|
||||||
<GridFloor />
|
<GridFloor />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, type ReactNode } from "react";
|
import { motion } from "motion/react";
|
||||||
import { motion } from "framer-motion";
|
import { type MouseEvent, type ReactNode, useState } from "react";
|
||||||
|
|
||||||
interface MagneticButtonProps {
|
interface MagneticButtonProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -13,7 +13,6 @@ interface MagneticButtonProps {
|
|||||||
target?: string;
|
target?: string;
|
||||||
rel?: string;
|
rel?: string;
|
||||||
download?: boolean | string;
|
download?: boolean | string;
|
||||||
"data-cursor"?: string;
|
|
||||||
type?: "button" | "submit" | "reset";
|
type?: "button" | "submit" | "reset";
|
||||||
wrapperClassName?: string;
|
wrapperClassName?: string;
|
||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
@@ -29,19 +28,14 @@ export default function MagneticButton({
|
|||||||
target,
|
target,
|
||||||
rel,
|
rel,
|
||||||
download,
|
download,
|
||||||
"data-cursor": dataCursor,
|
|
||||||
type,
|
type,
|
||||||
wrapperClassName = "",
|
wrapperClassName = "",
|
||||||
"aria-label": ariaLabel,
|
"aria-label": ariaLabel,
|
||||||
}: MagneticButtonProps) {
|
}: MagneticButtonProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
const handleMouse = (e: React.MouseEvent) => {
|
const handleMouse = (e: MouseEvent<HTMLElement>) => {
|
||||||
const el = ref.current;
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const centerX = rect.left + rect.width / 2;
|
const centerX = rect.left + rect.width / 2;
|
||||||
const centerY = rect.top + rect.height / 2;
|
const centerY = rect.top + rect.height / 2;
|
||||||
|
|
||||||
@@ -55,31 +49,57 @@ export default function MagneticButton({
|
|||||||
setPosition({ x: 0, y: 0 });
|
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 linkProps = as === "a" ? { href, target, rel, download } : {};
|
||||||
const buttonProps = as === "button" ? { type } : {};
|
const buttonProps = as === "button" ? { type } : {};
|
||||||
|
|
||||||
return (
|
const motionProps = {
|
||||||
<div
|
animate: {
|
||||||
ref={ref}
|
|
||||||
onMouseMove={handleMouse}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
className={`inline-block ${wrapperClassName}`.trim()}
|
|
||||||
>
|
|
||||||
<Tag
|
|
||||||
className={className}
|
|
||||||
onClick={onClick}
|
|
||||||
data-cursor={dataCursor}
|
|
||||||
animate={{
|
|
||||||
x: position.x,
|
x: position.x,
|
||||||
y: position.y,
|
y: position.y,
|
||||||
}}
|
},
|
||||||
transition={{
|
transition: {
|
||||||
type: "spring",
|
type: "spring" as const,
|
||||||
stiffness: 350,
|
stiffness: 350,
|
||||||
damping: 15,
|
damping: 15,
|
||||||
mass: 0.5,
|
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 className={`inline-block ${wrapperClassName}`.trim()}>
|
||||||
|
<Tag
|
||||||
|
className={className}
|
||||||
|
onMouseMove={handleMouse}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onClick={onClick}
|
||||||
|
{...motionProps}
|
||||||
{...linkProps}
|
{...linkProps}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
|
|
||||||
const INTRO_LINES = [
|
|
||||||
"EVANDRO",
|
|
||||||
"FONSECA",
|
|
||||||
"JUNIOR",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PageIntro() {
|
|
||||||
const [isVisible, setIsVisible] = useState(true);
|
|
||||||
const [counter, setCounter] = useState(0);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
// Remove the immediate blocking overlay now that the animated loader is ready
|
|
||||||
const blocker = document.getElementById("__loading-blocker");
|
|
||||||
if (blocker) blocker.remove();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
// Animate counter from 0 to 100
|
|
||||||
const duration = 2000;
|
|
||||||
const start = performance.now();
|
|
||||||
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const elapsed = now - start;
|
|
||||||
const progress = Math.min(elapsed / duration, 1);
|
|
||||||
// Eased progress
|
|
||||||
const eased = 1 - Math.pow(1 - progress, 3);
|
|
||||||
setCounter(Math.floor(eased * 100));
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
} else {
|
|
||||||
// Wait a beat then hide
|
|
||||||
setTimeout(() => setIsVisible(false), 400);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
}, [mounted]);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<AnimatePresence>
|
|
||||||
{isVisible && (
|
|
||||||
<motion.div
|
|
||||||
className="fixed inset-0 z-[99999] flex items-center justify-center bg-[#0a0a0a]"
|
|
||||||
style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, width: "100vw", height: "100vh" }}
|
|
||||||
exit={{
|
|
||||||
clipPath: "inset(0 0 100% 0)",
|
|
||||||
transition: { duration: 1, ease: [0.76, 0, 0.24, 1] },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Horizontal lines decoration */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
className="absolute left-0 h-px w-full bg-[rgba(204,255,0,0.06)]"
|
|
||||||
style={{ top: `${20 + i * 15}%` }}
|
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: 1 }}
|
|
||||||
transition={{
|
|
||||||
duration: 1.5,
|
|
||||||
delay: 0.1 * i,
|
|
||||||
ease: [0.76, 0, 0.24, 1],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Counter */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute bottom-12 right-12 font-mono text-[clamp(48px,8vw,96px)] font-bold leading-none tracking-tighter text-[rgba(204,255,0,0.15)]"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
{counter.toString().padStart(3, "0")}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="absolute bottom-8 left-12 right-12 h-px bg-[rgba(255,255,255,0.06)]">
|
|
||||||
<motion.div
|
|
||||||
className="h-full bg-[var(--slash)]"
|
|
||||||
initial={{ width: "0%" }}
|
|
||||||
animate={{ width: `${counter}%` }}
|
|
||||||
transition={{ duration: 0.1 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name reveal */}
|
|
||||||
<div className="relative flex flex-col items-center gap-0">
|
|
||||||
{INTRO_LINES.map((line, i) => (
|
|
||||||
<div key={line} className="overflow-hidden">
|
|
||||||
<motion.div
|
|
||||||
className="font-display text-[clamp(2.5rem,10vw,7rem)] font-extrabold leading-[0.9] tracking-[-0.06em] text-[#f4f2ed]"
|
|
||||||
initial={{ y: "100%", rotateX: -40 }}
|
|
||||||
animate={{ y: "0%", rotateX: 0 }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.8,
|
|
||||||
delay: 0.3 + i * 0.12,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{line}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Subtitle */}
|
|
||||||
<motion.div
|
|
||||||
className="mt-6 overflow-hidden"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 1, duration: 0.6 }}
|
|
||||||
>
|
|
||||||
<motion.p
|
|
||||||
className="font-mono text-[10px] uppercase tracking-[0.3em] text-[rgba(204,255,0,0.7)]"
|
|
||||||
initial={{ y: 20 }}
|
|
||||||
animate={{ y: 0 }}
|
|
||||||
transition={{ delay: 1, duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
>
|
|
||||||
Software Engineer
|
|
||||||
</motion.p>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Corner markers */}
|
|
||||||
<div className="pointer-events-none absolute inset-0">
|
|
||||||
{/* Top-left */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute left-8 top-8"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 0.3 }}
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
>
|
|
||||||
<div className="h-6 w-px bg-[var(--slash)]" />
|
|
||||||
<div className="absolute left-0 top-0 h-px w-6 bg-[var(--slash)]" />
|
|
||||||
</motion.div>
|
|
||||||
{/* Top-right */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute right-8 top-8"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 0.3 }}
|
|
||||||
transition={{ delay: 0.6 }}
|
|
||||||
>
|
|
||||||
<div className="absolute right-0 top-0 h-6 w-px bg-[var(--slash)]" />
|
|
||||||
<div className="absolute right-0 top-0 h-px w-6 bg-[var(--slash)]" />
|
|
||||||
</motion.div>
|
|
||||||
{/* Bottom-left */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute bottom-8 left-8"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 0.3 }}
|
|
||||||
transition={{ delay: 0.7 }}
|
|
||||||
>
|
|
||||||
<div className="h-6 w-px bg-[var(--slash)]" />
|
|
||||||
<div className="absolute bottom-0 left-0 h-px w-6 bg-[var(--slash)]" />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useScroll, useSpring, useTransform } from "framer-motion";
|
import { motion, useScroll, useSpring, useTransform } from "motion/react";
|
||||||
|
|
||||||
export default function ScrollProgress() {
|
export default function ScrollProgress() {
|
||||||
const { scrollYProgress } = useScroll();
|
const { scrollYProgress } = useScroll();
|
||||||
@@ -10,7 +10,11 @@ export default function ScrollProgress() {
|
|||||||
restDelta: 0.001,
|
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 (
|
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)]">•</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/components/ui/NumberIcons.tsx
|
// src/components/ui/NumberIcons.tsx
|
||||||
import React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
interface NumberIconProps extends React.SVGProps<SVGSVGElement> {
|
interface NumberIconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
/** Tamanho em pixels. Escala mantendo o viewBox 80x80 */
|
/** Tamanho em pixels. Escala mantendo o viewBox 80x80 */
|
||||||
@@ -65,7 +65,7 @@ export const NumberOne = ({
|
|||||||
<text
|
<text
|
||||||
x="40"
|
x="40"
|
||||||
y="54"
|
y="54"
|
||||||
fontFamily="Syne, sans-serif"
|
fontFamily="Space Grotesk, sans-serif"
|
||||||
fontSize="40"
|
fontSize="40"
|
||||||
fontWeight="800"
|
fontWeight="800"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
@@ -94,7 +94,7 @@ export const NumberTwo = ({
|
|||||||
<text
|
<text
|
||||||
x="40"
|
x="40"
|
||||||
y="54"
|
y="54"
|
||||||
fontFamily="Syne, sans-serif"
|
fontFamily="Space Grotesk, sans-serif"
|
||||||
fontSize="40"
|
fontSize="40"
|
||||||
fontWeight="800"
|
fontWeight="800"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
@@ -123,7 +123,7 @@ export const NumberThree = ({
|
|||||||
<text
|
<text
|
||||||
x="40"
|
x="40"
|
||||||
y="54"
|
y="54"
|
||||||
fontFamily="Syne, sans-serif"
|
fontFamily="Space Grotesk, sans-serif"
|
||||||
fontSize="40"
|
fontSize="40"
|
||||||
fontWeight="800"
|
fontWeight="800"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
@@ -152,7 +152,7 @@ export const NumberFour = ({
|
|||||||
<text
|
<text
|
||||||
x="40"
|
x="40"
|
||||||
y="54"
|
y="54"
|
||||||
fontFamily="Syne, sans-serif"
|
fontFamily="Space Grotesk, sans-serif"
|
||||||
fontSize="40"
|
fontSize="40"
|
||||||
fontWeight="800"
|
fontWeight="800"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||||
|
|
||||||
|
export const alt =
|
||||||
|
"Perfil profissional de Evandro Fonseca Junior em engenharia de software";
|
||||||
|
export const size = OG_SIZE;
|
||||||
|
export const contentType = OG_CONTENT_TYPE;
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
return new ImageResponse(
|
||||||
|
<OgCard
|
||||||
|
eyebrow="Perfil profissional"
|
||||||
|
title="Evandro Fonseca Junior"
|
||||||
|
description="Engenharia da computação e desenvolvimento de software."
|
||||||
|
/>,
|
||||||
|
OG_SIZE,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import CurriculoPage from "./page";
|
||||||
|
|
||||||
|
it("shows a matching breadcrumb and keeps consulting secondary to hiring", () => {
|
||||||
|
render(CurriculoPage());
|
||||||
|
|
||||||
|
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
|
||||||
|
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
|
||||||
|
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
|
||||||
|
"Início",
|
||||||
|
"Currículo",
|
||||||
|
]);
|
||||||
|
expect(breadcrumbLinks[0]).toHaveAttribute("href", "/");
|
||||||
|
expect(breadcrumbLinks[1]).toHaveAttribute("href", "/curriculo");
|
||||||
|
expect(breadcrumbLinks[1]).toHaveAttribute("aria-current", "page");
|
||||||
|
|
||||||
|
const hiringLink = screen.getByRole("link", { name: /oportunidade/i });
|
||||||
|
const consultingLink = screen.getByRole("link", { name: /consultoria/i });
|
||||||
|
expect(hiringLink).toHaveAttribute("href", "/#contact");
|
||||||
|
expect(consultingLink).toHaveAttribute("href", "/servicos");
|
||||||
|
expect(
|
||||||
|
hiringLink.compareDocumentPosition(consultingLink) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
|
import { portfolio } from "@/lib/portfolio-data";
|
||||||
|
import { buildBreadcrumbList, buildProfilePageJsonLd } from "@/lib/seo";
|
||||||
|
import { siteName, siteUrl } from "@/lib/site-content";
|
||||||
|
|
||||||
|
const pageUrl = `${siteUrl}/curriculo`;
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: { absolute: `Currículo de Evandro Fonseca Junior | ${siteName}` },
|
||||||
|
description:
|
||||||
|
"Currículo profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software, com experiência, formação e competências técnicas.",
|
||||||
|
alternates: { canonical: pageUrl },
|
||||||
|
openGraph: {
|
||||||
|
locale: "pt_BR",
|
||||||
|
title: `Currículo de Evandro Fonseca Junior | ${siteName}`,
|
||||||
|
description:
|
||||||
|
"Experiência profissional, formação e competências técnicas de Evandro Fonseca Junior para avaliação de contratação.",
|
||||||
|
url: pageUrl,
|
||||||
|
siteName,
|
||||||
|
type: "profile",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CurriculoPage() {
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
buildProfilePageJsonLd(),
|
||||||
|
buildBreadcrumbList(pageUrl, [
|
||||||
|
["Início", siteUrl],
|
||||||
|
["Currículo", pageUrl],
|
||||||
|
] as const),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||||
|
<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
|
||||||
|
aria-current="page"
|
||||||
|
className="ds-link ds-link-muted"
|
||||||
|
href="/curriculo"
|
||||||
|
>
|
||||||
|
Currículo
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<p className="ds-eyebrow">Currículo profissional</p>
|
||||||
|
<h1 className="ds-section-title text-white">
|
||||||
|
Engenheiro da Computação e Desenvolvedor de Software.
|
||||||
|
</h1>
|
||||||
|
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
|
||||||
|
Base em Garuva, atendimento a projetos no Norte de Santa Catarina e
|
||||||
|
atuação remota. Este currículo reúne experiência profissional,
|
||||||
|
formação e competências técnicas para avaliação de contratação.
|
||||||
|
</p>
|
||||||
|
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||||
|
<a className="ds-link ds-link-accent" href="/Profile.pdf" download>
|
||||||
|
Baixar currículo em PDF
|
||||||
|
</a>
|
||||||
|
<Link className="ds-link ds-link-muted" href="/#contact">
|
||||||
|
Falar sobre uma oportunidade
|
||||||
|
</Link>
|
||||||
|
<Link className="ds-link ds-link-muted" href="/servicos">
|
||||||
|
Procurando consultoria? Ver serviços
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ds-section">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<h2 className="ds-section-title">Experiência profissional</h2>
|
||||||
|
<div className="mt-sp-8 space-y-sp-6">
|
||||||
|
{portfolio.experiences.map((experience) => (
|
||||||
|
<article key={experience.id} className="ds-card">
|
||||||
|
<p className="ds-kicker">
|
||||||
|
{experience.period} · {experience.location}
|
||||||
|
</p>
|
||||||
|
<h3 className="ds-card-title">
|
||||||
|
{experience.title} — {experience.company}
|
||||||
|
</h3>
|
||||||
|
<p className="ds-copy">{experience.description}</p>
|
||||||
|
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
|
||||||
|
{experience.achievements.map((achievement) => (
|
||||||
|
<li key={achievement}>{achievement}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<ul className="ds-tags mt-sp-6">
|
||||||
|
{experience.technologies.map((technology) => (
|
||||||
|
<li key={technology} className="ds-tag">
|
||||||
|
{technology}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ds-section">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<h2 className="ds-section-title">Competências técnicas</h2>
|
||||||
|
<div className="ds-grid-2 mt-sp-8">
|
||||||
|
{portfolio.skills.technical.map((category) => (
|
||||||
|
<article key={category.category} className="ds-card">
|
||||||
|
<h3 className="ds-card-title">{category.category}</h3>
|
||||||
|
<ul className="ds-tags mt-sp-5">
|
||||||
|
{category.skills.map((skill) => (
|
||||||
|
<li key={skill} className="ds-tag">
|
||||||
|
{skill}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-sp-8">
|
||||||
|
<Link className="ds-link ds-link-accent" href="/cases">
|
||||||
|
Ver projetos e cases
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 13 KiB |
+1358
-208
File diff suppressed because it is too large.
Load diff
@@ -2,14 +2,14 @@ import { renderHook } from "@testing-library/react";
|
|||||||
import { useReducedMotion } from "./useReducedMotion";
|
import { useReducedMotion } from "./useReducedMotion";
|
||||||
|
|
||||||
// Mock do Framer Motion
|
// Mock do Framer Motion
|
||||||
jest.mock("framer-motion", () => ({
|
jest.mock("motion/react", () => ({
|
||||||
useReducedMotion: jest.fn(),
|
useReducedMotion: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("useReducedMotion", () => {
|
describe("useReducedMotion", () => {
|
||||||
it("deve retornar false quando prefers-reduced-motion não está ativo", () => {
|
it("deve retornar false quando prefers-reduced-motion não está ativo", () => {
|
||||||
const { useReducedMotion: mockUseReducedMotion } =
|
const { useReducedMotion: mockUseReducedMotion } =
|
||||||
jest.requireMock("framer-motion");
|
jest.requireMock("motion/react");
|
||||||
mockUseReducedMotion.mockReturnValue(false);
|
mockUseReducedMotion.mockReturnValue(false);
|
||||||
|
|
||||||
const { result } = renderHook(() => useReducedMotion());
|
const { result } = renderHook(() => useReducedMotion());
|
||||||
@@ -19,7 +19,7 @@ describe("useReducedMotion", () => {
|
|||||||
|
|
||||||
it("deve retornar true quando prefers-reduced-motion está ativo", () => {
|
it("deve retornar true quando prefers-reduced-motion está ativo", () => {
|
||||||
const { useReducedMotion: mockUseReducedMotion } =
|
const { useReducedMotion: mockUseReducedMotion } =
|
||||||
jest.requireMock("framer-motion");
|
jest.requireMock("motion/react");
|
||||||
mockUseReducedMotion.mockReturnValue(true);
|
mockUseReducedMotion.mockReturnValue(true);
|
||||||
|
|
||||||
const { result } = renderHook(() => useReducedMotion());
|
const { result } = renderHook(() => useReducedMotion());
|
||||||
@@ -29,7 +29,7 @@ describe("useReducedMotion", () => {
|
|||||||
|
|
||||||
it("deve retornar false quando o hook do Framer Motion retorna null", () => {
|
it("deve retornar false quando o hook do Framer Motion retorna null", () => {
|
||||||
const { useReducedMotion: mockUseReducedMotion } =
|
const { useReducedMotion: mockUseReducedMotion } =
|
||||||
jest.requireMock("framer-motion");
|
jest.requireMock("motion/react");
|
||||||
mockUseReducedMotion.mockReturnValue(null);
|
mockUseReducedMotion.mockReturnValue(null);
|
||||||
|
|
||||||
const { result } = renderHook(() => useReducedMotion());
|
const { result } = renderHook(() => useReducedMotion());
|
||||||
@@ -39,7 +39,7 @@ describe("useReducedMotion", () => {
|
|||||||
|
|
||||||
it("deve retornar false quando o hook do Framer Motion retorna undefined", () => {
|
it("deve retornar false quando o hook do Framer Motion retorna undefined", () => {
|
||||||
const { useReducedMotion: mockUseReducedMotion } =
|
const { useReducedMotion: mockUseReducedMotion } =
|
||||||
jest.requireMock("framer-motion");
|
jest.requireMock("motion/react");
|
||||||
mockUseReducedMotion.mockReturnValue(undefined);
|
mockUseReducedMotion.mockReturnValue(undefined);
|
||||||
|
|
||||||
const { result } = renderHook(() => useReducedMotion());
|
const { result } = renderHook(() => useReducedMotion());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useReducedMotion as useFramerReducedMotion } from "framer-motion";
|
import { useReducedMotion as useFramerReducedMotion } from "motion/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
/**
|
/**
|
||||||
* Hook que detecta a preferência prefers-reduced-motion do sistema operacional.
|
* Hook que detecta a preferência prefers-reduced-motion do sistema operacional.
|
||||||
* Wrapper sobre o hook useReducedMotion do Framer Motion.
|
* Wrapper sobre o hook useReducedMotion do Framer Motion.
|
||||||
@@ -20,5 +20,12 @@ import { useReducedMotion as useFramerReducedMotion } from "framer-motion";
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useReducedMotion(): boolean {
|
export function useReducedMotion(): boolean {
|
||||||
return useFramerReducedMotion() ?? false;
|
const systemPreference = useFramerReducedMotion();
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMounted && systemPreference === true;
|
||||||
}
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { gsap } from "gsap";
|
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
||||||
|
|
||||||
export function useScrollReveal(options?: {
|
|
||||||
y?: number;
|
|
||||||
duration?: number;
|
|
||||||
delay?: number;
|
|
||||||
ease?: string;
|
|
||||||
start?: string;
|
|
||||||
end?: string;
|
|
||||||
scrub?: boolean | number;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
|
|
||||||
// Lazy registration to avoid Next.js hydration mismatch issues
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
gsap.fromTo(
|
|
||||||
ref.current,
|
|
||||||
{ y: options?.y ?? 60, opacity: 0 },
|
|
||||||
{
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
duration: options?.duration ?? 0.9,
|
|
||||||
delay: options?.delay ?? 0,
|
|
||||||
ease: options?.ease ?? "power3.out",
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: ref.current,
|
|
||||||
start: options?.start ?? "top 85%",
|
|
||||||
end: options?.end ?? "top 50%",
|
|
||||||
scrub: options?.scrub ?? false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, ref);
|
|
||||||
|
|
||||||
return () => ctx.revert();
|
|
||||||
}, [
|
|
||||||
options?.y,
|
|
||||||
options?.duration,
|
|
||||||
options?.delay,
|
|
||||||
options?.ease,
|
|
||||||
options?.start,
|
|
||||||
options?.end,
|
|
||||||
options?.scrub,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
|
||||||
<defs>
|
|
||||||
<image width="978" height="981" id="img1" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA9IAAAPVCAYAAACKltJmAAAAAXNSR0IB2cksfwAEfEJJREFUeJzsvXm0Jkd1Jxj5vVf1ql5tUpV2CUlVkkAyCNk0NqoqYQw2Mw2a6WamZzxeMCCVBO0+42PPZp85Ph77jGfc03+224dxj+0zAwZj9+nFxjYYA8YsAiQWIwHaN1Sral9e1au35mRkZmRGRN5YMyK/zO+7Pykrv5cZcSMyMpb7i3sjMiEIBALRExw48MA/zU7vT5Lkv9q8eTOhx9zcHJmZmSGzs7Mku07SNM3Djkaj/O/iWpJfY3/TYz1dz440D7++vl7FY2B/s3OSitdllElU4YSwSZ54I35SXuPBXxPCAumzsGl+pHVSJBHuF7fS8lzklS+L8kmJ/GQJ/Khg/qpyouU+KssYKFcxX3WeoGcR4lRPpS5X+V1VYcvnJWX9YMeIlQFJRJl5BPWzcleztAyFJMgQ62Hzfv0mmFRVfSCa+6r3lkpJlrkpi7HOU94uoHcgXRLaU/au2fumdWA0Sqp3IzwbyyNQB6D3Wgso/0nFvPI5rWSTug3w9SKX2Sx2I7TlatG2Vc9enQG58rsqBTTaC8nLgjTLS4EkkfJSlVaiLNc6/TIPpH4HNn2Hqb1CKNrmKI8itG1DexCyK5U7X1Z8PyA8t6HPFZ6Ry0sCPFf9jkijjlR1lt1OxarJ1xFZJj/OVeeqOTXroPx8pvpoKmvbcQsKV1xX10EoP7q0qjSBoFTeCHivVRzCjZtcuZrqJjgGK8onSZvX+LzyZcAuC/WmHItUY0adJsn7XL5PHo1myEwyI+hCqryy/nt1dZVcvnyZLC0tZedFcnnpMlnMzouLxbGysvJcFvwj9PhP/+HPDykzhKjgMeQgEAhEHCCRRiItpItEGok0Emkk0gogkUYijUQaifS4gUQagUBExa/92q9ty07X0CPD6LrrriPXX389/YNce+21ZNu2bTlRpscnP/kXbz148OC7Ll68+OOUQM/NbSQbNmyo7hddljgwFoMjIYSISkVOoEvCAA2caaW0Mm2KNO/XYqWbUjimwBFRZJOvSRc06WuSrIhRMwAXKjCRFuSO1MpyUoap8iQpXVBeeMInpMuIUVrG0JUXH7Z87yPSVE64qgI8MCQ4Nb6bxpNoFCJB0eb0qSqJZsGA9+UUVFnk64pAosBnJVIVEol0RUxKhU4gd/w7atRxUtUD+dl0aYuX0ur1NJ5dniSxRiqUKxhTkMlNgFT/ply1SevHTzjiAKYM56dRBoqJHzCb+Q++fRS5qYiMJEvIAzeRUaXNCW6GpbcYYWpkoup3VXlNEp5aNJV/E4mW43CR8wQEctrod/kOXJ9Go/9o1GOxnJjMVKor0CQVO0NEmqXF95RQVtmzsTM/rlXtRZ6IabwvxTUpjTqccoBj01uC2Lp/b05MQHAh0onQOfKpSpeTpGqtaTkuaV+93I9J1xL+mpRnmEgT+PmTOm+qybKqPym7XNZ2KIEe0cmoZMTVp2ZXy/JBD0qkM7JMlpeXi2NliSxlZ0qsl7MjE3vwql1X/8299+799J7dtx2/cOECWVhYIOfOnSXnz58jp06dIocPHyaHDh2ixPt8Jvo4Pf7oj/7IepScNCCRRiAQUZER6d3ZaS89MvI8pyPSR48eueX48eOvzTrvW2dmZsns7Ew+61rMxI5qfpbWA0oNcZBK2UBZDZiikgpd00FQDZiuypPESuEuBmpOpxR+cMGs8lAQ4bQKlkj36myJJFq+ryNaPIwKLK+MCwRXvA0lWj2LUrSkQPOFRYhYrnx+K0WEFQMrrEQxyEGC6P/ixcS2clR50SuIXFJEramJOVDeNwUtLzYuc4pnU/8UyQx/Fi2/7HqDPXBEUsquioAAeQAnP4S/gEmXSi4ossmxhWorymxAJhygnFS43yQf+jyC4UiT0OqRAr+Txr8qgay/FNK1Sqv4OyVineC9aGQoW6bBMgqFkwQT1hHwIXjCBbY8eULQkLa+jhZ/i0Qalm8zaQDQQiKMd+KfEpHi6rYw7jTbqdB2QUCstdlHQdUeet8uVu48TIO0Kx+aQLPECanHnzq4rjGq+l+uNQETEGn5t1CXpRw3nlNf8PV8TcLJSWpPk1qmOEnK60z0YJbptbW18lglq+VvSrJnZ2bPXblz53M3XH/js5m8iwYiTa3XX82Or2VEel2Z+QkHEmkEAhEVGZH+0ez03uz4hYw8b9ER6QyjbPAZZR39yIaQBIVmRr4RFHCd07kkqp7FpEB5uf0GKDdjWnCsgp8qLDidv08teDWXQSaG0wfTu2r7LmPVBZ98m+q4T/ohZLUDVK8hJNKZk+BEpJttSJ6MglPRSO1lf2EGRKTZGSLSFC7PONRycQE0QSHcNwvg5ufs2kFz8tQRCiItTxgz0h3//aXSWc6Fvo/Mzun6evZ/hkuXFlMDkf5KFuWP6ZER6bUIDzMITG6LRCAQneCXf/mXr85Ou+lxyy23jPbs2UNuvvlmctVVV+XHwYMHb3vppZd+/OzZsz+ekea5K664guzYsaM6Nm3alLsolUR6PLA11xI9+dVZF0B3NRORLiIaZbpYU0zgrVIqecpcQ5Yag3LUPVSKBhJpCqMy20Kht7U2+spUyR0HkW4jzx+2RJoCru/jJtIsD/3qM8yASl2wyLbso6edSNuQaIbEtGapDjl4Ig33PfqJYlP/Xlis18nS0nK+npqtn15cvJSR6gvk9OnT+ZHpbE9fd911X3rLW97ypSNHjqy/8sor5MSJE4uZmJey48X3vve9F8I8Zb8xuS0SgUB0goxIvz47/VR2vDMj0rMykc465h3nzp27cWFh4YYNGzbMFGuf58jGjRvzg20iJiu5rciga3zmCiUNitWfBgWch8sGLtWaU8DxMncNM6Rhs45QlXYjL2LGtHEd5h16iOES6T5ZhF1dYeUwofJi0wZsJ7Fi5KVb4tOeSLdLKwyRNqbcY6INWaYLEuU3iSS3E9c6FqqsXNprDNIfi0i71E7wuVSu3UQk00VKXfQv8PiWMn9zME4znWI99Vp5rBYu4Pn66uVqs7KMSJ/ZunXr4e3btx85dOhQWhLpU5mIz2bH5zIiPRWblfWzJ0IgEL3Eb/zGb2zOTpu3bdu28Q1veAOhx6c//ekfO378+E8vLy//9E033bSBkujsTHbu3Jkf1OLsgrZWK6/4uU7YHKSqnbotyLHrTqi8ezij0Y0hUZF9VyINpS8DItIu1oHhDCZIpLtw/4fCRfGg6BGRjinXMnVi69IqnkOlFZ9Ix/BqCAmdtw6FD4mW47Vpe75wkVX3M/m/rdKtZBoDxCXSyufXEOnid1kWrWm0S9+iaJupLo4qTVLu/cHeZ50XttaaEuwjR47kLt+nTp06tnHjxn939dVX/7sbb7zxhaeffprQIwt3OYu2+Cu/8itL1okPBP3rhRAIRG+REek3ZKc3ZET6FkakFxcXbz127Nibzp87/4+uuPKKmZ1X7iTUfXt+yzzZsmVLvuu2K0IQBneLNPejNCOkijyAm820ItJNi7SKRPOyQhLpXF6dOW2cYRNpCvtyKzD+pwtJPkPBpf6ZENqt2ka268ZWtnkcH9lTEWkoD63VeiCtgphAV0Ohj+1ABlgjHccHPowPSY49WaWW59InWLYnq0BjItJQWE1qbWH3XnXP75YLPjk5KbZxGT3OnTtHzp49m+/knel939q1a9e3FhYWTnJE+uksynczIv2iUwYGgP71QAgEorfIiDT9zvN7MiL9Fkaks9/zq6urO9ZW13ZkpDmhxDn/ZNVssYGY7Lbtgs7XhaWSO1YAFzymCLm4h5tgS6TlPFmT6chE2sU9Pi7SSlEokrSbxXdRLFV12FbZluVA9SkUfKypqnfpswFYWw8UFUKSiH67f6vILX9W58ltwkFNpNldE3lsyjTDFF/Z3sbozQGVRcz23MX6f7V8lcdPI4Z0NqSvyn/jvbCvC9hMpo+fSMepl/K4Fmc85esw+6xWRphXZ2dnz2bHuePHjy9zRPrTWdA/z4j0l6NkZoxAIo1AILQ4fPjg9ux0FT2+/vVH333u3Pn7M6L8ZroW+rbbbss3DJvNP1U124hbuTUNhUi3SNfWIh3Ciucii7douFrUYq2P7g+Rlt0Qm/lK02bZwYpq/q8cGybi6sw0wsppxiLSundiY61VXXedYHB9LiTSgnTSFyKtkjsOIj2W5UIKGbGJtKo9dEukXSzC/FmTvgeRpmL1DtXdEWml/T7SBM84dSd20B2+X3755fyYmZn53Pbt2//69ttv/2IW7CQ9Mh1ysdPMRQISaQQCoUVGpF+bnd6SHfeeP3/hdYuLi3cmyehGfg30KBnlO2+DGHMvY3Lz1t3XDVJW1l1LRdIWshLmQqjbKJBWJJCl4yDXN186uX7yVFYUWNFrkiuTfNkaXRLsRL4Lq322yrFuTaUtGfYvwzrdkJNFujxB6UATRy7PJIeN4UocbyLJj0gbpXoSaXeZdnHkd+zi5uzTl+vqgG97URHpkKRfh7aTF2p59kS6EGue5NVJA6YupTwkfGJSWmV95RNQ8nX7fsCFSCtltOyHQ8K1/2TnS5cu8Tt8P79hw4an5+bmvp/dfjQ7vp4R6aPxct0d+vGWEAhEb5ER6bdmp5+nx6ZNmzfMzW2a3bhx4wwlzvTQzvj2gETn2XAkyvJ9PkxXio4MH6tLDEtMoaKU6StesCmlGES6vUx7YuBGLPhzGSpNG94aRUp2ZFFIwWDhciEZba0YbSeM+PRN+VeRaD6cauLJlL4qrdjutzzC1mH+7CE1ApHm5foSaZ867vNemYVNVa9cnsHmWXy8iGS5NvAl0/rndyPS9LB5VpVEFZFOq23vuLw2ypWSaCBN4JLLe2lLpGNNNPvAyRIvhaVrp4sdv9foTt+ri4uLq0tLS8ey2x+nR0akn4Line truncated
|
|
||||||
</defs>
|
|
||||||
<style>
|
|
||||||
</style>
|
|
||||||
<use id="Background" href="#img1" x="13" y="21"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 383 KiB |
+54
-161
@@ -1,25 +1,24 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist_Mono, Syne } from "next/font/google";
|
import { Geist_Mono, Space_Grotesk } from "next/font/google";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import GoogleAnalytics from "./components/GoogleAnalytics";
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
|
import { buildPersonJsonLd, buildWebSiteJsonLd } from "@/lib/seo";
|
||||||
|
import { siteUrl } from "@/lib/site-content";
|
||||||
import CinematicOverlays from "./components/cinematic/CinematicOverlays";
|
import CinematicOverlays from "./components/cinematic/CinematicOverlays";
|
||||||
import { RouteTransition } from "./components/RouteTransition";
|
import GoogleAnalytics from "./components/GoogleAnalytics";
|
||||||
import { AnimationProvider } from "./providers/AnimationProvider";
|
import { Header } from "./components/Header";
|
||||||
import { SmoothScrollProvider } from "./providers/SmoothScrollProvider";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const siteUrl = "https://evandro.dev.br";
|
const defaultTitle = "Sistemas sob medida para empresas | Evandro.dev.br";
|
||||||
const seoTitle =
|
|
||||||
"Engenharia de Software, Next.js, Tauri, Rust e Consultoria Tecnica | Evandro Fonseca Junior";
|
|
||||||
const seoDescription =
|
const seoDescription =
|
||||||
"Consultoria e engenharia de software para Next.js, Tauri, Rust, IoT e sistemas criticos. Arquitetura de software e integracoes hardware + software para empresas que precisam de estabilidade e escala.";
|
"Sistemas web, aplicativos e integrações sob medida, do negócio à produção, com Evandro Fonseca Junior. Base em Santa Catarina, atendimento remoto.";
|
||||||
|
|
||||||
const syne = Syne({
|
const spaceGrotesk = Space_Grotesk({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500", "600", "700", "800"],
|
weight: ["400", "500", "600", "700"],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
variable: "--font-syne",
|
variable: "--font-space-grotesk",
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const geistMono = Geist_Mono({
|
||||||
@@ -47,24 +46,11 @@ const monumentExtended = localFont({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: seoTitle,
|
title: {
|
||||||
|
default: defaultTitle,
|
||||||
|
template: "%s | Evandro Fonseca Junior",
|
||||||
|
},
|
||||||
description: seoDescription,
|
description: seoDescription,
|
||||||
keywords: [
|
|
||||||
"engenharia de software",
|
|
||||||
"engenheiro de software",
|
|
||||||
"consultoria tecnica",
|
|
||||||
"consultoria next.js",
|
|
||||||
"next.js",
|
|
||||||
"tauri",
|
|
||||||
"rust",
|
|
||||||
"arquitetura de software",
|
|
||||||
"sistemas criticos",
|
|
||||||
"integracao hardware software",
|
|
||||||
"iot",
|
|
||||||
"software para iot",
|
|
||||||
"engenharia full-stack",
|
|
||||||
"aplicacoes de alta performance",
|
|
||||||
],
|
|
||||||
authors: [{ name: "Evandro Fonseca Junior" }],
|
authors: [{ name: "Evandro Fonseca Junior" }],
|
||||||
creator: "Evandro Fonseca Junior",
|
creator: "Evandro Fonseca Junior",
|
||||||
publisher: "Evandro Fonseca Junior",
|
publisher: "Evandro Fonseca Junior",
|
||||||
@@ -80,7 +66,7 @@ export const metadata: Metadata = {
|
|||||||
telephone: false,
|
telephone: false,
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: seoTitle,
|
title: defaultTitle,
|
||||||
description: seoDescription,
|
description: seoDescription,
|
||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
siteName: "Evandro.dev.br",
|
siteName: "Evandro.dev.br",
|
||||||
@@ -91,16 +77,21 @@ export const metadata: Metadata = {
|
|||||||
url: "/opengraph-image",
|
url: "/opengraph-image",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: "Evandro Fonseca Junior | Engenharia de Software, Next.js, Tauri e Rust",
|
alt: "Evandro.dev.br | Sistemas sob medida para empresas",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: seoTitle,
|
title: defaultTitle,
|
||||||
description: seoDescription,
|
description: seoDescription,
|
||||||
creator: "@evandrodevbr",
|
creator: "@evandrodevbr",
|
||||||
images: ["/opengraph-image"],
|
images: [
|
||||||
|
{
|
||||||
|
url: "/opengraph-image",
|
||||||
|
alt: "Evandro.dev.br | Sistemas sob medida para empresas",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
@@ -129,145 +120,47 @@ interface RootLayoutProps {
|
|||||||
export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@graph": [
|
"@graph": [buildWebSiteJsonLd(), buildPersonJsonLd()],
|
||||||
{
|
|
||||||
"@type": "WebSite",
|
|
||||||
"@id": `${siteUrl}/#website`,
|
|
||||||
url: siteUrl,
|
|
||||||
name: "Evandro.dev.br",
|
|
||||||
inLanguage: "pt-BR",
|
|
||||||
description: seoDescription,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Person",
|
|
||||||
"@id": `${siteUrl}/#person`,
|
|
||||||
name: "Evandro Fonseca Junior",
|
|
||||||
alternateName: ["Evandro.dev", "evandrodevbr"],
|
|
||||||
jobTitle: "Engenheiro de Software Full-Stack",
|
|
||||||
description: seoDescription,
|
|
||||||
url: siteUrl,
|
|
||||||
image: `${siteUrl}/opengraph-image`,
|
|
||||||
sameAs: [
|
|
||||||
"https://github.com/evandrodevbr",
|
|
||||||
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
|
||||||
"https://www.instagram.com/evandrodevbr/",
|
|
||||||
],
|
|
||||||
worksFor: {
|
|
||||||
"@type": "Organization",
|
|
||||||
name: "Consultoria Independente",
|
|
||||||
},
|
|
||||||
homeLocation: {
|
|
||||||
"@type": "Place",
|
|
||||||
name: "Garuva, Santa Catarina, Brasil",
|
|
||||||
},
|
|
||||||
knowsAbout: [
|
|
||||||
"Engenharia de Software",
|
|
||||||
"Next.js",
|
|
||||||
"Tauri",
|
|
||||||
"Rust",
|
|
||||||
"Arquitetura de Software",
|
|
||||||
"Sistemas Criticos",
|
|
||||||
"Integracao hardware e software",
|
|
||||||
"IoT",
|
|
||||||
"TypeScript",
|
|
||||||
"Cloud Computing",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "ProfessionalService",
|
|
||||||
"@id": `${siteUrl}/#services`,
|
|
||||||
name: "Evandro Fonseca Junior | Engenharia de Software e Consultoria Tecnica",
|
|
||||||
url: siteUrl,
|
|
||||||
description:
|
|
||||||
"Servico de engenharia de software, arquitetura de software, integracoes criticas e consultoria tecnica para Next.js, Tauri, Rust, IoT e produtos conectados.",
|
|
||||||
areaServed: [
|
|
||||||
{
|
|
||||||
"@type": "Country",
|
|
||||||
name: "Brasil",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Place",
|
|
||||||
name: "Remote worldwide",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
provider: {
|
|
||||||
"@id": `${siteUrl}/#person`,
|
|
||||||
},
|
|
||||||
serviceType: [
|
|
||||||
"Engenharia de Software",
|
|
||||||
"Consultoria Tecnica",
|
|
||||||
"Arquitetura de Software",
|
|
||||||
"Integracao hardware + software",
|
|
||||||
"Desenvolvimento Next.js",
|
|
||||||
"Desenvolvimento Tauri",
|
|
||||||
"Desenvolvimento Rust",
|
|
||||||
"IoT",
|
|
||||||
"Sistemas Criticos",
|
|
||||||
],
|
|
||||||
hasOfferCatalog: {
|
|
||||||
"@type": "OfferCatalog",
|
|
||||||
name: "Modelos de contratacao",
|
|
||||||
itemListElement: [
|
|
||||||
{
|
|
||||||
"@type": "Offer",
|
|
||||||
itemOffered: {
|
|
||||||
"@type": "Service",
|
|
||||||
name: "Auditoria e diagnostico tecnico",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Offer",
|
|
||||||
itemOffered: {
|
|
||||||
"@type": "Service",
|
|
||||||
name: "Ciclos de engenharia",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Offer",
|
|
||||||
itemOffered: {
|
|
||||||
"@type": "Service",
|
|
||||||
name: "Fractional Tech Lead",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="pt-BR"
|
lang="pt-BR"
|
||||||
className={`${syne.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<link rel="canonical" href={siteUrl} />
|
{/* Set arrival policy before native restoration and client-side layout changes. */}
|
||||||
{/* Blocking script: creates immediate black overlay BEFORE any paint.
|
<style id="arrival-scroll-policy">{`html { scroll-behavior: auto !important; }`}</style>
|
||||||
This prevents the flash of real content before React hydrates
|
<script>{`
|
||||||
and PageIntro mounts. PageIntro removes the #__loading-blocker div. */}
|
addEventListener("load", () => {
|
||||||
<script
|
const sheet = document.getElementById("arrival-scroll-policy")?.sheet;
|
||||||
dangerouslySetInnerHTML={{
|
if (sheet) sheet.disabled = true;
|
||||||
__html: `(function(){
|
}, { once: true });
|
||||||
var el=document.createElement('div');
|
if (location.pathname === "/") {
|
||||||
el.id='__loading-blocker';
|
const navigation = performance.getEntriesByType("navigation")[0];
|
||||||
el.style.cssText='position:fixed;inset:0;background:#0a0a0a;z-index:99998;pointer-events:none;';
|
if (navigation?.type !== "back_forward") {
|
||||||
document.documentElement.appendChild(el);
|
history.scrollRestoration = "manual";
|
||||||
})();`,
|
if (navigation?.type === "reload" && location.hash) {
|
||||||
}}
|
history.replaceState(history.state, "", location.pathname + location.search);
|
||||||
/>
|
}
|
||||||
|
addEventListener("pagehide", () => {
|
||||||
|
history.scrollRestoration = "auto";
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<SmoothScrollProvider>
|
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||||
<AnimationProvider>
|
<a className="skip-link" href="#main-content">
|
||||||
<script
|
Pular para o conteúdo
|
||||||
type="application/ld+json"
|
</a>
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
<Header />
|
||||||
/>
|
|
||||||
<CinematicOverlays />
|
<CinematicOverlays />
|
||||||
<RouteTransition>{children}</RouteTransition>
|
<div id="main-content" tabIndex={-1}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
<GoogleAnalytics />
|
<GoogleAnalytics />
|
||||||
</AnimationProvider>
|
|
||||||
</SmoothScrollProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* Animation Configuration
|
|
||||||
* Centralized configuration for all animations in the portfolio
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const animationConfig = {
|
|
||||||
// Easing curves
|
|
||||||
easings: {
|
|
||||||
easeOut: "cubic-bezier(0.16, 1, 0.3, 1)",
|
|
||||||
easeIn: "cubic-bezier(0.4, 0, 1, 1)",
|
|
||||||
spring: {
|
|
||||||
default: { stiffness: 200, damping: 20 },
|
|
||||||
hover: { stiffness: 260, damping: 20 },
|
|
||||||
magnetic: { stiffness: 150, damping: 15 },
|
|
||||||
gentle: { stiffness: 60, damping: 20 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Durations (in seconds)
|
|
||||||
durations: {
|
|
||||||
instant: 0.15,
|
|
||||||
fast: 0.3,
|
|
||||||
normal: 0.6,
|
|
||||||
slow: 0.9,
|
|
||||||
verySlow: 1.2,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Scroll trigger
|
|
||||||
scrollTrigger: {
|
|
||||||
threshold: 0.2,
|
|
||||||
once: true,
|
|
||||||
margin: "0px",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Stagger
|
|
||||||
stagger: {
|
|
||||||
default: 0.08,
|
|
||||||
fast: 0.04,
|
|
||||||
slow: 0.12,
|
|
||||||
reducedMotion: 0.03,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Parallax
|
|
||||||
parallax: {
|
|
||||||
speed: 0.5, // 50% mais lento que scroll normal
|
|
||||||
maxDistance: 100, // pixels
|
|
||||||
},
|
|
||||||
|
|
||||||
// Magnetic effect
|
|
||||||
magnetic: {
|
|
||||||
radius: 80, // pixels
|
|
||||||
strength: 12, // max movement in pixels
|
|
||||||
},
|
|
||||||
|
|
||||||
// Cursor
|
|
||||||
cursor: {
|
|
||||||
size: 40,
|
|
||||||
expandedSize: 60,
|
|
||||||
followDelay: 0.1,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Reduced motion overrides
|
|
||||||
reducedMotion: {
|
|
||||||
duration: 0.15,
|
|
||||||
stagger: 0.03,
|
|
||||||
disableParallax: true,
|
|
||||||
disableMagnetic: true,
|
|
||||||
disableCursor: true,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* Animation Variants
|
|
||||||
* Preset variants for Framer Motion animations
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const variants = {
|
|
||||||
// Fade in
|
|
||||||
fadeIn: {
|
|
||||||
initial: { opacity: 0 },
|
|
||||||
animate: { opacity: 1 },
|
|
||||||
exit: { opacity: 0 },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fade in + slide up
|
|
||||||
fadeInUp: {
|
|
||||||
initial: { opacity: 0, y: 24, filter: "blur(8px)" },
|
|
||||||
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
|
|
||||||
exit: { opacity: 0, y: -24, filter: "blur(8px)" },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fade in + slide down
|
|
||||||
fadeInDown: {
|
|
||||||
initial: { opacity: 0, y: -24, filter: "blur(8px)" },
|
|
||||||
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
|
|
||||||
exit: { opacity: 0, y: 24, filter: "blur(8px)" },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fade in + slide left
|
|
||||||
fadeInLeft: {
|
|
||||||
initial: { opacity: 0, x: 24, filter: "blur(8px)" },
|
|
||||||
animate: { opacity: 1, x: 0, filter: "blur(0px)" },
|
|
||||||
exit: { opacity: 0, x: -24, filter: "blur(8px)" },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fade in + slide right
|
|
||||||
fadeInRight: {
|
|
||||||
initial: { opacity: 0, x: -24, filter: "blur(8px)" },
|
|
||||||
animate: { opacity: 1, x: 0, filter: "blur(0px)" },
|
|
||||||
exit: { opacity: 0, x: 24, filter: "blur(8px)" },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Scale in
|
|
||||||
scaleIn: {
|
|
||||||
initial: { opacity: 0, scale: 0.95 },
|
|
||||||
animate: { opacity: 1, scale: 1 },
|
|
||||||
exit: { opacity: 0, scale: 0.95 },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Image reveal
|
|
||||||
imageReveal: {
|
|
||||||
initial: {
|
|
||||||
opacity: 0,
|
|
||||||
scale: 0.98,
|
|
||||||
filter: "blur(8px)",
|
|
||||||
clipPath: "inset(0 100% 0 0)",
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
filter: "blur(0px)",
|
|
||||||
clipPath: "inset(0 0% 0 0)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Text reveal (per word)
|
|
||||||
textReveal: {
|
|
||||||
initial: {
|
|
||||||
opacity: 0,
|
|
||||||
scale: 0.95,
|
|
||||||
filter: "blur(6px)",
|
|
||||||
clipPath: "inset(0 100% 0 0)",
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
filter: "blur(0px)",
|
|
||||||
clipPath: "inset(0 0% 0 0)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Stagger container
|
|
||||||
staggerContainer: {
|
|
||||||
animate: {
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.08,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Reduced motion variants
|
|
||||||
reducedMotion: {
|
|
||||||
fadeIn: {
|
|
||||||
initial: { opacity: 0 },
|
|
||||||
animate: { opacity: 1 },
|
|
||||||
exit: { opacity: 0 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
+10
-10
@@ -1,29 +1,29 @@
|
|||||||
import { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: "Evandro Fonseca Junior - Portfólio",
|
name: "Evandro.dev.br — Sistemas sob medida para empresas",
|
||||||
short_name: "Evandro.dev",
|
short_name: "Evandro.dev",
|
||||||
description:
|
description:
|
||||||
"Portfólio profissional de Evandro Fonseca Junior, Desenvolvedor Full-Stack",
|
"Desenvolvimento de sistemas web, aplicativos e integrações sob medida. Engenharia de software com Evandro Fonseca Junior, de Santa Catarina para sua empresa.",
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#000000",
|
background_color: "#000000",
|
||||||
theme_color: "#000000",
|
theme_color: "#000000",
|
||||||
orientation: "portrait-primary",
|
orientation: "portrait-primary",
|
||||||
categories: ["portfolio", "developer", "technology"],
|
categories: ["business", "productivity", "technology"],
|
||||||
lang: "pt-BR",
|
lang: "pt-BR",
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: "/favicon.ico",
|
src: "/brand/icon-192.png",
|
||||||
sizes: "any",
|
sizes: "192x192",
|
||||||
type: "image/x-icon",
|
type: "image/png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/icon.svg",
|
src: "/icon.png",
|
||||||
sizes: "any",
|
sizes: "512x512",
|
||||||
type: "image/svg+xml",
|
type: "image/png",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
|
import { ArrowLeft, Code2, Home } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Home, ArrowLeft, Code2 } from "lucide-react";
|
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user