Compare commits
64
Commits
develop
...
fix/ci-lint
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
|
||||
*.tsbuildinfo
|
||||
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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
corepack [email protected] install --frozen-lockfile
|
||||
corepack [email protected] dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
O servidor de desenvolvimento usa Turbopack. Para executar o artefato de produção:
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```bash
|
||||
corepack [email protected] build
|
||||
corepack [email protected] start
|
||||
```
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
## Scripts
|
||||
|
||||
## Learn More
|
||||
| Comando | Comportamento |
|
||||
| --- | --- |
|
||||
| `pnpm dev` | Inicia o Next.js em desenvolvimento com Turbopack. |
|
||||
| `pnpm build` | Gera o build de produção com Turbopack. |
|
||||
| `pnpm start` | Serve um build já gerado. |
|
||||
| `pnpm lint` | Executa `biome check`. |
|
||||
| `pnpm typecheck` | Gera os tipos de rota com `next typegen` e executa TypeScript sem emitir arquivos. |
|
||||
| `pnpm format` | Formata o repositório com Biome. |
|
||||
| `pnpm test` | Executa a suíte Jest. |
|
||||
| `pnpm test:watch` | Executa Jest em modo watch. |
|
||||
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
## Stack
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
| Área | Pacotes usados |
|
||||
| --- | --- |
|
||||
| Aplicação | Next.js 16.3.3, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. |
|
||||
| Estilos e composição | Tailwind CSS 4, `clsx` e `tailwind-merge`. |
|
||||
| Movimento | Motion 12 e GSAP 3. |
|
||||
| 3D e gráficos | Three.js, React Three Fiber e Drei. |
|
||||
| Interface | Lucide React. |
|
||||
| Qualidade | Biome, Jest, Testing Library e ambiente JSDOM. |
|
||||
|
||||
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 Link from "next/link";
|
||||
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
|
||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||
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 = {
|
||||
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
|
||||
description:
|
||||
"Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos. Portfolio tecnico com foco em clareza, performance e execucao.",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/cases`,
|
||||
canonical: pageUrl,
|
||||
},
|
||||
openGraph: {
|
||||
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
|
||||
description:
|
||||
"Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos.",
|
||||
url: `${siteUrl}/cases`,
|
||||
locale: "pt_BR",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
url: pageUrl,
|
||||
siteName,
|
||||
type: "website",
|
||||
},
|
||||
@@ -27,11 +33,10 @@ export default function CasesPage() {
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "CollectionPage",
|
||||
"@id": `${siteUrl}/cases#page`,
|
||||
name: "Cases de engenharia de software",
|
||||
url: `${siteUrl}/cases`,
|
||||
description:
|
||||
"Colecao de cases em Next.js, IoT, integracoes e software para operacoes criticas.",
|
||||
"@id": `${pageUrl}#page`,
|
||||
name: pageTitle,
|
||||
url: pageUrl,
|
||||
description: pageDescription,
|
||||
},
|
||||
{
|
||||
"@type": "ItemList",
|
||||
@@ -39,30 +44,49 @@ export default function CasesPage() {
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
name: caseStudy.title,
|
||||
url: caseStudy.demoUrl ?? caseStudy.githubUrl ?? `${siteUrl}/cases`,
|
||||
url: `${pageUrl}/${caseStudy.id}`,
|
||||
})),
|
||||
},
|
||||
buildBreadcrumbList(pageUrl, [
|
||||
["Início", siteUrl],
|
||||
["Cases", pageUrl],
|
||||
] as const),
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
|
||||
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
|
||||
<HeroBackground />
|
||||
<div className="ds-section-shell relative z-10">
|
||||
<div className="max-w-[860px]">
|
||||
<nav aria-label="Breadcrumb" className="mb-sp-6">
|
||||
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
|
||||
<li>
|
||||
<Link className="ds-link ds-link-muted" href="/">
|
||||
Início
|
||||
</Link>
|
||||
</li>
|
||||
<li className="flex items-center gap-sp-2">
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link
|
||||
aria-current="page"
|
||||
className="ds-link ds-link-muted"
|
||||
href="/cases"
|
||||
>
|
||||
Cases
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<p className="ds-eyebrow">Selected work</p>
|
||||
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
|
||||
Cases em software, Next.js, integracoes e produtos conectados.
|
||||
Cases em software, Next.js, integrações e produtos conectados.
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
||||
Esta pagina organiza projetos com leitura mais indexavel e comercial, conectando stack,
|
||||
contexto tecnico e tipo de problema resolvido.
|
||||
{pageDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,9 +94,9 @@ export default function CasesPage() {
|
||||
|
||||
<section className="ds-section">
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-grid-2">
|
||||
<div className="ds-grid-2 items-start">
|
||||
{caseStudies.map((caseStudy) => (
|
||||
<article key={caseStudy.id} className="ds-card">
|
||||
<article key={caseStudy.id} className="ds-card !h-auto">
|
||||
<div className="ds-kicker">{caseStudy.title}</div>
|
||||
<h2 className="ds-card-title">{caseStudy.subtitle}</h2>
|
||||
<p className="ds-copy">{caseStudy.summary}</p>
|
||||
@@ -81,33 +105,51 @@ export default function CasesPage() {
|
||||
</p>
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
{caseStudy.stack.map((item) => (
|
||||
<MagneticButton key={item} as="li" className="ds-tag" strength={0.15}>
|
||||
<li key={item} className="ds-tag">
|
||||
{item}
|
||||
</MagneticButton>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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 ? (
|
||||
<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
|
||||
</MagneticButton>
|
||||
) : 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>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
Ver servicos
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={intentCtas.project.href}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
{intentCtas.project.label}
|
||||
</MagneticButton>
|
||||
<MagneticButton as="a" href="/#contact" className="ds-link ds-link-muted" strength={0.2}>
|
||||
Ir para contato
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={intentCtas.consulting.href}
|
||||
className="ds-link ds-link-muted"
|
||||
strength={0.2}
|
||||
>
|
||||
{intentCtas.consulting.label}
|
||||
</MagneticButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+41
-34
@@ -1,41 +1,48 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import profilePhoto from "@/public/edev.jpeg";
|
||||
import { Section } from "./Section";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import profilePhoto from "@/public/edev.jpg";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
||||
import { Section } from "./Section";
|
||||
|
||||
const STORY_DATA = [
|
||||
{
|
||||
label: "Hardware + software",
|
||||
title: "Do sinal fisico ao produto digital",
|
||||
title: "Do sinal físico ao produto digital",
|
||||
content:
|
||||
"Construo a ponte entre dispositivos, protocolos de baixo nivel, backend e interface para que o sistema inteiro pareca uma unica experiencia coerente.",
|
||||
"Construo a ponte entre dispositivos, protocolos de baixo nível, backend e interface para que o sistema inteiro pareça uma única experiência coerente.",
|
||||
},
|
||||
{
|
||||
label: "Execution",
|
||||
title: "Entrega clara mesmo sob restricao",
|
||||
label: "Decisões técnicas",
|
||||
title: "Entrega clara mesmo sob restrição",
|
||||
content:
|
||||
"Trabalho bem em contextos com legado, integracoes complexas e pouca margem para erro, priorizando estabilidade, leitura tecnica e evolucao sustentavel.",
|
||||
"Avalio o que já existe, identifico dependências e combino prioridades antes de alterar sistemas em uso. Nem todo problema exige começar novamente.",
|
||||
},
|
||||
{
|
||||
label: "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 = [
|
||||
{
|
||||
title: "Arquitetura pragmatica",
|
||||
title: "Arquitetura pragmática",
|
||||
description:
|
||||
"Escolhas guiadas por contexto, operabilidade e impacto real no produto.",
|
||||
},
|
||||
{
|
||||
title: "Integracao profunda",
|
||||
title: "Integração profunda",
|
||||
description:
|
||||
"Capacidade de atravessar camada fisica, servicos e interface sem perder consistencia.",
|
||||
"Capacidade de atravessar camada física, serviços e interface sem perder consistência.",
|
||||
},
|
||||
{
|
||||
title: "Ownership ponta a ponta",
|
||||
title: "Responsabilidade técnica",
|
||||
description:
|
||||
"Responsabilidade que cobre discovery, implementacao, qualidade e operacao.",
|
||||
"Contato direto com quem define a arquitetura e desenvolve o sistema.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -79,19 +86,19 @@ export default function About() {
|
||||
return (
|
||||
<Section
|
||||
id="about"
|
||||
eyebrow="Operating context"
|
||||
title="Engenharia de software, arquitetura e integracoes complexas"
|
||||
description="Atuo na intersecao entre arquitetura de software, integracoes de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
|
||||
eyebrow="Quem conduz o projeto"
|
||||
title="Evandro Fonseca Junior. Engenharia com contato direto."
|
||||
description="Sou engenheiro da computação e desenvolvedor de software. Você conversa comigo sobre o negócio e acompanha as decisões técnicas ao longo da entrega."
|
||||
disableChildrenAnimation
|
||||
aside={
|
||||
<div className="mt-sp-4 ds-metric">
|
||||
<div className="mt-sp-4 ds-metric" data-reel="left">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" />
|
||||
Garuva, SC | Remote worldwide
|
||||
Base em Garuva, SC | Atendimento remoto
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
className="ds-grid-2"
|
||||
className="ds-grid-2 items-start"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
@@ -113,7 +120,9 @@ export default function About() {
|
||||
<div className="ds-kicker">{step.label}</div>
|
||||
<DrawLine delay={0.03} />
|
||||
<h3 className="ds-card-title">{step.title}</h3>
|
||||
<p className="ds-copy">{step.content}</p>
|
||||
<p className="ds-copy" data-reel="right">
|
||||
{step.content}
|
||||
</p>
|
||||
</motion.article>
|
||||
))}
|
||||
</motion.div>
|
||||
@@ -127,20 +136,20 @@ export default function About() {
|
||||
>
|
||||
<LazyRevealImage
|
||||
src={profilePhoto}
|
||||
alt="Retrato de Evandro"
|
||||
className="mb-sp-6 h-[220px] w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
|
||||
imageClassName="object-cover"
|
||||
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC"
|
||||
className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
|
||||
imageClassName="object-contain"
|
||||
/>
|
||||
<div className="ds-kicker">Technical profile</div>
|
||||
<div className="ds-kicker">Da arquitetura à implementação</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
|
||||
<p className="ds-copy">
|
||||
Sou engenheiro de software full-stack com foco em integrar camadas
|
||||
que normalmente ficam separadas: hardware, servicos, operacao e
|
||||
interface. Meu trabalho e dar forma clara ao que costuma nascer
|
||||
fragmentado.
|
||||
<h3 className="ds-card-title">Uma visão do sistema inteiro</h3>
|
||||
<p className="ds-copy" data-reel="top">
|
||||
Meu trabalho conecta interface, serviços, dados e, quando
|
||||
necessário, equipamentos. Entender essas partes em conjunto ajuda a
|
||||
definir o que construir, o que integrar e o que pode ser
|
||||
aproveitado.
|
||||
</p>
|
||||
<div className="mt-sp-6 space-y-sp-4">
|
||||
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
|
||||
{PROFILE_POINTS.map((point) => (
|
||||
<div
|
||||
key={point.title}
|
||||
@@ -158,5 +167,3 @@ export default function About() {
|
||||
</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");
|
||||
});
|
||||
+231
-195
@@ -1,216 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { Section } from "./Section";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { trackWhatsAppOpen } from "@/lib/analytics";
|
||||
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||
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() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||
useEffect(() => {
|
||||
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||
}, []);
|
||||
|
||||
const fieldClassName =
|
||||
"mt-sp-2 w-full border border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.06)] px-sp-4 py-[14px] text-[13px] leading-6 text-white outline-none transition-[border-color,box-shadow] focus-visible:border-[var(--slash)] focus-visible:ring-2 focus-visible:ring-[rgba(204,255,0,0.34)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
|
||||
"mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
|
||||
const labelClassName = "block font-display text-sm font-medium text-white/85";
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "full-stack",
|
||||
subject: "Sistemas web sob medida",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const messageTemplate = `*NOVA SOLICITACAO DE PROJETO*\n\n*Origem:* Portfolio Profissional\n*Prioridade:* Alta\n\n---\n*CLIENTE:* ${formData.name}\n*EMAIL:* ${formData.email}\n*AREA:* ${formData.subject.toUpperCase()}\n\n*DESCRICAO:*\n"${formData.message}"\n---\n_Mensagem gerada via Evandro.dev | Garuva, SC_`;
|
||||
|
||||
const encodedMessage = encodeURIComponent(messageTemplate);
|
||||
const whatsappUrl = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodedMessage}`;
|
||||
|
||||
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const gridVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.05,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 18, clipPath: "inset(100% 0 0 0)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.75, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
if (!e.currentTarget.reportValidity()) return;
|
||||
const messageTemplate = `Olá, Evandro! Quero conversar sobre um projeto.\n\nNome: ${formData.name}\nE-mail: ${formData.email}\nTipo de projeto: ${formData.subject}\n\nContexto, objetivo e prazo:\n${formData.message}\n\nContato iniciado pelo site evandro.dev.br.`;
|
||||
window.open(
|
||||
buildWhatsAppUrl(messageTemplate),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
<section
|
||||
id="contact"
|
||||
eyebrow="Contact"
|
||||
title="Consultoria tecnica para software, arquitetura e integracoes criticas"
|
||||
description="Fale comigo se voce precisa de engenharia de software, consultoria tecnica, arquitetura de software ou apoio em projetos com Next.js, Tauri, Rust e IoT."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
aria-labelledby="contact-title"
|
||||
className="ds-section ds-section-dark"
|
||||
>
|
||||
<motion.div
|
||||
className="ds-grid-2"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={gridVariants}
|
||||
>
|
||||
<motion.div
|
||||
className="ds-card"
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<div className="ds-kicker">Availability</div>
|
||||
<DrawLine delay={0.04} />
|
||||
<h3 className="ds-card-title">Resposta inicial em ate 24h</h3>
|
||||
<p className="ds-copy">
|
||||
Ideal para discovery tecnico, revisao de arquitetura, rescue de
|
||||
projeto, performance ou construcao de produto com ownership real.
|
||||
</p>
|
||||
<div className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]">
|
||||
<div>WhatsApp: +55 47 99274-2365</div>
|
||||
<div>E-mail: evandrofonsecajunior@gmail.com</div>
|
||||
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
|
||||
</div>
|
||||
<div className="mt-sp-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]">
|
||||
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
|
||||
conversa tecnica objetiva.
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.form
|
||||
onSubmit={handleSubmit}
|
||||
className="ds-card space-y-sp-5"
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
|
||||
}
|
||||
}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-name"
|
||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
||||
>
|
||||
Nome completo
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-email"
|
||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
||||
>
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-subject"
|
||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
||||
>
|
||||
Tipo de projeto
|
||||
</label>
|
||||
<select
|
||||
id="contact-subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, subject: e.target.value })
|
||||
}
|
||||
className={fieldClassName}
|
||||
style={{ color: "#fff", backgroundColor: "#1a1a1a" }}
|
||||
>
|
||||
<option value="full-stack">Desenvolvimento Full-Stack</option>
|
||||
<option value="cloud">Cloud & Infrastructure</option>
|
||||
<option value="iot">Internet of Things (IoT)</option>
|
||||
<option value="consultancy">Consultoria Tecnica Senior</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-message"
|
||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
||||
>
|
||||
Contexto do projeto
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
required
|
||||
rows={6}
|
||||
autoComplete="off"
|
||||
value={formData.message}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, message: e.target.value })
|
||||
}
|
||||
className={`${fieldClassName} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MagneticButton
|
||||
as="button"
|
||||
type="submit"
|
||||
className="ds-link ds-link-accent min-h-12 border-0"
|
||||
strength={0.2}
|
||||
<div className="ds-section-shell grid items-start gap-sp-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-16">
|
||||
<div className="min-w-0">
|
||||
<p className="ds-eyebrow">Contato</p>
|
||||
<h2
|
||||
id="contact-title"
|
||||
className="ds-section-title mt-sp-4 max-w-[19ch] text-balance"
|
||||
>
|
||||
Enviar briefing pelo WhatsApp
|
||||
</MagneticButton>
|
||||
</motion.form>
|
||||
</motion.div>
|
||||
</Section>
|
||||
Vamos conversar sobre o que sua empresa precisa.
|
||||
</h2>
|
||||
<p className="mt-sp-5 max-w-[44ch] font-display text-base leading-8 text-white/70">
|
||||
Conte o que você quer resolver. Pode ser um projeto novo, uma
|
||||
melhoria ou uma integração. Não precisa chegar com a solução
|
||||
definida.
|
||||
</p>
|
||||
<div className="mt-sp-8 border-l-2 border-[var(--slash)] pl-sp-5">
|
||||
<h3 className="font-display text-lg font-semibold text-white">
|
||||
Você fala direto com quem desenvolve.
|
||||
</h3>
|
||||
<p className="mt-sp-2 max-w-[44ch] font-display text-sm leading-7 text-white/65">
|
||||
Sou Evandro. A primeira conversa é para entender sua necessidade;
|
||||
escopo, prazo e proposta vêm depois.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
|
||||
<WhatsAppLink
|
||||
service="Contato"
|
||||
placement="contact_direct"
|
||||
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
|
||||
>
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
|
||||
<WhatsAppMark className="h-6 w-6" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display">
|
||||
<span className="block text-sm font-semibold text-white group-hover:text-[#25d366]">
|
||||
Prefere conversar direto?
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-white/65">
|
||||
WhatsApp · {whatsAppDisplay}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[#25d366]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</WhatsAppLink>
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="group flex min-h-16 items-center gap-sp-4 rounded-sm py-sp-5 text-white/65 no-underline hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
|
||||
>
|
||||
<Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 break-all font-display text-sm">
|
||||
evandrofonsecajunior@gmail.com
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className="ml-auto h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-sm leading-6 text-white/55">
|
||||
<MapPin className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
Garuva, Santa Catarina. Atendimento remoto em todo o Brasil.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
aria-labelledby="contact-form-title"
|
||||
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
|
||||
>
|
||||
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#25d366] text-[#071b10]">
|
||||
<WhatsAppMark className="h-7 w-7" />
|
||||
</span>
|
||||
<div>
|
||||
<h3
|
||||
id="contact-form-title"
|
||||
className="font-display text-xl font-semibold leading-7 text-white"
|
||||
>
|
||||
Prepare sua mensagem
|
||||
</h3>
|
||||
<p className="mt-1 font-display text-sm leading-6 text-white/65">
|
||||
Você revisa no WhatsApp antes de enviar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-sp-5">
|
||||
<div className="grid gap-sp-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="contact-name" className={labelClassName}>
|
||||
Nome completo
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder="Como posso te chamar?"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-email" className={labelClassName}>
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="[email protected]"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-subject" className={labelClassName}>
|
||||
Tipo de projeto
|
||||
</label>
|
||||
<select
|
||||
id="contact-subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, subject: e.target.value })
|
||||
}
|
||||
className={`${fieldClassName} [color-scheme:dark]`}
|
||||
>
|
||||
<option value="Sistemas web sob medida">
|
||||
Sistemas web sob medida
|
||||
</option>
|
||||
<option value="Aplicativos">Aplicativos</option>
|
||||
<option value="Integrações e automações">
|
||||
Integrações e automações
|
||||
</option>
|
||||
<option value="Evolução de um sistema existente">
|
||||
Evolução de um sistema existente
|
||||
</option>
|
||||
<option value="Ainda preciso definir">
|
||||
Ainda preciso definir
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-message" className={labelClassName}>
|
||||
Contexto do projeto
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
required
|
||||
rows={4}
|
||||
autoComplete="off"
|
||||
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
|
||||
aria-describedby="contact-privacy-note"
|
||||
value={formData.message}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, message: e.target.value })
|
||||
}
|
||||
className={`${fieldClassName} min-h-32 resize-y`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 font-display text-base font-semibold leading-6 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
|
||||
>
|
||||
<WhatsAppMark className="h-6 w-6 shrink-0" />
|
||||
Continuar no WhatsApp
|
||||
<ArrowUpRight className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
<p
|
||||
id="contact-privacy-note"
|
||||
className="font-display text-xs leading-6 text-white/55"
|
||||
>
|
||||
Nada é enviado automaticamente. O WhatsApp abre com a mensagem
|
||||
para você revisar. Não inclua senhas nem dados pessoais de
|
||||
clientes.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { Section } from "./Section";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { SectionLoading } from "./SectionLoading";
|
||||
import { Section } from "./Section";
|
||||
|
||||
export default function Experience() {
|
||||
const { data, loading, error } = usePortfolioData();
|
||||
export default function Experience({
|
||||
experiences = [],
|
||||
}: {
|
||||
experiences?: PortfolioExperience[];
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const sortedExperiences = useMemo(() => {
|
||||
if (!data?.experiences) return [];
|
||||
return [...data.experiences]
|
||||
.filter((exp) => exp.featured)
|
||||
.sort((a, b) => {
|
||||
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||
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 sortedExperiences = [...experiences]
|
||||
.filter((experience) => experience.featured)
|
||||
.sort((a, b) => {
|
||||
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||
return yearB - yearA;
|
||||
});
|
||||
|
||||
const listVariants: Variants = {
|
||||
hidden: {},
|
||||
@@ -59,8 +48,8 @@ export default function Experience() {
|
||||
<Section
|
||||
id="experience"
|
||||
eyebrow="Selected experience"
|
||||
title="Experiencia em engenharia de software e sistemas criticos"
|
||||
description="Projetos e contextos em que combinei arquitetura de software, integracoes complexas e entrega com responsabilidade operacional."
|
||||
title="Experiência em engenharia de software e sistemas críticos"
|
||||
description="Projetos e contextos em que combinei arquitetura de software, integrações complexas e entrega com responsabilidade operacional."
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<motion.div
|
||||
@@ -90,7 +79,9 @@ export default function Experience() {
|
||||
<div className="max-w-[720px]">
|
||||
<div className="ds-kicker">{exp.period}</div>
|
||||
<DrawLine delay={0.04} />
|
||||
<h3 className="ds-card-title">{exp.title}</h3>
|
||||
<h3 className="ds-card-title" data-reel="left">
|
||||
{exp.title}
|
||||
</h3>
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(10,10,10,.64)] dark:text-[rgba(255,255,255,.6)]">
|
||||
{exp.company} • {exp.type}
|
||||
</p>
|
||||
@@ -98,7 +89,9 @@ export default function Experience() {
|
||||
<div className="ds-metric">{exp.location}</div>
|
||||
</div>
|
||||
|
||||
<p className="ds-copy">{exp.description}</p>
|
||||
<p className="ds-copy" data-reel="right">
|
||||
{exp.description}
|
||||
</p>
|
||||
|
||||
<ul className="ds-list">
|
||||
{exp.achievements?.map((achievement) => (
|
||||
@@ -106,11 +99,11 @@ export default function Experience() {
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
|
||||
{exp.technologies?.slice(0, 6).map((tech) => (
|
||||
<MagneticButton key={tech} as="li" className="ds-tag" strength={0.15}>
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
</MagneticButton>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.article>
|
||||
@@ -119,5 +112,3 @@ export default function Experience() {
|
||||
</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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
+50
-298
@@ -1,323 +1,75 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Footer Component - Contact Information", () => {
|
||||
it("should render contact section heading", () => {
|
||||
describe("Footer contact and navigation", () => {
|
||||
it("offers a direct mailto link without repeating the contact call to action", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const contactHeading = screen.getByText("Contato direto");
|
||||
expect(contactHeading).toBeInTheDocument();
|
||||
expect(contactHeading.tagName).toBe("H3");
|
||||
// o convite principal fica na secao Contato, logo acima do rodape
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Conversar sobre meu projeto" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
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 />);
|
||||
|
||||
const emailLink = screen.getByText("[email protected]");
|
||||
expect(emailLink).toBeInTheDocument();
|
||||
expect(emailLink.tagName).toBe("A");
|
||||
const servicesLink = screen.getByRole("link", { name: "Hub de serviços" });
|
||||
expect(servicesLink).toHaveAttribute("href", "/servicos");
|
||||
expect(servicesLink).not.toHaveAttribute("target", "_blank");
|
||||
expect(screen.getByRole("link", { name: "Privacidade" })).toHaveAttribute(
|
||||
"href",
|
||||
"/privacidade",
|
||||
);
|
||||
});
|
||||
|
||||
it("should have mailto link for email", () => {
|
||||
it("keeps the résumé page and PDF download as the final secondary links", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const emailLink = screen.getByText("[email protected]");
|
||||
expect(emailLink).toHaveAttribute("href", "mailto:[email protected]");
|
||||
});
|
||||
|
||||
it("should apply correct styling classes to email link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const emailLink = screen.getByText("[email protected]");
|
||||
const classes = emailLink.className;
|
||||
|
||||
expect(classes).toContain("text-[12px]");
|
||||
expect(classes).toContain("hover:text-[var(--slash)]");
|
||||
const résuméLink = screen.getByRole("link", { name: "Currículo" });
|
||||
const downloadLink = screen.getByRole("link", {
|
||||
name: "Baixar currículo (PDF)",
|
||||
});
|
||||
expect(résuméLink).toHaveAttribute("href", "/curriculo");
|
||||
expect(downloadLink).toHaveAttribute("href", "/Profile.pdf");
|
||||
expect(downloadLink).toHaveAttribute(
|
||||
"download",
|
||||
"Evandro_Fonseca_Junior_CV.pdf",
|
||||
);
|
||||
expect(screen.getAllByRole("link").slice(-2)).toEqual([
|
||||
résuméLink,
|
||||
downloadLink,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Social Links", () => {
|
||||
it("should render social links section heading", () => {
|
||||
describe("Footer social links", () => {
|
||||
it("opens social links securely in a new tab", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const connectHeading = screen.getByText("Redes profissionais");
|
||||
expect(connectHeading).toBeInTheDocument();
|
||||
expect(connectHeading.tagName).toBe("H3");
|
||||
});
|
||||
const socialLinks = screen.getAllByRole("link", { name: /profile/i });
|
||||
|
||||
it("should render GitHub social link", () => {
|
||||
render(<Footer />);
|
||||
for (const link of socialLinks) {
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(link.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
expect(githubLink).toBeInTheDocument();
|
||||
expect(githubLink.tagName).toBe("A");
|
||||
});
|
||||
|
||||
it("should render LinkedIn social link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
expect(linkedinLink).toBeInTheDocument();
|
||||
expect(linkedinLink.tagName).toBe("A");
|
||||
});
|
||||
|
||||
it("should have correct href for GitHub link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
expect(githubLink).toHaveAttribute(
|
||||
expect(screen.getByLabelText("Visit GitHub profile")).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/evandrodevbr",
|
||||
);
|
||||
});
|
||||
|
||||
it("should have correct href for LinkedIn link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
expect(linkedinLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://linkedin.com/in/evandrodevbr",
|
||||
);
|
||||
});
|
||||
|
||||
it('should open social links in new tab with target="_blank"', () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
|
||||
expect(githubLink).toHaveAttribute("target", "_blank");
|
||||
expect(linkedinLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it('should have rel="noopener noreferrer" for security', () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
|
||||
expect(githubLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(linkedinLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should have aria-label for accessibility", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
|
||||
expect(githubLink).toHaveAttribute("aria-label", "Visit GitHub profile");
|
||||
expect(linkedinLink).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Visit LinkedIn profile",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render SVG icons for social links", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
|
||||
const githubSvg = githubLink.querySelector("svg");
|
||||
const linkedinSvg = linkedinLink.querySelector("svg");
|
||||
|
||||
expect(githubSvg).toBeInTheDocument();
|
||||
expect(linkedinSvg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have aria-hidden="true" on SVG icons', () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
|
||||
const githubSvg = githubLink.querySelector("svg");
|
||||
const linkedinSvg = linkedinLink.querySelector("svg");
|
||||
|
||||
expect(githubSvg).toHaveAttribute("aria-hidden", "true");
|
||||
expect(linkedinSvg).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Copyright", () => {
|
||||
it("should render copyright section heading", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const copyrightHeading = screen.getByText("Informacoes legais");
|
||||
expect(copyrightHeading).toBeInTheDocument();
|
||||
expect(copyrightHeading.tagName).toBe("H3");
|
||||
});
|
||||
|
||||
it("should display current year in copyright text", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const copyrightText = screen.getByText(
|
||||
new RegExp(`© ${currentYear} Evandro Fonseca Junior`),
|
||||
);
|
||||
|
||||
expect(copyrightText).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should update copyright year dynamically", () => {
|
||||
// Test that the component uses new Date().getFullYear() dynamically
|
||||
// by verifying it shows the current year (not a hardcoded value)
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
render(<Footer />);
|
||||
|
||||
// Verify the copyright includes the current year
|
||||
const copyrightText = screen.getByText(
|
||||
new RegExp(`© ${currentYear} Evandro Fonseca Junior`),
|
||||
);
|
||||
expect(copyrightText).toBeInTheDocument();
|
||||
|
||||
// Verify it's not hardcoded to a specific year like 2023 or 2024
|
||||
// by checking that the year matches the actual current year
|
||||
expect(currentYear).toBeGreaterThanOrEqual(2024);
|
||||
});
|
||||
|
||||
it("should apply correct styling to copyright text", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const copyrightText = screen.getByText(
|
||||
new RegExp(`© ${currentYear} Evandro Fonseca Junior`),
|
||||
);
|
||||
const classes = copyrightText.className;
|
||||
|
||||
expect(classes).toContain("text-[12px]");
|
||||
expect(classes).toContain("text-[rgba(255,255,255,0.5)]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Responsive Layout", () => {
|
||||
beforeEach(() => {
|
||||
// Mock window.matchMedia for responsive tests
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
it("should have responsive grid layout classes", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const gridContainer = container.querySelector(".grid");
|
||||
expect(gridContainer).toBeInTheDocument();
|
||||
|
||||
const classes = gridContainer?.className || "";
|
||||
expect(classes).toContain("grid-cols-1");
|
||||
expect(classes).toContain("md:grid-cols-2");
|
||||
expect(classes).toContain("lg:grid-cols-3");
|
||||
});
|
||||
|
||||
it("should have responsive padding classes on container", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const innerContainer = container.querySelector(".ds-section-shell");
|
||||
expect(innerContainer).toBeInTheDocument();
|
||||
|
||||
// The ds-section class handles responsive padding via CSS
|
||||
const footer = container.querySelector("footer");
|
||||
const classes = footer?.className || "";
|
||||
expect(classes).toContain("ds-section");
|
||||
});
|
||||
|
||||
it("should have responsive gap classes on grid", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const gridContainer = container.querySelector(".grid");
|
||||
const classes = gridContainer?.className || "";
|
||||
|
||||
expect(classes).toContain("gap-sp-6");
|
||||
});
|
||||
|
||||
it("should have responsive column span for copyright section", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find the copyright section by its heading
|
||||
const copyrightHeading = screen.getByText("Informacoes legais");
|
||||
const copyrightSection = copyrightHeading.closest(".ds-card");
|
||||
|
||||
expect(copyrightSection).toBeInTheDocument();
|
||||
const classes = copyrightSection?.className || "";
|
||||
|
||||
expect(classes).toContain("md:col-span-2");
|
||||
expect(classes).toContain("lg:col-span-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Design System Consistency", () => {
|
||||
it("should use design system kicker class for section labels", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const kickers = document.querySelectorAll(".ds-kicker");
|
||||
expect(kickers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should have ds-section class on footer element", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const footer = container.querySelector("footer");
|
||||
expect(footer).toBeInTheDocument();
|
||||
|
||||
const classes = footer?.className || "";
|
||||
expect(classes).toContain("ds-section");
|
||||
});
|
||||
|
||||
it("should have ds-section-dark class for dark background", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const footer = container.querySelector("footer");
|
||||
const classes = footer?.className || "";
|
||||
|
||||
expect(classes).toContain("ds-section-dark");
|
||||
});
|
||||
|
||||
it("should use design system shell class for container", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const shell = container.querySelector(".ds-section-shell");
|
||||
expect(shell).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Structure", () => {
|
||||
it("should render as footer semantic element", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const footer = container.querySelector("footer");
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have three main sections", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const contactHeading = screen.getByText("Contato direto");
|
||||
const connectHeading = screen.getByText("Redes profissionais");
|
||||
const copyrightHeading = screen.getByText("Informacoes legais");
|
||||
|
||||
expect(contactHeading).toBeInTheDocument();
|
||||
expect(connectHeading).toBeInTheDocument();
|
||||
expect(copyrightHeading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have design system shell container", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const innerContainer = container.querySelector(".ds-section-shell");
|
||||
expect(innerContainer).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("Visit LinkedIn profile").getAttribute("href"),
|
||||
).toContain("https://www.linkedin.com/in/evandrodevbr/");
|
||||
});
|
||||
});
|
||||
+40
-20
@@ -16,27 +16,30 @@ const socialLinks: SocialLink[] = [
|
||||
name: "LinkedIn",
|
||||
href: "https://www.linkedin.com/in/evandrodevbr/?skipRedirect=true",
|
||||
icon: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
|
||||
ariaLabel: "Connect on LinkedIn",
|
||||
ariaLabel: "Visit LinkedIn profile",
|
||||
},
|
||||
{
|
||||
name: "Instagram",
|
||||
href: "https://www.instagram.com/evandrodevbr/",
|
||||
icon: "M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.85.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.85-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z",
|
||||
ariaLabel: "Follow on Instagram",
|
||||
ariaLabel: "Visit Instagram profile",
|
||||
},
|
||||
];
|
||||
|
||||
const quickLinks = [
|
||||
{ label: "Hub de servicos", href: "/servicos" },
|
||||
{ label: "Hub de serviços", href: "/servicos" },
|
||||
{ label: "Cases", href: "/cases" },
|
||||
{ label: "Privacidade", href: "/privacidade" },
|
||||
{ label: "Consultoria Next.js", href: "/servicos/consultoria-nextjs" },
|
||||
{
|
||||
label: "Integracao hardware + software",
|
||||
label: "Integração hardware + software",
|
||||
href: "/servicos/integracao-hardware-software-iot",
|
||||
},
|
||||
];
|
||||
|
||||
import { CV_DOWNLOAD_NAME, CV_URL } from "@/lib/site-navigation";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
export default function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -49,44 +52,43 @@ export default function Footer() {
|
||||
<div>
|
||||
<div className="ds-kicker">Contato</div>
|
||||
<span className="ds-draw-line" />
|
||||
<h3 className="ds-card-title">
|
||||
Engenharia com menos ruido e mais clareza de execucao
|
||||
</h3>
|
||||
<p className="ds-copy max-w-[48ch]">
|
||||
Se voce precisa de alguem para conectar produto, integracao e
|
||||
entrega com mais criterio tecnico, este portfolio e o ponto de
|
||||
partida.
|
||||
</p>
|
||||
|
||||
<div className="mt-sp-6">
|
||||
<div className="mt-sp-5">
|
||||
<p className="mb-2 text-[12px] tracking-[0.02em] text-[var(--meta)]">
|
||||
E-mail direto
|
||||
</p>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="mailto:evandro@dev.br"
|
||||
className="inline-block text-[18px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)]"
|
||||
href="mailto:evandro[email protected]"
|
||||
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}
|
||||
>
|
||||
evandro@dev.br
|
||||
evandrofonsecajunior@gmail.com
|
||||
</MagneticButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-8">
|
||||
<a
|
||||
href="/#home"
|
||||
aria-label="Evandro.dev.br — início"
|
||||
className="mb-sp-6 inline-flex min-h-11 max-w-full items-center"
|
||||
>
|
||||
<Logo theme="light" className="w-60" />
|
||||
</a>
|
||||
<div className="ds-metric">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-slash" />
|
||||
Garuva, SC | Remote worldwide
|
||||
</div>
|
||||
<p className="mt-sp-4 text-[12px] text-[var(--meta)]">
|
||||
Copyright {currentYear} Evandro Fonseca Junior. All rights reserved.
|
||||
Copyright {currentYear} Evandro Fonseca Junior. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ds-card flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="ds-kicker">Presenca digital</div>
|
||||
<div className="ds-kicker">Presença digital</div>
|
||||
<span className="ds-draw-line" />
|
||||
<h3 className="ds-card-title">Canais profissionais</h3>
|
||||
|
||||
@@ -141,9 +143,27 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-8 ds-metric">Open to collaboration</div>
|
||||
<div className="mt-sp-8 ds-metric">
|
||||
Software sob medida, contato direto
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-sp-8 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-2 border-t border-[var(--border)] pt-sp-4 text-[12px] text-[var(--meta)]">
|
||||
<span>Trajetória profissional</span>
|
||||
<a
|
||||
href="/curriculo"
|
||||
className="inline-flex min-h-11 items-center underline-offset-4 hover:text-[var(--ink)] hover:underline"
|
||||
>
|
||||
Currículo
|
||||
</a>
|
||||
<a
|
||||
href={CV_URL}
|
||||
download={CV_DOWNLOAD_NAME}
|
||||
className="inline-flex min-h-11 items-center underline-offset-4 hover:text-[var(--ink)] hover:underline"
|
||||
>
|
||||
Baixar currículo (PDF)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
import Script from "next/script";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type AnalyticsConsent,
|
||||
CONSENT_STORAGE_KEY,
|
||||
GA_DISABLE_KEY,
|
||||
GA_MEASUREMENT_ID,
|
||||
type Gtag,
|
||||
readConsent,
|
||||
} from "@/lib/analytics";
|
||||
|
||||
const GA_MEASUREMENT_ID = "G-QTT9QT0T3S";
|
||||
const GOOGLE_ADS_ID = "AW-18432694647";
|
||||
const GA_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
|
||||
const SITE_DOMAIN = "evandro.dev.br";
|
||||
|
||||
export default function GoogleAnalytics() {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return null;
|
||||
const deniedConsent = {
|
||||
analytics_storage: "denied",
|
||||
ad_storage: "denied",
|
||||
ad_user_data: "denied",
|
||||
ad_personalization: "denied",
|
||||
} as const;
|
||||
|
||||
function persistConsent(value: AnalyticsConsent): boolean {
|
||||
try {
|
||||
localStorage.setItem(CONSENT_STORAGE_KEY, value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getGtag(): Gtag {
|
||||
window.dataLayer ??= [];
|
||||
const dataLayer = window.dataLayer;
|
||||
if (!window.gtag) {
|
||||
window.gtag = function gtag() {
|
||||
// biome-ignore lint/complexity/noArguments: The official gtag bootstrap requires an IArguments entry, not an Array.
|
||||
dataLayer.push(arguments);
|
||||
};
|
||||
}
|
||||
return window.gtag;
|
||||
}
|
||||
|
||||
function removeAnalyticsLoader() {
|
||||
document.getElementById("google-analytics-script")?.remove();
|
||||
}
|
||||
|
||||
function writeCookie(value: string) {
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store is not universal and domain-scoped GA cookies must also be expired.
|
||||
document.cookie = value;
|
||||
}
|
||||
function expireAnalyticsCookies() {
|
||||
const cookieNames = new Set(
|
||||
document.cookie
|
||||
.split(";")
|
||||
.map((cookie) => cookie.split("=")[0]?.trim())
|
||||
.filter(
|
||||
(name): name is string =>
|
||||
name === "_ga" || Boolean(name?.startsWith("_ga_")),
|
||||
),
|
||||
);
|
||||
const { hostname } = window.location;
|
||||
const domains =
|
||||
hostname === SITE_DOMAIN || hostname.endsWith(`.${SITE_DOMAIN}`)
|
||||
? new Set([hostname, SITE_DOMAIN])
|
||||
: new Set<string>();
|
||||
|
||||
for (const name of cookieNames) {
|
||||
const expiration = `${name}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax`;
|
||||
writeCookie(expiration);
|
||||
for (const domain of domains) {
|
||||
writeCookie(`${expiration}; Domain=${domain}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const panelClassName =
|
||||
"fixed bottom-sp-4 left-sp-4 right-sp-4 z-[9980] ml-auto max-w-[560px] border border-[var(--slash)] bg-[var(--ink)] p-sp-5 text-white shadow-[8px_8px_0_var(--slash)] sm:left-auto";
|
||||
const buttonClassName =
|
||||
"inline-flex min-h-11 min-w-11 items-center justify-center border px-sp-4 py-sp-2 font-mono text-[11px] font-bold uppercase tracking-[0.08em] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
|
||||
|
||||
function GoogleAnalyticsConsent() {
|
||||
const [consent, setConsent] = useState<AnalyticsConsent | null>();
|
||||
const [preferencesOpen, setPreferencesOpen] = useState(false);
|
||||
const [consentError, setConsentError] = useState<string | null>(null);
|
||||
const configuredRef = useRef(false);
|
||||
const preferencesButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const enableAnalytics = useCallback(() => {
|
||||
const gtag = getGtag();
|
||||
window[GA_DISABLE_KEY] = false;
|
||||
if (!configuredRef.current) {
|
||||
gtag("consent", "default", deniedConsent);
|
||||
gtag("set", "ads_data_redaction", true);
|
||||
gtag("js", new Date());
|
||||
configuredRef.current = true;
|
||||
}
|
||||
gtag("consent", "update", {
|
||||
...deniedConsent,
|
||||
analytics_storage: "granted",
|
||||
});
|
||||
gtag("config", GA_MEASUREMENT_ID, {
|
||||
allow_ad_personalization_signals: false,
|
||||
anonymize_ip: true,
|
||||
});
|
||||
// One loader serves both destinations; GT IDs are aliases, not extra installs.
|
||||
gtag("config", GOOGLE_ADS_ID, {
|
||||
allow_ad_personalization_signals: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedConsent = readConsent();
|
||||
if (storedConsent === "granted") enableAnalytics();
|
||||
setConsent(storedConsent);
|
||||
}, [enableAnalytics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (consent !== "granted" && consent !== undefined) {
|
||||
removeAnalyticsLoader();
|
||||
}
|
||||
}, [consent]);
|
||||
|
||||
function closePreferences() {
|
||||
setPreferencesOpen(false);
|
||||
preferencesButtonRef.current?.focus();
|
||||
}
|
||||
|
||||
function acceptAnalytics() {
|
||||
if (!persistConsent("granted")) {
|
||||
setConsentError(
|
||||
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setConsentError(null);
|
||||
enableAnalytics();
|
||||
setConsent("granted");
|
||||
closePreferences();
|
||||
}
|
||||
|
||||
function denyAnalytics() {
|
||||
if (!persistConsent("denied")) {
|
||||
setConsentError(
|
||||
"Não foi possível salvar sua recusa. Tente novamente para concluir.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setConsentError(null);
|
||||
setConsent("denied");
|
||||
closePreferences();
|
||||
}
|
||||
|
||||
function revokeAnalytics() {
|
||||
if (!persistConsent("denied")) {
|
||||
setConsentError(
|
||||
"Não foi possível salvar a revogação. Tente novamente para concluir.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setConsentError(null);
|
||||
window[GA_DISABLE_KEY] = true;
|
||||
window.gtag?.("consent", "update", deniedConsent);
|
||||
expireAnalyticsCookies();
|
||||
setConsent("denied");
|
||||
closePreferences();
|
||||
}
|
||||
|
||||
if (consent === undefined) return null;
|
||||
|
||||
if (consent === null) {
|
||||
return (
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby="analytics-consent-title"
|
||||
aria-describedby="analytics-consent-description"
|
||||
className={panelClassName}
|
||||
>
|
||||
<p
|
||||
id="analytics-consent-title"
|
||||
className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--slash)]"
|
||||
>
|
||||
{"Privacidade // Analytics"}
|
||||
</p>
|
||||
<p
|
||||
id="analytics-consent-description"
|
||||
className="mt-sp-2 max-w-[48ch] text-[13px] leading-6 text-white/85"
|
||||
>
|
||||
Você decide se permite métricas de navegação com a tag compartilhada
|
||||
do Google Analytics e Google Ads. Cookies de publicidade e
|
||||
personalização continuam desativados.
|
||||
</p>
|
||||
{consentError && (
|
||||
<p
|
||||
role="alert"
|
||||
aria-label="Falha ao salvar o consentimento"
|
||||
className="mt-sp-3 text-[13px] leading-6 text-[var(--slash)]"
|
||||
>
|
||||
{consentError}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
|
||||
onClick={acceptAnalytics}
|
||||
>
|
||||
Aceitar analytics
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-white/40 bg-transparent text-white`}
|
||||
onClick={denyAnalytics}
|
||||
>
|
||||
Recusar analytics
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
className="ds-link ds-link-accent mt-sp-3 inline-block"
|
||||
href="/privacidade"
|
||||
>
|
||||
Ler política de privacidade
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script src="/sys/libs/vendor-utils.js" strategy="lazyOnload" />
|
||||
<Script id="google-analytics" strategy="lazyOnload">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', '${GA_MEASUREMENT_ID}', {
|
||||
transport_url: window.location.origin + '/sys/api/vitals',
|
||||
cookie_flags: 'SameSite=None;Secure',
|
||||
allow_ad_personalization_signals: false,
|
||||
anonymize_ip: true,
|
||||
page_location: window.location.href,
|
||||
page_title: document.title
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
{consent === "granted" && (
|
||||
<Script
|
||||
id="google-analytics-script"
|
||||
data-testid="google-analytics-script"
|
||||
src={GA_SCRIPT_SRC}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
ref={preferencesButtonRef}
|
||||
aria-expanded={preferencesOpen}
|
||||
aria-controls="analytics-preferences"
|
||||
className={`${buttonClassName} fixed bottom-sp-4 right-sp-4 z-[9980] border-white/25 bg-[var(--ink)] text-white shadow-[4px_4px_0_var(--slash)]`}
|
||||
onClick={() => setPreferencesOpen((open) => !open)}
|
||||
>
|
||||
<span className="max-sm:hidden">Preferências de analytics</span>
|
||||
<span aria-hidden="true" className="sm:hidden">
|
||||
Cookies
|
||||
</span>
|
||||
</button>
|
||||
{preferencesOpen && (
|
||||
<section
|
||||
id="analytics-preferences"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby="analytics-preferences-title"
|
||||
aria-describedby="analytics-preferences-description"
|
||||
className={panelClassName}
|
||||
>
|
||||
<p
|
||||
id="analytics-preferences-title"
|
||||
className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--slash)]"
|
||||
>
|
||||
{"Preferências // Analytics"}
|
||||
</p>
|
||||
<p
|
||||
id="analytics-preferences-description"
|
||||
className="mt-sp-2 text-[13px] leading-6 text-white/85"
|
||||
>
|
||||
Analytics está {consent === "granted" ? "ativo" : "desativado"}.
|
||||
Cookies de publicidade e personalização permanecem negados.
|
||||
</p>
|
||||
{consentError && (
|
||||
<p
|
||||
role="alert"
|
||||
aria-label="Falha ao salvar o consentimento"
|
||||
className="mt-sp-3 text-[13px] leading-6 text-[var(--slash)]"
|
||||
>
|
||||
{consentError}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
|
||||
{consent === "granted" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
|
||||
onClick={revokeAnalytics}
|
||||
>
|
||||
Revogar analytics
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
|
||||
onClick={acceptAnalytics}
|
||||
>
|
||||
Aceitar analytics
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-white/40 bg-transparent text-white`}
|
||||
onClick={closePreferences}
|
||||
>
|
||||
Fechar preferências
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
className="ds-link ds-link-accent mt-sp-3 inline-block"
|
||||
href="/privacidade"
|
||||
>
|
||||
Ler política de privacidade
|
||||
</a>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GoogleAnalytics() {
|
||||
if (process.env.NODE_ENV !== "production") return null;
|
||||
return <GoogleAnalyticsConsent />;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation";
|
||||
import { Header } from "./Header";
|
||||
|
||||
function HomeWithHeader() {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
useEffect(() => {
|
||||
onLayoutReady();
|
||||
}, [onLayoutReady]);
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<section id="about" aria-label="Sobre">
|
||||
<h2>Sobre</h2>
|
||||
</section>
|
||||
<section id="contact" aria-label="Contato">
|
||||
<h2>Contato</h2>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Header", () => {
|
||||
it("links to commercial destinations without promoting the résumé", () => {
|
||||
render(<Header />);
|
||||
|
||||
const navigation = screen.getByRole("navigation", {
|
||||
name: "Navegação principal",
|
||||
});
|
||||
expect(
|
||||
within(navigation)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual([
|
||||
"/#modalities",
|
||||
"/cases",
|
||||
"/#process",
|
||||
"/#about",
|
||||
"/#experience",
|
||||
]);
|
||||
const contactLink = screen
|
||||
.getAllByRole("link")
|
||||
.find((link) => link.getAttribute("href") === "/#contact");
|
||||
expect(contactLink).toHaveAccessibleName();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /currículo|download cv/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("announces and controls the mobile menu", () => {
|
||||
render(<Header />);
|
||||
|
||||
const menuButton = screen.getByRole("button", { name: "Abrir menu" });
|
||||
expect(menuButton).not.toHaveAttribute("aria-controls");
|
||||
expect(menuButton).toHaveAttribute("aria-expanded", "false");
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
const mobileNavigation = screen.getByRole("navigation", {
|
||||
name: "Navegação mobile",
|
||||
});
|
||||
expect(
|
||||
document.getElementById(menuButton.getAttribute("aria-controls") ?? ""),
|
||||
).toBe(mobileNavigation);
|
||||
expect(
|
||||
within(mobileNavigation)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual([
|
||||
"/#modalities",
|
||||
"/cases",
|
||||
"/#process",
|
||||
"/#about",
|
||||
"/#experience",
|
||||
"/#contact",
|
||||
]);
|
||||
expect(
|
||||
within(mobileNavigation).queryByRole("link", {
|
||||
name: /currículo|download cv/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const mobileLink = within(mobileNavigation).getByRole("link", {
|
||||
name: "Sobre",
|
||||
});
|
||||
mobileLink.focus();
|
||||
fireEvent.keyDown(mobileLink, { key: "Escape" });
|
||||
const openButton = screen.getByRole("button", { name: "Abrir menu" });
|
||||
expect(openButton).toHaveAttribute("aria-expanded", "false");
|
||||
expect(document.activeElement).toBe(openButton);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/#about", "Sobre"],
|
||||
["/#contact", "Contato"],
|
||||
])(
|
||||
"closes the mobile menu and preserves destination focus for %s",
|
||||
(href, destination) => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
render(<HomeWithHeader />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||
const mobileNavigation = screen.getByRole("navigation", {
|
||||
name: "Navegação mobile",
|
||||
});
|
||||
const mobileLink = within(mobileNavigation)
|
||||
.getAllByRole("link")
|
||||
.find((link) => link.getAttribute("href") === href);
|
||||
if (!mobileLink)
|
||||
throw new Error(`Missing navigation destination: ${href}`);
|
||||
mobileLink.focus();
|
||||
fireEvent.click(mobileLink);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Abrir menu" }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: destination })).toHaveFocus();
|
||||
},
|
||||
);
|
||||
});
|
||||
+101
-69
@@ -1,68 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Logo } from "./Logo";
|
||||
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
const navItems = [
|
||||
{ href: "#home", label: "Home" },
|
||||
{ href: "#about", label: "Sobre" },
|
||||
{ href: "#skills", label: "Habilidades" },
|
||||
{ href: "#experience", label: "Experiencia" },
|
||||
{ href: "#projects", label: "Projetos" },
|
||||
{ href: "#modalities", label: "Contratacao" },
|
||||
{ href: "#contact", label: "Contato" },
|
||||
];
|
||||
const MENU_ID = "site-navigation-menu";
|
||||
|
||||
export function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let wasScrolled = false;
|
||||
const updateScrolled = () => {
|
||||
const nextScrolled = window.scrollY > 0;
|
||||
if (nextScrolled === wasScrolled) return;
|
||||
wasScrolled = nextScrolled;
|
||||
setScrolled(nextScrolled);
|
||||
};
|
||||
|
||||
updateScrolled();
|
||||
window.addEventListener("scroll", updateScrolled, { passive: true });
|
||||
return () => window.removeEventListener("scroll", updateScrolled);
|
||||
}, []);
|
||||
|
||||
const closeMobileMenu = () => setMobileMenuOpen(false);
|
||||
|
||||
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key !== "Escape") return;
|
||||
|
||||
event.preventDefault();
|
||||
closeMobileMenu();
|
||||
menuButtonRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="ds-section-dark fixed inset-x-0 top-0 z-50 h-[52px] w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/95 px-sp-6 backdrop-blur-xl lg:px-sp-16">
|
||||
<div className="ds-section-shell flex h-full items-center justify-between">
|
||||
<a href="#home" className="flex items-center gap-3 no-underline">
|
||||
<Logo variant="mark" size="sm" theme="dark" className="shrink-0" />
|
||||
<span className="font-display text-sm font-extrabold tracking-tight text-white">
|
||||
evandro<span className="text-[var(--slash)]">.dev.br</span>
|
||||
</span>
|
||||
<header
|
||||
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
|
||||
scrolled
|
||||
? "border-white/12 bg-[var(--ink)]/95 backdrop-blur-md"
|
||||
: "border-transparent bg-[var(--ink)]"
|
||||
}`}
|
||||
>
|
||||
<div className="ds-section-shell flex h-full items-center justify-between gap-sp-6">
|
||||
<a
|
||||
href="/#home"
|
||||
aria-label="Evandro.dev.br — início"
|
||||
className="flex min-h-11 shrink-0 items-center no-underline"
|
||||
>
|
||||
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
|
||||
</a>
|
||||
|
||||
<nav className="hidden items-center lg:flex">
|
||||
<ul className="flex list-none items-center gap-sp-2">
|
||||
{navItems.map((item) => (
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
className="hidden items-center xl:flex"
|
||||
>
|
||||
<ul className="flex list-none items-center gap-sp-4">
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
<li key={item.href}>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
<a
|
||||
href={item.href}
|
||||
className="ds-link rounded-sm px-sp-3 py-sp-2 text-white/85 transition-colors hover:bg-white/10 hover:text-white"
|
||||
strength={0.15}
|
||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
{item.label}
|
||||
</MagneticButton>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-sp-4">
|
||||
<span className="ds-metric hidden rounded-full border border-white/10 px-[10px] py-1 text-white/40 lg:inline-flex">
|
||||
v1.0 / 2026
|
||||
</span>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/Profile.pdf"
|
||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
||||
className="ds-link ds-link-accent hidden rounded-sm font-semibold transition-shadow hover:shadow-[0_0_0_1px_rgba(232,255,0,.8),0_8px_24px_rgba(232,255,0,.35)] lg:inline-flex"
|
||||
strength={0.2}
|
||||
>
|
||||
Download CV
|
||||
</MagneticButton>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="hidden xl:block">
|
||||
<a
|
||||
href="/#contact"
|
||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
|
||||
>
|
||||
Falar sobre meu projeto
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
className="flex h-8 w-8 flex-col items-center justify-center gap-sp-2 border-none bg-transparent lg:hidden"
|
||||
className="flex h-11 w-11 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] xl:hidden"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-label="Menu"
|
||||
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
|
||||
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
<span
|
||||
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[6px] rotate-45" : ""}`}
|
||||
@@ -77,40 +103,46 @@ export function Header() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute inset-x-0 top-[52px] overflow-hidden border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/98 px-sp-6 backdrop-blur-xl transition-all duration-300 lg:hidden lg:px-sp-16 ${mobileMenuOpen ? "max-h-[420px] opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="ds-section-shell flex flex-col py-sp-6">
|
||||
<nav className="mb-sp-6">
|
||||
<ul className="flex list-none flex-col gap-sp-2">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
{mobileMenuOpen ? (
|
||||
<div className="absolute inset-x-0 top-[var(--site-header-height)] max-h-[calc(100dvh-var(--site-header-height))] overflow-y-auto border-b border-white/12 bg-[var(--ink)] px-sp-6 lg:px-sp-8 xl:hidden">
|
||||
<div className="ds-section-shell flex flex-col py-sp-6">
|
||||
<nav
|
||||
id={MENU_ID}
|
||||
aria-hidden={!mobileMenuOpen}
|
||||
aria-label="Navegação mobile"
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
<ul className="flex list-none flex-col gap-sp-2">
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
<li key={item.href}>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
strength={0}
|
||||
wrapperClassName="w-full"
|
||||
href={item.href}
|
||||
className="flex min-h-11 items-center rounded-sm px-sp-3 py-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
{item.label}
|
||||
</MagneticButton>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={item.href}
|
||||
className="ds-link block rounded-sm py-sp-3 text-white/85 transition-all hover:pl-sp-2 hover:text-white"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
strength={0.15}
|
||||
strength={0}
|
||||
href="/#contact"
|
||||
className="mt-sp-4 inline-flex min-h-11 items-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
{item.label}
|
||||
Falar sobre meu projeto
|
||||
</MagneticButton>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/Profile.pdf"
|
||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
||||
className="ds-link ds-link-accent mt-sp-2 inline-flex rounded-sm font-semibold"
|
||||
strength={0.15}
|
||||
>
|
||||
Download CV
|
||||
</MagneticButton>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +1,52 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { act } from "@testing-library/react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { hydrateRoot, type Root } from "react-dom/client";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import Hero from "./Hero";
|
||||
|
||||
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,
|
||||
};
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Hero reduced motion", () => {
|
||||
afterEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
it.each([false, true])(
|
||||
"hydrates without attribute mismatches when reduced motion resolves to %s",
|
||||
async (reducedMotion) => {
|
||||
jest.spyOn(window, "matchMedia").mockImplementation((media) => ({
|
||||
media,
|
||||
matches: media === "(prefers-reduced-motion: reduce)" && reducedMotion,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
const preference = jest.mocked(useReducedMotion);
|
||||
preference.mockReturnValue(null);
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = renderToString(<Hero />);
|
||||
document.body.append(container);
|
||||
|
||||
it("removes hover/tap scale animations when reduced motion is enabled", () => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
const { container } = render(<Hero />);
|
||||
|
||||
const primaryAction = screen.getByText(/Iniciar projeto/i).closest("a");
|
||||
const secondaryAction = screen.getByText(/Ver projetos/i).closest("a");
|
||||
|
||||
expect(primaryAction).not.toHaveAttribute("data-while-hover");
|
||||
expect(primaryAction).not.toHaveAttribute("data-while-tap");
|
||||
expect(secondaryAction).not.toHaveAttribute("data-while-hover");
|
||||
expect(secondaryAction).not.toHaveAttribute("data-while-tap");
|
||||
|
||||
const rootMotionBlock = container.querySelector(
|
||||
"div[data-initial][data-animate]",
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
preference.mockReturnValue(reducedMotion);
|
||||
const consoleErrors = jest.spyOn(console, "error").mockImplementation();
|
||||
const recoverableErrors: unknown[] = [];
|
||||
let root: Root | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, <Hero />, {
|
||||
onRecoverableError: (error) => recoverableErrors.push(error),
|
||||
});
|
||||
});
|
||||
expect(recoverableErrors).toEqual([]);
|
||||
expect(consoleErrors.mock.calls).toEqual([]);
|
||||
} finally {
|
||||
await act(async () => root?.unmount());
|
||||
container.remove();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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 Hero from "./Hero";
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
const motion = new Proxy(
|
||||
@@ -12,6 +12,9 @@ jest.mock("framer-motion", () => {
|
||||
get: (_, tag: string) => {
|
||||
const Component = ({
|
||||
children,
|
||||
initial: _initial,
|
||||
animate: _animate,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
React.createElement(tag, props, children);
|
||||
@@ -21,42 +24,61 @@ jest.mock("framer-motion", () => {
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
|
||||
motion,
|
||||
useInView: () => true,
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
return { motion, useReducedMotion: () => false };
|
||||
});
|
||||
|
||||
describe("Hero", () => {
|
||||
it("renders the upgraded hero content and primary actions", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Engenharia de software para produtos conectados, interfaces de alta performance e sistemas criticos/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Iniciar projeto/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Ver projetos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Download CV/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument();
|
||||
beforeEach(() => {
|
||||
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||
});
|
||||
|
||||
it("renders social links and proof content", () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("routes project conversations to contact without opening an external service", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getAllByLabelText("GitHub").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("LinkedIn").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("Email").length).toBeGreaterThan(0);
|
||||
const contactLink = screen.getByRole("link", {
|
||||
name: /conversar/i,
|
||||
});
|
||||
expect(contactLink).toHaveAttribute("href", "/#contact");
|
||||
expect(contactLink).not.toHaveAttribute("target", "_blank");
|
||||
fireEvent.click(contactLink);
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("link", { name: /ver projetos/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/cases",
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Especialidade/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Escopo/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Formato/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Scroll/i)).toBeInTheDocument();
|
||||
it("keeps the headline as one readable sentence despite the per-word animation", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"Sistemas sob medida para operações que não cabem em soluções prontas.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps social destinations with isolated external tabs", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/evandrodevbr",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "LinkedIn" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Email" })).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:[email protected]",
|
||||
);
|
||||
for (const name of ["GitHub", "LinkedIn"]) {
|
||||
const link = screen.getByRole("link", { name });
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
});
|
||||
});
|
||||
+139
-374
@@ -1,300 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
|
||||
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
||||
ssr: false,
|
||||
});
|
||||
import Link from "next/link";
|
||||
import { type CSSProperties, Fragment } from "react";
|
||||
import HeroField from "./cinematic/HeroField";
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{
|
||||
href: "https://github.com/evandrodevbr",
|
||||
label: "GitHub",
|
||||
icon: (
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
||||
label: "LinkedIn",
|
||||
icon: (
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "mailto:[email protected]",
|
||||
label: "Email",
|
||||
icon: (
|
||||
<>
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||
<polyline points="22,6 12,13 2,6" />
|
||||
</>
|
||||
),
|
||||
stroke: true,
|
||||
},
|
||||
];
|
||||
|
||||
const HERO_POINTS = [
|
||||
"Produtos conectados, integracoes complexas e operacoes criticas.",
|
||||
"Arquitetura mais clara para evoluir sem aumentar o caos tecnico.",
|
||||
"Execucao ponta a ponta, do discovery a entrega em producao.",
|
||||
];
|
||||
/** Números reais, calculados em app/page.tsx a partir de public/portfolio-data.json (nada digitado à mão). */
|
||||
export type HeroStats = {
|
||||
years?: number;
|
||||
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.",
|
||||
},
|
||||
];
|
||||
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);
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
|
||||
const TITLE =
|
||||
"Sistemas sob medida para operações que não cabem em soluções prontas.".split(
|
||||
" ",
|
||||
);
|
||||
const ACCENT = new Set([1, 2]);
|
||||
|
||||
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?";
|
||||
/** O que cada camada da cena significa, em linguagem simples. */
|
||||
const CAPTIONS = [
|
||||
["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
|
||||
[
|
||||
"As integrações",
|
||||
"Os programas que você já usa conversando entre si, sem retrabalho.",
|
||||
],
|
||||
["Os dados", "Suas informações guardadas, organizadas e seguras."],
|
||||
] as const;
|
||||
|
||||
// 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];
|
||||
|
||||
const heroItem: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 40, filter: "blur(10px)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
duration: 1,
|
||||
ease: easeOut,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const heroItemSlow: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 60, filter: "blur(12px)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
duration: 1.2,
|
||||
ease: easeOut,
|
||||
},
|
||||
},
|
||||
};
|
||||
export default function Hero({ stats }: { stats?: HeroStats }) {
|
||||
const items = statItems(stats);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="home"
|
||||
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||
className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
|
||||
>
|
||||
{/* 3D Scene Background */}
|
||||
{!shouldReduceMotion && <HeroScene />}
|
||||
<div className="hero-stage">
|
||||
<div className="hero-glow" aria-hidden="true" />
|
||||
<HeroField />
|
||||
<div className="hero-scrim" aria-hidden="true" />
|
||||
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
||||
<ol className="hero-captions">
|
||||
{CAPTIONS.map(([title, text], index) => (
|
||||
<li key={title} style={{ "--i": index } as CSSProperties}>
|
||||
<span className="hero-caption-title">{title}</span>
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{/* Cinematic overlays */}
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.4)_50%,var(--ink)_100%)]" />
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
|
||||
|
||||
{/* Scanlines effect */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.015]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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],
|
||||
}}
|
||||
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-8 lg:px-sp-12">
|
||||
<p className="hero-in mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
|
||||
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
||||
Evandro Fonseca Junior · Engenharia de software
|
||||
</p>
|
||||
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
|
||||
<h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw)] text-[clamp(38px,min(6vw,10.5svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
|
||||
{TITLE.map((word, index) => (
|
||||
<Fragment key={word}>
|
||||
<span
|
||||
className={`hero-word${ACCENT.has(index) ? " text-[var(--slash)]" : ""}`}
|
||||
style={{ "--i": index } as CSSProperties}
|
||||
>
|
||||
<span className="mt-[8px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--slash)] shadow-[0_0_8px_rgba(204,255,0,0.5)]" />
|
||||
<span>{point}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
{word}
|
||||
</span>{" "}
|
||||
</Fragment>
|
||||
))}
|
||||
</h1>
|
||||
<p className="hero-in mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s]">
|
||||
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
|
||||
entendimento do negócio à arquitetura e à entrega em produção.
|
||||
</p>
|
||||
|
||||
{/* CTAs with magnetic effect */}
|
||||
<motion.div
|
||||
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
|
||||
variants={heroItem}
|
||||
<div className="hero-in mt-sp-6 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Link
|
||||
href="/#contact"
|
||||
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
||||
>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={`https://wa.me/5547992742365?text=${encodeURIComponent(greeting)}`}
|
||||
className="cinematic-cta-primary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#0A0A0A] no-underline"
|
||||
data-cursor="View"
|
||||
strength={0.25}
|
||||
Conversar sobre meu projeto
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
className="h-4 w-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<span className="relative z-10">Agendar conversa tecnica</span>
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/cases"
|
||||
className="cinematic-cta-secondary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#f4f2ed] no-underline"
|
||||
data-cursor="View"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver casos selecionados
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/assets/Evandro_Fonseca_Junior_CV.pdf"
|
||||
download
|
||||
className="inline-flex min-h-12 items-center justify-center px-sp-2 text-[12px] font-medium text-[rgba(244,242,237,.76)] no-underline transition-colors duration-300 hover:text-[var(--slash)]"
|
||||
strength={0.2}
|
||||
>
|
||||
Download CV
|
||||
</MagneticButton>
|
||||
</motion.div>
|
||||
|
||||
{/* 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}
|
||||
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/cases"
|
||||
className="inline-flex min-h-12 items-center justify-center border border-white/15 bg-[rgba(10,10,10,.35)] px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline backdrop-blur-sm transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
|
||||
>
|
||||
<div className="inline-flex items-center gap-sp-3 text-[11px] uppercase tracking-[0.08em] text-[rgba(244,242,237,.68)]">
|
||||
<span className="h-2 w-2 rounded-full bg-[var(--slash)] shadow-[0_0_8px_var(--slash)]" />
|
||||
Garuva, SC / Brasil
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-sp-3 text-[11px] uppercase tracking-[0.08em] text-[rgba(244,242,237,.68)]">
|
||||
<span className="h-[1px] w-6 bg-[rgba(255,255,255,.2)]" />
|
||||
Next.js, Tauri, Rust, Node.js
|
||||
</div>
|
||||
<div className="flex items-center gap-sp-2">
|
||||
Ver projetos entregues
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
|
||||
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
|
||||
{items.length > 0 && (
|
||||
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
|
||||
{items.map(([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) => (
|
||||
<MagneticButton
|
||||
<a
|
||||
key={link.label}
|
||||
as="a"
|
||||
href={link.href}
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
@@ -302,122 +163,26 @@ export default function Hero() {
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
className="cinematic-social-link inline-flex h-11 w-11 items-center justify-center rounded-full"
|
||||
strength={0.4}
|
||||
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill={link.stroke ? "none" : "currentColor"}
|
||||
stroke={link.stroke ? "currentColor" : "none"}
|
||||
strokeWidth={link.stroke ? "2" : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{link.icon}
|
||||
</svg>
|
||||
</MagneticButton>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
|
||||
Santa Catarina · Atendimento remoto
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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],
|
||||
}}
|
||||
<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"
|
||||
>
|
||||
{/* 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>
|
||||
Role para mergulhar
|
||||
<span className="hero-cue-line" />
|
||||
</p>
|
||||
</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>
|
||||
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</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";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { Section } from "./Section";
|
||||
import { useState } from "react";
|
||||
|
||||
const METRICS = [
|
||||
{
|
||||
score: 100,
|
||||
id: "performance",
|
||||
label: "Performance",
|
||||
code: "LCP / INP / CLS",
|
||||
title: "O essencial chega primeiro.",
|
||||
description:
|
||||
"Cada milissegundo conta. Tempos de carregamento instantâneos retêm mais usuários, aumentam a conversão e garantem que a interface pareça nativa.",
|
||||
"O carregamento começa pelo que importa. Imagens, fontes e JavaScript precisam trabalhar a favor da experiência, não competir com ela.",
|
||||
checks: [
|
||||
"Conteúdo principal com prioridade de carregamento",
|
||||
"Dimensões reservadas para evitar saltos de layout",
|
||||
"JavaScript carregado onde faz diferença",
|
||||
],
|
||||
caption: "Em foco: o conteúdo principal e a estabilidade da página.",
|
||||
},
|
||||
{
|
||||
score: 100,
|
||||
id: "accessibility",
|
||||
label: "Acessibilidade",
|
||||
code: "TECLADO / FOCO / SEMÂNTICA",
|
||||
title: "A interface inclui mais pessoas.",
|
||||
description:
|
||||
"Acesso universal. Contraste perfeito, navegação por teclado e semântica adequada garantindo o uso por todos os perfis e mitigando riscos.",
|
||||
"Uma boa interface não exige um jeito único de navegar. Teclado, leitores de tela e preferências de movimento fazem parte do projeto.",
|
||||
checks: [
|
||||
"Foco visível e navegação por teclado",
|
||||
"Controles com nomes e estrutura semântica",
|
||||
"Contraste legível e movimento reduzido",
|
||||
],
|
||||
caption:
|
||||
"Em foco: controles reconhecíveis e um caminho claro pelo teclado.",
|
||||
},
|
||||
{
|
||||
score: 100,
|
||||
label: "Best Practices",
|
||||
id: "practices",
|
||||
label: "Boas práticas",
|
||||
code: "HTTPS / PRIVACIDADE / DEPENDÊNCIAS",
|
||||
title: "Confiança também se constrói.",
|
||||
description:
|
||||
"Código seguro e moderno. Uso das APIs mais recentes, prevenção inteligente contra vulnerabilidades comuns e front-end limpo.",
|
||||
"Cuidar da entrega também é cuidar dos dados, das dependências e dos limites do navegador. Segurança não se resume a uma nota.",
|
||||
checks: [
|
||||
"Conexões seguras e políticas do navegador",
|
||||
"Analytics condicionado ao consentimento",
|
||||
"Dependências verificadas e atualizadas",
|
||||
],
|
||||
caption: "Em foco: a conexão e os limites de acesso aos dados.",
|
||||
},
|
||||
{
|
||||
score: 100,
|
||||
id: "seo",
|
||||
label: "SEO",
|
||||
code: "HTML / METADATA / INDEXAÇÃO",
|
||||
title: "Conteúdo pronto para ser encontrado.",
|
||||
description:
|
||||
"Rankeamento orgânico superior. Estrutura impecável para indexação precisa pelos motores de busca e otimização para crawlers sociais.",
|
||||
"Estrutura antes de promessas. Conteúdo claro, títulos coerentes e dados estruturados ajudam pessoas e buscadores a entender cada página.",
|
||||
checks: [
|
||||
"Títulos, descrições e URLs canônicas",
|
||||
"HTML semântico e conteúdo rastreável",
|
||||
"Sitemap e dados estruturados factuais",
|
||||
],
|
||||
caption: "Em foco: a estrutura que dá contexto ao conteúdo.",
|
||||
},
|
||||
];
|
||||
|
||||
const BLUEPRINT = (
|
||||
<svg
|
||||
className="quality-blueprint"
|
||||
viewBox="0 0 640 440"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<g className="quality-dimensions">
|
||||
<path d="M64 68V48m512 20V48M64 58h512M44 86H24m20 284H24M34 86v284" />
|
||||
<path d="m60 54 8 8m504-8 8 8M30 82l8 8m-8 276 8 8" />
|
||||
<text x="304" y="47">
|
||||
RESPONSIVE
|
||||
</text>
|
||||
<path d="M64 390v18m512-18v18M64 400h180m152 0h180" />
|
||||
<text x="272" y="405">
|
||||
WEB / BLUEPRINT
|
||||
</text>
|
||||
</g>
|
||||
<rect
|
||||
className="quality-browser-frame"
|
||||
x="64"
|
||||
y="86"
|
||||
width="512"
|
||||
height="284"
|
||||
rx="8"
|
||||
/>
|
||||
<path className="quality-wire" d="M64 123h512" />
|
||||
<g className="quality-browser-dots">
|
||||
<circle cx="83" cy="105" r="3" />
|
||||
<circle cx="95" cy="105" r="3" />
|
||||
<circle cx="107" cy="105" r="3" />
|
||||
</g>
|
||||
<rect
|
||||
className="quality-wire"
|
||||
x="142"
|
||||
y="96"
|
||||
width="350"
|
||||
height="18"
|
||||
rx="3"
|
||||
/>
|
||||
<text className="quality-address" x="158" y="109">
|
||||
evandro.dev.br
|
||||
</text>
|
||||
<path className="quality-wire" d="M539 100h15m-15 5h15m-15 5h15" />
|
||||
<text className="quality-browser-heading" x="96" y="176">
|
||||
Engenharia
|
||||
</text>
|
||||
<text className="quality-browser-heading" x="96" y="203">
|
||||
que funciona.
|
||||
</text>
|
||||
<path className="quality-wire" d="M96 222h170M96 230h130" />
|
||||
<rect
|
||||
className="quality-browser-button"
|
||||
x="96"
|
||||
y="254"
|
||||
width="142"
|
||||
height="32"
|
||||
rx="2"
|
||||
/>
|
||||
<text className="quality-browser-button-label" x="112" y="274">
|
||||
EXPLORAR PROJETO
|
||||
</text>
|
||||
<rect
|
||||
className="quality-wire"
|
||||
x="330"
|
||||
y="144"
|
||||
width="216"
|
||||
height="142"
|
||||
rx="3"
|
||||
/>
|
||||
<g className="quality-wire">
|
||||
<path d="m438 164 58 33v55l-58 33-58-33v-55zM380 197l58 33 58-33m-58 33v55m0-121v66" />
|
||||
<path d="m409 181 58 33v55m-58 0v-55l58-33" />
|
||||
<path d="M96 306h450M96 324h113m-113 10h80m84-10h113m-113 10h80m84-10h122m-122 10h80" />
|
||||
</g>
|
||||
<g className="quality-layer" data-layer="performance">
|
||||
<rect
|
||||
className="quality-highlight"
|
||||
x="318"
|
||||
y="133"
|
||||
width="240"
|
||||
height="165"
|
||||
rx="4"
|
||||
/>
|
||||
<path className="quality-trace" pathLength="1" d="M558 216h43V59h-91" />
|
||||
<circle cx="558" cy="216" r="4" />
|
||||
<text x="393" y="63">
|
||||
LCP / CONTEÚDO
|
||||
</text>
|
||||
<path d="M330 299v12m216-12v12m-216-6h216" />
|
||||
<rect
|
||||
className="quality-annotation"
|
||||
x="368"
|
||||
y="311"
|
||||
width="154"
|
||||
height="18"
|
||||
/>
|
||||
<text x="376" y="323">
|
||||
ESPAÇO RESERVADO
|
||||
</text>
|
||||
</g>
|
||||
<g className="quality-layer" data-layer="accessibility">
|
||||
<rect
|
||||
className="quality-highlight quality-focus-frame"
|
||||
x="88"
|
||||
y="246"
|
||||
width="158"
|
||||
height="48"
|
||||
rx="5"
|
||||
/>
|
||||
<path className="quality-trace" pathLength="1" d="M88 270H50v149h20" />
|
||||
<circle cx="88" cy="270" r="4" />
|
||||
<text x="79" y="424">
|
||||
TAB / FOCO VISÍVEL
|
||||
</text>
|
||||
<rect x="260" y="259" width="45" height="24" rx="3" />
|
||||
<text x="270" y="275">
|
||||
TAB
|
||||
</text>
|
||||
</g>
|
||||
<g className="quality-layer" data-layer="practices">
|
||||
<rect
|
||||
className="quality-highlight"
|
||||
x="134"
|
||||
y="91"
|
||||
width="366"
|
||||
height="28"
|
||||
rx="4"
|
||||
/>
|
||||
<path className="quality-trace" pathLength="1" d="M500 105h101v314h-90" />
|
||||
<circle cx="500" cy="105" r="4" />
|
||||
<path
|
||||
className="quality-highlight"
|
||||
d="m499 220 36 13v32c0 25-36 41-36 41s-36-16-36-41v-32z"
|
||||
/>
|
||||
<rect x="487" y="255" width="24" height="20" rx="2" />
|
||||
<path d="M491 255v-7a8 8 0 0 1 16 0v7m-8 6v7" />
|
||||
<text x="290" y="424">
|
||||
HTTPS / CONSENTIMENTO
|
||||
</text>
|
||||
</g>
|
||||
<g className="quality-layer" data-layer="seo">
|
||||
<rect
|
||||
className="quality-highlight"
|
||||
x="85"
|
||||
y="144"
|
||||
width="224"
|
||||
height="95"
|
||||
rx="3"
|
||||
/>
|
||||
<path className="quality-trace" pathLength="1" d="M85 159H48V58h135" />
|
||||
<circle cx="85" cy="159" r="4" />
|
||||
<text x="74" y="49">
|
||||
HTML / SEMÂNTICA
|
||||
</text>
|
||||
<text x="99" y="140">
|
||||
<h1>
|
||||
</text>
|
||||
<path d="M331 317h213v29H331z" />
|
||||
<text x="350" y="337">
|
||||
<article> / <nav>
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function LighthouseMetrics() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [active, setActive] = useState(METRICS[0]);
|
||||
|
||||
return (
|
||||
<Section
|
||||
<section
|
||||
id="lighthouse"
|
||||
eyebrow="Quality & Performance"
|
||||
title="Engenharia de ponta a ponta: 100/100 não é vaidade."
|
||||
description="Sistemas não podem apenas parecer bons. Eles precisam ser consistentes, rápidos, acessíveis, seguros e fáceis de processar. Entregar métricas máximas significa que a fundação técnica do projeto está preparada para escalar sem fricção."
|
||||
dark
|
||||
disableChildrenAnimation={false}
|
||||
className="ds-section ds-section-dark quality-lab"
|
||||
aria-labelledby="quality-title"
|
||||
data-inspection={active.id}
|
||||
>
|
||||
<div className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{METRICS.map((metric, i) => (
|
||||
<motion.article
|
||||
key={metric.label}
|
||||
className="ds-card flex flex-col items-center gap-sp-6 text-center lg:items-start lg:text-left"
|
||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
|
||||
whileInView={
|
||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||
}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.6, delay: i * 0.1 }}
|
||||
>
|
||||
{/* The Ring Container */}
|
||||
<div className="relative flex h-[100px] w-[100px] shrink-0 items-center justify-center rounded-full bg-[rgba(0,0,0,0.3)] shadow-inner">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full rotate-[-90deg] drop-shadow-[0_0_12px_rgba(12,206,107,0.35)]"
|
||||
viewBox="0 0 36 36"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Background Track */}
|
||||
<path
|
||||
className="text-[rgba(255,255,255,0.1)]"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
{/* Animated Value Track */}
|
||||
<motion.path
|
||||
className="text-[#0cce6b]"
|
||||
initial={{ strokeDasharray: "0, 100" }}
|
||||
whileInView={{ strokeDasharray: `${metric.score}, 100` }}
|
||||
viewport={{ once: true }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
ease: "easeOut",
|
||||
delay: i * 0.15 + 0.2,
|
||||
}}
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="font-mono text-[24px] font-bold tracking-tight text-[#0cce6b]">
|
||||
{metric.score}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ds-section-shell">
|
||||
<div className="quality-intro">
|
||||
<header className="quality-copy">
|
||||
<p className="ds-eyebrow">Critérios de entrega</p>
|
||||
<h2 id="quality-title" className="ds-section-title">
|
||||
Engenharia de ponta a ponta.
|
||||
<span>Qualidade em cada camada.</span>
|
||||
</h2>
|
||||
<p>
|
||||
Sistemas precisam mais do que uma boa aparência. Explore as
|
||||
decisões que tornam uma interface rápida, acessível, confiável e
|
||||
fácil de encontrar.
|
||||
</p>
|
||||
<a className="ds-link ds-link-accent" href="/#contact">
|
||||
Conversar sobre a qualidade do seu projeto
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{/* Content Details */}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]">
|
||||
{metric.label}
|
||||
</h3>
|
||||
<p className="mt-sp-3 text-[13px] leading-[1.8] text-[rgba(255,255,255,.75)]">
|
||||
{metric.description}
|
||||
</p>
|
||||
<figure className="quality-visual bg-grid-light">
|
||||
<div className="quality-visual-label">
|
||||
<span>Laboratório de qualidade</span>
|
||||
<span>Modelo interativo</span>
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
{BLUEPRINT}
|
||||
<figcaption>
|
||||
{METRICS.map((metric) => (
|
||||
<span key={metric.id} aria-hidden={active.id !== metric.id}>
|
||||
{metric.caption}
|
||||
</span>
|
||||
))}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<fieldset className="quality-controls" aria-describedby="quality-hint">
|
||||
<legend className="sr-only">Escolha um aspecto da qualidade</legend>
|
||||
{METRICS.map((metric) => (
|
||||
<label key={metric.id} className="quality-control">
|
||||
<input
|
||||
type="radio"
|
||||
name="quality-pillar"
|
||||
value={metric.id}
|
||||
checked={active.id === metric.id}
|
||||
onChange={() => setActive(metric)}
|
||||
aria-controls="quality-report"
|
||||
/>
|
||||
<span className="quality-control-content">
|
||||
<span className="quality-control-name">{metric.label}</span>
|
||||
<span className="quality-control-target">Ver critérios</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<p id="quality-hint" className="quality-hint">
|
||||
Selecione um pilar para explorar. No teclado, use as setas.
|
||||
</p>
|
||||
|
||||
<section
|
||||
id="quality-report"
|
||||
className="quality-report-stack"
|
||||
aria-label="Inspeção de qualidade"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{METRICS.map((metric) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className="quality-report"
|
||||
aria-hidden={active.id !== metric.id}
|
||||
>
|
||||
<div className="quality-report-copy">
|
||||
<p className="quality-code">{metric.code}</p>
|
||||
<h3>{metric.title}</h3>
|
||||
<p>{metric.description}</p>
|
||||
</div>
|
||||
<ul>
|
||||
{metric.checks.map((check) => (
|
||||
<li key={check}>
|
||||
<span aria-hidden="true">/</span>
|
||||
{check}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<p className="quality-disclaimer">
|
||||
Exemplos de critérios avaliados conforme o projeto. Este painel não
|
||||
representa uma auditoria, certificação ou garantia de resultado.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+14
-173
@@ -1,184 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
|
||||
interface LogoProps {
|
||||
variant?: "mark" | "wordmark" | "icon";
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
theme?: "dark" | "light" | "accent";
|
||||
theme?: "dark" | "light";
|
||||
className?: string;
|
||||
showTagline?: boolean;
|
||||
loading?: "eager" | "lazy";
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: { width: 32, height: 32 },
|
||||
md: { width: 48, height: 48 },
|
||||
lg: { width: 64, height: 64 },
|
||||
xl: { width: 120, height: 80 },
|
||||
};
|
||||
|
||||
export function Logo({
|
||||
variant = "mark",
|
||||
size = "md",
|
||||
theme = "dark",
|
||||
className = "",
|
||||
showTagline = false,
|
||||
className = "w-48",
|
||||
loading = "lazy",
|
||||
}: Readonly<LogoProps>) {
|
||||
const dimensions = sizeMap[size];
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const colors = {
|
||||
dark: {
|
||||
primary: "#FFFFFF",
|
||||
slash: "#CCFF00",
|
||||
},
|
||||
light: {
|
||||
primary: "#0A0A0A",
|
||||
slash: "#0A0A0A",
|
||||
},
|
||||
accent: {
|
||||
primary: "#0A0A0A",
|
||||
slash: "rgba(0,0,0,.3)",
|
||||
},
|
||||
};
|
||||
|
||||
const c = colors[theme];
|
||||
|
||||
const hoverMotion = shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: 1.03,
|
||||
rotate: -2,
|
||||
};
|
||||
|
||||
const hoverTransition = {
|
||||
type: "spring" as const,
|
||||
stiffness: 280,
|
||||
damping: 18,
|
||||
};
|
||||
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<motion.svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Evandro icon logo"
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<title>Evandro icon logo</title>
|
||||
<rect width="64" height="64" rx="14" fill={c.primary} />
|
||||
<rect
|
||||
x="10"
|
||||
y="10"
|
||||
width="7"
|
||||
height="44"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<rect
|
||||
x="17"
|
||||
y="10"
|
||||
width="18"
|
||||
height="7"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<rect
|
||||
x="17"
|
||||
y="28.5"
|
||||
width="14"
|
||||
height="7"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<rect
|
||||
x="17"
|
||||
y="47"
|
||||
width="18"
|
||||
height="7"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<polygon points="37,38 41,38 50,26 46,26" fill={c.slash} />
|
||||
<circle
|
||||
cx="52"
|
||||
cy="47"
|
||||
r="5"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "wordmark") {
|
||||
return (
|
||||
<motion.div
|
||||
className={`flex items-center gap-3 ${className}`}
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 48 32"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Evandro wordmark logo"
|
||||
>
|
||||
<title>Evandro wordmark logo</title>
|
||||
<rect x="2" y="2" width="7" height="28" fill={c.primary} />
|
||||
<rect x="9" y="2" width="20" height="7" fill={c.primary} />
|
||||
<rect x="9" y="12.5" width="16" height="7" fill={c.primary} />
|
||||
<rect x="9" y="23" width="20" height="7" fill={c.primary} />
|
||||
<polygon points="30,22 34,22 42,10 38,10" fill={c.slash} />
|
||||
<circle cx="44" cy="25" r="4" fill={c.primary} />
|
||||
</svg>
|
||||
<div>
|
||||
<div
|
||||
className="font-display text-lg font-extrabold tracking-tight"
|
||||
style={{ color: c.primary }}
|
||||
>
|
||||
evandro<span style={{ color: c.slash }}>.</span>dev
|
||||
<span style={{ color: c.slash }}>.</span>br
|
||||
</div>
|
||||
{showTagline && (
|
||||
<div
|
||||
className="mt-1 text-[9px] tracking-widest"
|
||||
style={{
|
||||
color:
|
||||
theme === "dark"
|
||||
? "rgba(255,255,255,.25)"
|
||||
: "rgba(10,10,10,.4)",
|
||||
}}
|
||||
>
|
||||
DESENVOLVIMENTO DE SOFTWARE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 120 80"
|
||||
fill="none"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Evandro mark logo"
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<title>Evandro mark logo</title>
|
||||
<rect x="10" y="8" width="9" height="64" fill={c.primary} />
|
||||
<rect x="19" y="8" width="28" height="9" fill={c.primary} />
|
||||
<rect x="19" y="35.5" width="22" height="9" fill={c.primary} />
|
||||
<rect x="19" y="63" width="28" height="9" fill={c.primary} />
|
||||
<polygon points="50,55 57,55 74,25 67,25" fill={c.slash} />
|
||||
<circle cx="80" cy="63" r="7" fill={c.primary} />
|
||||
</motion.svg>
|
||||
<Image
|
||||
src={`/brand/wordmark-${theme}.webp`}
|
||||
alt="Evandro.dev.br"
|
||||
width={1600}
|
||||
height={330}
|
||||
sizes="240px"
|
||||
loading={loading}
|
||||
className={`block h-auto max-w-full ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+150
-118
@@ -1,48 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { Section } from "./Section";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
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() {
|
||||
const { data, loading, error } = usePortfolioData();
|
||||
export default function Projects({
|
||||
projects,
|
||||
}: {
|
||||
projects: PortfolioProjectView[];
|
||||
}) {
|
||||
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 = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 24, clipPath: "inset(100% 0 0 0)" },
|
||||
hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
|
||||
@@ -52,100 +30,154 @@ export default function Projects() {
|
||||
return (
|
||||
<Section
|
||||
id="projects"
|
||||
eyebrow="Selected work"
|
||||
title="Projetos em Next.js, Tauri, Rust e integracao de sistemas"
|
||||
description="Cases e produtos que mostram minha atuacao em engenharia de software, consultoria tecnica e construcao de sistemas de alta exigencia."
|
||||
eyebrow="Projetos"
|
||||
title="Veja o que já saiu do papel."
|
||||
description="Sistemas e aplicações com contexto, decisões técnicas e limites documentados. Explore os cases para entender o problema e a solução, além da interface."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<motion.div
|
||||
className="ds-grid-2"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={gridVariants}
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<motion.article
|
||||
key={project.id}
|
||||
className="ds-card h-full"
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 18 }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-sp-6">
|
||||
<div className="max-w-[560px]">
|
||||
<div className="ds-kicker">{project.category}</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">{project.title}</h3>
|
||||
{project.subtitle && (
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
|
||||
{project.subtitle}
|
||||
</p>
|
||||
<div className="ds-grid-fluid">
|
||||
{projects.map((project) => {
|
||||
const caseStudy = caseStudies.find(
|
||||
(entry) => entry.id === project.id,
|
||||
);
|
||||
return (
|
||||
<motion.article
|
||||
key={project.id}
|
||||
className="ds-card h-full"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: "some" }}
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 18 }}
|
||||
>
|
||||
<ProjectCover project={project} />
|
||||
<div className="flex flex-wrap items-start justify-between gap-sp-6">
|
||||
<div className="max-w-[560px]">
|
||||
<div className="ds-kicker">{project.category}</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">{project.title}</h3>
|
||||
{project.subtitle && (
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
|
||||
{project.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<MagneticButton
|
||||
as="span"
|
||||
className="ds-tag"
|
||||
strength={0.15}
|
||||
wrapperClassName="shrink-0 max-sm:ml-auto"
|
||||
>
|
||||
{project.status}
|
||||
</MagneticButton>
|
||||
</div>
|
||||
|
||||
<p className="ds-copy">
|
||||
{caseStudy?.summary ?? project.description}
|
||||
</p>
|
||||
{caseStudy && (
|
||||
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
|
||||
<p className="ds-kicker">O desafio</p>
|
||||
<p className="ds-copy">{caseStudy.impact}</p>
|
||||
</div>
|
||||
)}
|
||||
{caseStudy?.role && (
|
||||
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
|
||||
<p className="ds-kicker">Minha participação</p>
|
||||
<p className="ds-copy">{caseStudy.role}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
|
||||
{project.technologies.slice(0, 5).map((tech) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<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 && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-muted"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver codigo
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoUrl &&
|
||||
(project.demoAvailability === "available" ||
|
||||
project.demoAvailability === "unknown") && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
Abrir projeto
|
||||
{project.demoAvailability === "unknown" && (
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
Disponibilidade não verificada
|
||||
</span>
|
||||
)}
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoAvailability === "unavailable" && (
|
||||
<span className="ds-link ds-link-accent" aria-disabled="true">
|
||||
Demo indisponível na última verificação
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<MagneticButton as="span" className="ds-tag" strength={0.15}>{project.status}</MagneticButton>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="ds-copy">{project.description}</p>
|
||||
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
{project.technologies.slice(0, 5).map((tech: string) => (
|
||||
<MagneticButton key={tech} as="li" className="ds-tag" strength={0.15}>
|
||||
{tech}
|
||||
</MagneticButton>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
{project.githubUrl && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-muted"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver codigo
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoUrl && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
Abrir projeto
|
||||
</MagneticButton>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
||||
<MagneticButton as="a" href="/cases" className="ds-link ds-link-accent" strength={0.2}>
|
||||
<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}
|
||||
>
|
||||
Explorar todos os cases
|
||||
</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
|
||||
</MagneticButton>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface RevealProps {
|
||||
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";
|
||||
|
||||
import { motion, type Variants } from "motion/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 { TextDiffuseReveal } from "./animations/TextDiffuseReveal";
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { TopProgressBar } from "./animations/TopProgressBar";
|
||||
|
||||
interface SectionLoadingProps {
|
||||
@@ -14,6 +15,11 @@ export function SectionLoading({
|
||||
skeletons = 2,
|
||||
}: Readonly<SectionLoadingProps>) {
|
||||
const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section";
|
||||
const skeletonId = useId();
|
||||
const skeletonKeys = Array.from(
|
||||
{ length: skeletons },
|
||||
(_, index) => `${skeletonId}-${index}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={sectionClass}>
|
||||
@@ -21,8 +27,8 @@ export function SectionLoading({
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-loading">{message}</div>
|
||||
<div className="ds-grid-2 mt-sp-6" aria-hidden="true">
|
||||
{Array.from({ length: skeletons }).map((_, index) => (
|
||||
<div key={`skeleton-${index}`} className="ds-card ds-skeleton-card">
|
||||
{skeletonKeys.map((skeletonKey) => (
|
||||
<div key={skeletonKey} className="ds-card ds-skeleton-card">
|
||||
<div className="ds-skeleton ds-skeleton-kicker" />
|
||||
<div className="ds-skeleton ds-skeleton-title" />
|
||||
<div className="ds-skeleton ds-skeleton-line" />
|
||||
|
||||
+19
-25
@@ -1,24 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { Section } from "./Section";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { PortfolioSkillCategory } from "@/lib/portfolio-data";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { SectionLoading } from "./SectionLoading";
|
||||
import { Section } from "./Section";
|
||||
|
||||
export default function Skills() {
|
||||
const { data, loading, error } = usePortfolioData();
|
||||
export default function Skills({
|
||||
categories,
|
||||
}: {
|
||||
categories: PortfolioSkillCategory[];
|
||||
}) {
|
||||
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 = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
@@ -47,8 +41,8 @@ export default function Skills() {
|
||||
<Section
|
||||
id="skills"
|
||||
eyebrow="Core stack"
|
||||
title="Next.js, Tauri, Rust e stack para produtos criticos"
|
||||
description="Tecnologias e fundamentos que uso em engenharia de software, arquitetura de software, IoT e integracoes complexas."
|
||||
title="Next.js, Tauri, Rust e stack para produtos críticos"
|
||||
description="Tecnologias e fundamentos que uso em engenharia de software, arquitetura de software, IoT e integrações complexas."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
@@ -59,7 +53,7 @@ export default function Skills() {
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={gridVariants}
|
||||
>
|
||||
{data.skills.technical.map((category) => (
|
||||
{categories.map((category) => (
|
||||
<motion.article
|
||||
key={category.category}
|
||||
className="ds-card h-full"
|
||||
@@ -75,17 +69,19 @@ export default function Skills() {
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<h3 className="ds-card-title !mt-0">{category.category}</h3>
|
||||
<h3 className="ds-card-title !mt-0" data-reel="left">
|
||||
{category.category}
|
||||
</h3>
|
||||
<DrawLine delay={0.05} />
|
||||
<p className="ds-copy !mt-sp-4">
|
||||
<p className="ds-copy !mt-sp-4" data-reel="right">
|
||||
Ferramentas que uso para construir com clareza tecnica e ritmo de
|
||||
entrega.
|
||||
</p>
|
||||
<ul className="ds-tags">
|
||||
<ul className="ds-tags" data-reel-stagger="fade">
|
||||
{category.skills.map((skill) => (
|
||||
<MagneticButton key={skill} as="li" className="ds-tag" strength={0.15}>
|
||||
<li key={skill} className="ds-tag">
|
||||
{skill}
|
||||
</MagneticButton>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.article>
|
||||
@@ -94,5 +90,3 @@ export default function Skills() {
|
||||
</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";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
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 { Section } from "./Section";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface Tier {
|
||||
id: string;
|
||||
badge: string;
|
||||
level: string;
|
||||
title: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
@@ -24,64 +24,83 @@ interface Tier {
|
||||
|
||||
const TIERS: Tier[] = [
|
||||
{
|
||||
id: "discovery",
|
||||
badge: "Entrada / Baixo Risco",
|
||||
level: "01",
|
||||
title: "Auditoria & Diagnostico Tecnico",
|
||||
tagline: "Fotografia clara do problema antes de qualquer comprometimento.",
|
||||
id: "build",
|
||||
badge: "Do zero à produção",
|
||||
title: "Um sistema para a sua operação",
|
||||
tagline: "Quando as ferramentas prontas já não atendem.",
|
||||
description:
|
||||
"Analise profunda de arquitetura, performance ou seguranca com entrega de um plano de acao executavel. Ideal para quem precisa de direcao antes de contratar execucao.",
|
||||
"Transforme regras de negócio, planilhas e processos dispersos em um sistema próprio, com os fluxos e as integrações que a empresa precisa.",
|
||||
deliverables: [
|
||||
"Relatorio de gargalos e divida tecnica",
|
||||
"Mapeamento de riscos e dependencias",
|
||||
"Sugestao de stack e cronograma de mitigacao",
|
||||
"Plano de acao priorizado",
|
||||
"Definição de escopo e arquitetura",
|
||||
"Interface, backend e banco de dados",
|
||||
"Integrações e permissões de acesso",
|
||||
"Validação, publicação e documentação",
|
||||
],
|
||||
pricing: "Valor Fixo",
|
||||
pricingNote: "Contrato unico. Entrega em ate 5 dias uteis.",
|
||||
featured: false,
|
||||
ctaLabel: "Solicitar diagnostico",
|
||||
ctaHref: "#contact",
|
||||
},
|
||||
{
|
||||
id: "cycles",
|
||||
badge: "Mais Escolhido",
|
||||
level: "02",
|
||||
title: "Ciclos de Engenharia",
|
||||
tagline: "Capacidade produtiva garantida. Entrega a cada 2 semanas.",
|
||||
description:
|
||||
"Desenvolvimento continuo com a previsibilidade de orcamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.",
|
||||
deliverables: [
|
||||
"Features em producao a cada ciclo",
|
||||
"Refatoracao iterativa com rastreabilidade",
|
||||
"Documentacao tecnica atualizada",
|
||||
"Capacidade de pivotar prioridades no inicio de cada sprint",
|
||||
],
|
||||
pricing: "Fixo por Ciclo",
|
||||
pricingNote: "Assinatura quinzenal ou mensal. Cancele a qualquer ciclo.",
|
||||
pricing: "Proposta por projeto",
|
||||
pricingNote: "Escopo, etapas e investimento definidos antes da execução.",
|
||||
featured: true,
|
||||
ctaLabel: "Iniciar ciclo de engenharia",
|
||||
ctaHref: "#contact",
|
||||
ctaLabel: "Conversar sobre meu sistema",
|
||||
ctaHref: "/#contact",
|
||||
},
|
||||
{
|
||||
id: "fractional",
|
||||
badge: "Premium / High-Ticket",
|
||||
level: "03",
|
||||
title: "Fractional Tech Lead",
|
||||
tagline: "Lideranca tecnica senior. Sem o custo de um CTO full-time.",
|
||||
id: "discovery",
|
||||
badge: "Antes de investir",
|
||||
title: "Diagnóstico de um sistema existente",
|
||||
tagline: "Entenda o que aproveitar e o que precisa mudar.",
|
||||
description:
|
||||
"Direcao arquitetural, gestao de risco e sustentacao de operacoes criticas. Para empresas que precisam de decisoes de alto impacto com responsabilidade continua.",
|
||||
"Seu sistema ficou lento, frágil ou difícil de manter? Avalio o código e a operação para orientar a próxima decisão, inclusive em projetos iniciados com IA.",
|
||||
deliverables: [
|
||||
"SLA prioritario Zero-Risk",
|
||||
"Code review e lideranca do time interno",
|
||||
"Decisoes de infra: Cloud, VPS, CI/CD",
|
||||
"Consultoria estrategica recorrente",
|
||||
"Levantamento de riscos e dependências",
|
||||
"Revisão de acesso aos dados",
|
||||
"Análise dos gargalos da aplicação",
|
||||
"Plano de correção por prioridade",
|
||||
],
|
||||
pricing: "Retainer Mensal",
|
||||
pricingNote: "Renovacao mensal. Vagas limitadas.",
|
||||
featured: false,
|
||||
ctaLabel: "Agendar conversa estrategica",
|
||||
ctaHref: "#contact",
|
||||
pricing: "Escopo de análise definido",
|
||||
pricingNote: "Profundidade e prazo combinados conforme o sistema.",
|
||||
ctaLabel: "Avaliar meu projeto",
|
||||
ctaHref: "/#contact",
|
||||
},
|
||||
{
|
||||
id: "evolution",
|
||||
badge: "Continuidade",
|
||||
title: "Integração e evolução",
|
||||
tagline: "Faça o que já existe trabalhar melhor.",
|
||||
description:
|
||||
"Conecte ferramentas, reduza tarefas manuais e acrescente funcionalidades ao sistema atual, com atenção às dependências e ao impacto de cada mudança.",
|
||||
deliverables: [
|
||||
"Integração entre sistemas e equipamentos",
|
||||
"Novos fluxos e automações",
|
||||
"Testes das partes afetadas",
|
||||
"Planejamento de manutenção e operação",
|
||||
],
|
||||
pricing: "Etapas acordadas",
|
||||
pricingNote:
|
||||
"Prioridades, suporte e responsabilidades definidos em contrato.",
|
||||
ctaLabel: "Planejar a próxima etapa",
|
||||
ctaHref: "/#contact",
|
||||
},
|
||||
];
|
||||
|
||||
const PROCESS = [
|
||||
{
|
||||
title: "Entender a operação",
|
||||
description:
|
||||
"Quem usa, qual problema precisa resolver e quais dados e ferramentas já existem.",
|
||||
},
|
||||
{
|
||||
title: "Definir a entrega",
|
||||
description:
|
||||
"Escopo, arquitetura, riscos, investimento e critérios de aceite antes de desenvolver.",
|
||||
},
|
||||
{
|
||||
title: "Construir e validar",
|
||||
description:
|
||||
"Entregas por etapa, demonstrações e testes dos fluxos, das permissões e das integrações.",
|
||||
},
|
||||
{
|
||||
title: "Publicar e dar continuidade",
|
||||
description:
|
||||
"Implantação, documentação e condições de manutenção combinadas para a operação.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -111,15 +130,12 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
||||
<span className="ds-kicker max-md:text-[10px] max-md:leading-[1.6]">
|
||||
{tier.badge}
|
||||
</span>
|
||||
<span className="text-[11px] tracking-[0.08em] text-[var(--meta)]">
|
||||
{tier.level}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DrawLine delay={0.04} />
|
||||
|
||||
<div className="mt-sp-4 min-w-0">
|
||||
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(22px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
|
||||
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(19px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
|
||||
{tier.title}
|
||||
</h3>
|
||||
<p className="mt-sp-2 text-[12px] leading-[1.7] text-[var(--meta)] md:text-[11px]">
|
||||
@@ -127,11 +143,17 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]">
|
||||
<p
|
||||
className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
|
||||
data-reel="right"
|
||||
>
|
||||
{tier.description}
|
||||
</p>
|
||||
|
||||
<ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]">
|
||||
<ul
|
||||
className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
|
||||
data-reel-stagger="fade"
|
||||
>
|
||||
{tier.deliverables.map((deliverable) => (
|
||||
<li key={deliverable} className="pl-sp-1">
|
||||
{deliverable}
|
||||
@@ -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">
|
||||
{tier.pricing}
|
||||
</div>
|
||||
<div className="text-[12px] leading-[1.7] text-[var(--meta)]">
|
||||
<div className="font-display text-[12px] leading-[1.7] text-[var(--meta)]">
|
||||
{tier.pricingNote}
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,37 +214,49 @@ export function WorkModalities() {
|
||||
return (
|
||||
<Section
|
||||
id="modalities"
|
||||
eyebrow="Working models"
|
||||
title="Servicos de engenharia de software e consultoria tecnica."
|
||||
description="Formatos de contratacao para arquitetura de software, integracoes criticas, produtos conectados e evolucao de sistemas em producao."
|
||||
eyebrow="Soluções"
|
||||
title="Software feito a partir do seu negócio."
|
||||
description="Para empresas que precisam de um sistema próprio, de ferramentas conectadas ou de uma avaliação técnica antes de dar o próximo passo."
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div className="mb-sp-8 ds-card bg-[rgba(10,10,10,0.02)]">
|
||||
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-[760px]">
|
||||
<div className="ds-kicker">Paginas de servico</div>
|
||||
<h3 className="ds-card-title !mt-sp-3">Especialidades organizadas por intencao de busca</h3>
|
||||
<p className="ds-copy !mt-sp-3 max-w-[62ch]">
|
||||
Se voce chegou buscando uma stack, um tipo de consultoria ou um contexto tecnico especifico,
|
||||
aqui esta o caminho mais direto para a pagina certa.
|
||||
<div className="ds-kicker">Reconhece algum destes cenários?</div>
|
||||
<h3 className="ds-card-title !mt-sp-3" data-reel="left">
|
||||
Planilhas demais. Sistemas isolados. Trabalho repetido.
|
||||
</h3>
|
||||
<p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
|
||||
A operação cresceu, mas as ferramentas não acompanharam. Ou a
|
||||
ideia precisa sair do protótipo para atender clientes. O projeto
|
||||
começa pelo problema, sem exigir que você já saiba qual tecnologia
|
||||
usar.
|
||||
</p>
|
||||
</div>
|
||||
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-accent" strength={0.2}>
|
||||
Ver hub de servicos
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/servicos"
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.2}
|
||||
>
|
||||
Conhecer todos os serviços
|
||||
</MagneticButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3"
|
||||
data-reel-stagger="left"
|
||||
>
|
||||
{serviceHubLinks.map((service) => (
|
||||
<Link
|
||||
key={service.href}
|
||||
href={service.href}
|
||||
className="rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
|
||||
className="min-w-0 rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
|
||||
>
|
||||
<div className="text-[12px] font-semibold tracking-[0.02em] text-[var(--ink)]">
|
||||
<div className="font-display text-base font-semibold leading-snug text-[var(--ink)]">
|
||||
{service.label}
|
||||
</div>
|
||||
<div className="mt-sp-2 text-[12px] leading-6 text-[var(--meta)]">
|
||||
<div className="mt-sp-2 font-display text-sm leading-6 text-[var(--ink-80)]">
|
||||
{service.description}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -243,7 +277,28 @@ export function WorkModalities() {
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div id="process" className="mt-sp-12 scroll-mt-24">
|
||||
<h3 className="ds-card-title">Como o projeto acontece</h3>
|
||||
<p className="ds-copy max-w-[65ch]">
|
||||
Você acompanha as decisões e valida cada etapa. Prazo e investimento
|
||||
dependem do escopo, das integrações e dos riscos identificados.
|
||||
</p>
|
||||
<ol className="mt-sp-6 grid gap-sp-6 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{PROCESS.map((step, index) => (
|
||||
<li
|
||||
key={step.title}
|
||||
className="border-t border-[var(--border)] pt-sp-4"
|
||||
>
|
||||
<span className="ds-kicker">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<h4 className="mt-sp-3 text-base font-semibold">{step.title}</h4>
|
||||
<p className="ds-copy">{step.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useInView } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInView, useReducedMotion } from "framer-motion";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface CounterValueProps {
|
||||
value: number;
|
||||
@@ -41,7 +42,7 @@ export function CounterValue({
|
||||
const frame = (now: number) => {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
setDisplayValue(Math.round(value * eased));
|
||||
|
||||
if (progress < 1) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface DrawLineProps {
|
||||
className?: string;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
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 {
|
||||
src: string | StaticImageData;
|
||||
@@ -26,9 +27,7 @@ export function LazyRevealImage({
|
||||
<motion.div
|
||||
className={`relative ${className}`.trim()}
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 20, scale: 1.02 }
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20, scale: 1.02 }
|
||||
}
|
||||
animate={
|
||||
loaded
|
||||
|
||||
@@ -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";
|
||||
|
||||
import { motion, useInView, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useInView } from "motion/react";
|
||||
import { type CSSProperties, useRef } from "react";
|
||||
|
||||
interface TextDiffuseRevealProps {
|
||||
text: string;
|
||||
@@ -11,8 +11,12 @@ interface TextDiffuseRevealProps {
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
const EXPO_OUT = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
/**
|
||||
* Título que entra palavra por palavra. A animação é CSS (.text-reveal em globals.css) com transparência
|
||||
* e deslocamento: a placa de vídeo compõe sem redesenhar. A versão anterior animava cada letra com
|
||||
* desfoque e renderizava o React de novo a cada letra, o que pesava justamente durante as transições.
|
||||
* Movimento reduzido: o CSS mostra o texto direto, sem animação.
|
||||
*/
|
||||
export function TextDiffuseReveal({
|
||||
text,
|
||||
className = "",
|
||||
@@ -21,113 +25,36 @@ export function TextDiffuseReveal({
|
||||
amount = 0.3,
|
||||
}: Readonly<TextDiffuseRevealProps>) {
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const isInView = useInView(ref, { once, amount });
|
||||
const [staggerDelay, setStaggerDelay] = useState(0.03);
|
||||
const [completedChars, setCompletedChars] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(max-width: 768px)");
|
||||
const updateStagger = () => setStaggerDelay(media.matches ? 0.015 : 0.03);
|
||||
|
||||
updateStagger();
|
||||
media.addEventListener("change", updateStagger);
|
||||
return () => media.removeEventListener("change", updateStagger);
|
||||
}, []);
|
||||
|
||||
const tokens = useMemo(() => text.split(/(\n|\s+)/), [text]);
|
||||
const animatedChars = useMemo(
|
||||
() =>
|
||||
Array.from(text).filter((char) => char !== "\n" && char !== " ").length,
|
||||
[text],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) {
|
||||
setCompletedChars(0);
|
||||
}
|
||||
}, [isInView]);
|
||||
|
||||
const hasActiveWillChange =
|
||||
!shouldReduceMotion && isInView && completedChars < animatedChars;
|
||||
|
||||
let animatedIndex = 0;
|
||||
let word = 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
aria-label={text}
|
||||
style={{
|
||||
willChange: hasActiveWillChange
|
||||
? "filter, opacity, transform"
|
||||
: undefined,
|
||||
}}
|
||||
className={`text-reveal ${className}`.trim()}
|
||||
data-shown={isInView ? "" : undefined}
|
||||
style={{ "--d": `${startDelay}s` } as CSSProperties}
|
||||
>
|
||||
{tokens.map((token, tokenIndex) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token === "\n") {
|
||||
return <br key={`line-${tokenIndex}`} />;
|
||||
}
|
||||
|
||||
if (/^\s+$/.test(token)) {
|
||||
return (
|
||||
<span key={`space-${tokenIndex}`} aria-hidden="true">
|
||||
{token.replace(/ /g, "\u00A0")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`word-${tokenIndex}`}
|
||||
className="inline-block whitespace-nowrap"
|
||||
>
|
||||
{Array.from(token).map((char, charIndex) => {
|
||||
const currentIndex = animatedIndex;
|
||||
animatedIndex += 1;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
key={`char-${tokenIndex}-${charIndex}-${char}`}
|
||||
className="inline-block"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
||||
}
|
||||
animate={
|
||||
isInView
|
||||
? 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 className="sr-only">{text}</span>
|
||||
<span aria-hidden="true">
|
||||
{text
|
||||
.split(/(\n|\s+)/)
|
||||
.filter(Boolean)
|
||||
.map((token, index) => {
|
||||
const key = `${index}:${token}`;
|
||||
if (token === "\n") return <br key={key} />;
|
||||
if (/^\s+$/.test(token)) return " ";
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className="text-reveal-word"
|
||||
style={{ "--i": word++ } as CSSProperties}
|
||||
>
|
||||
{token}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface TopProgressBarProps {
|
||||
className?: string;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
export default function PointCloudScene() {
|
||||
return <div data-testid="point-cloud-mock"></div>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import FilmGrain from "./FilmGrain";
|
||||
|
||||
describe("static cinematic effects", () => {
|
||||
it("renders grain without a canvas", () => {
|
||||
const grain = render(<FilmGrain />);
|
||||
expect(grain.container.firstElementChild).toHaveAttribute(
|
||||
"aria-hidden",
|
||||
"true",
|
||||
);
|
||||
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
resetHomeArrivalState,
|
||||
useHomeFragmentNavigation,
|
||||
} from "./useHomeFragmentNavigation";
|
||||
|
||||
function HomeNavigation() {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={onLayoutReady}>
|
||||
Establish layout
|
||||
</button>
|
||||
<a href="/#about">Sobre</a>
|
||||
<a href="/#contact">Contato</a>
|
||||
<section id="about" aria-label="Sobre">
|
||||
<button type="button">Read biography</button>
|
||||
</section>
|
||||
<section id="contact" aria-label="Contato">
|
||||
<button type="button">Send message</button>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
beforeEach(() => {
|
||||
resetHomeArrivalState();
|
||||
window.history.replaceState(null, "", "/");
|
||||
HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
if (originalScrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
|
||||
}
|
||||
});
|
||||
|
||||
describe("home fragment navigation", () => {
|
||||
it("starts an unfragmented visit at the top after the home establishes layout", () => {
|
||||
let restoredPosition = 3200;
|
||||
jest
|
||||
.spyOn(window, "scrollTo")
|
||||
.mockImplementation((options: ScrollToOptions | number) => {
|
||||
if (typeof options === "object")
|
||||
restoredPosition = options.top ?? restoredPosition;
|
||||
});
|
||||
render(<HomeNavigation />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(restoredPosition).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves a same-document return to the position the browser restores", () => {
|
||||
const scrollTo = jest
|
||||
.spyOn(window, "scrollTo")
|
||||
.mockImplementation(() => {});
|
||||
const firstVisit = render(<HomeNavigation />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
firstVisit.unmount();
|
||||
expect(window.history.scrollRestoration).not.toBe("manual");
|
||||
|
||||
scrollTo.mockClear();
|
||||
render(<HomeNavigation />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(window.history.scrollRestoration).not.toBe("manual");
|
||||
});
|
||||
|
||||
it("does not reset the visitor's position after intentional scrolling during startup", () => {
|
||||
let position = 0;
|
||||
jest
|
||||
.spyOn(window, "scrollTo")
|
||||
.mockImplementation((options: ScrollToOptions | number) => {
|
||||
if (typeof options === "object") position = options.top ?? position;
|
||||
});
|
||||
render(<HomeNavigation />);
|
||||
position = 600;
|
||||
fireEvent.wheel(window, { deltaY: 600 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(position).toBe(600);
|
||||
});
|
||||
|
||||
it("waits for the home layout on fragment arrival, then never steals focus on refresh", () => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
render(<HomeNavigation />);
|
||||
const contact = screen.getByRole("region", { name: "Contato" });
|
||||
expect(contact).not.toHaveFocus();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(contact).toHaveFocus();
|
||||
screen.getByRole("button", { name: "Read biography" }).focus();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Read biography" }),
|
||||
).toHaveFocus();
|
||||
});
|
||||
|
||||
it("abandons a pending arrival when the visitor intentionally scrolls", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<HomeNavigation />);
|
||||
const message = screen.getByRole("button", { name: "Send message" });
|
||||
message.focus();
|
||||
fireEvent.wheel(window, { deltaY: 200 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(message).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps repeated same-fragment links native while moving focus to the destination", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<HomeNavigation />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
const link = screen.getByRole("link", { name: "Sobre" });
|
||||
link.focus();
|
||||
expect(fireEvent.click(link)).toBe(true);
|
||||
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
|
||||
link.focus();
|
||||
expect(fireEvent.click(link)).toBe(true);
|
||||
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
|
||||
link.focus();
|
||||
fireEvent.click(link, { ctrlKey: true });
|
||||
expect(link).toHaveFocus();
|
||||
});
|
||||
|
||||
it("does not resurrect a stale fragment when the URL changes before readiness", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<HomeNavigation />);
|
||||
const message = screen.getByRole("button", { name: "Send message" });
|
||||
message.focus();
|
||||
act(() => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(message).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,90 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Suspense } from "react";
|
||||
import Hero from "../Hero";
|
||||
import { SectionLoading } from "../SectionLoading";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type {
|
||||
PortfolioExperience,
|
||||
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..." /> });
|
||||
const Skills = dynamic(() => import("../Skills"), { loading: () => <SectionLoading message="Carregando..." /> });
|
||||
const Experience = dynamic(() => import("../Experience"), { loading: () => <SectionLoading message="Carregando..." /> });
|
||||
const WorkModalities = dynamic(() => import("../WorkModalities").then(mod => mod.WorkModalities), { loading: () => <SectionLoading message="Carregando..." /> });
|
||||
const Contact = dynamic(() => import("../Contact"), { loading: () => <SectionLoading message="Carregando..." /> });
|
||||
const Projects = dynamic(() => import("../Projects"));
|
||||
const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics"), { loading: () => <SectionLoading message="Carregando..." /> });
|
||||
const Footer = dynamic(() => import("../Footer"));
|
||||
export default function CinematicHome({
|
||||
projects,
|
||||
experiences,
|
||||
skillCategories,
|
||||
heroStats,
|
||||
}: {
|
||||
heroStats?: HeroStats;
|
||||
projects: PortfolioProjectView[];
|
||||
experiences: PortfolioExperience[];
|
||||
skillCategories: PortfolioSkillCategory[];
|
||||
}) {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
useCinematicScroll(mainRef);
|
||||
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
|
||||
useEffect(() => onLayoutReady(), [onLayoutReady]);
|
||||
|
||||
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 (
|
||||
<div className="flex min-h-screen flex-col overflow-x-hidden">
|
||||
<main className="ds-main">
|
||||
<Hero />
|
||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. */}
|
||||
<main ref={mainRef} className="ds-main relative z-0">
|
||||
<Hero stats={heroStats} />
|
||||
|
||||
{/* Marquee divider after hero */}
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
|
||||
<ScrollVelocityText
|
||||
text="SOFTWARE ENGINEERING"
|
||||
baseVelocity={-1.5}
|
||||
/>
|
||||
</div>
|
||||
<Projects projects={projects} />
|
||||
|
||||
<CinematicSection effect="parallax-depth">
|
||||
<WorkModalities />
|
||||
|
||||
<EngineeringWithAI />
|
||||
|
||||
<LighthouseMetrics />
|
||||
|
||||
<div className="bg-[var(--paper)]">
|
||||
<About />
|
||||
</CinematicSection>
|
||||
|
||||
<SectionDivider variant="waveform" />
|
||||
|
||||
<CinematicSection effect="clip-reveal">
|
||||
<Skills />
|
||||
</CinematicSection>
|
||||
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-4 text-[rgba(204,255,0,0.04)]">
|
||||
<ScrollVelocityText
|
||||
text="NEXT.JS TAURI RUST NODE.JS TYPESCRIPT"
|
||||
baseVelocity={1}
|
||||
/>
|
||||
<ExperienceDisclosure>
|
||||
<Skills categories={skillCategories} />
|
||||
<Experience experiences={experiences} />
|
||||
</ExperienceDisclosure>
|
||||
</div>
|
||||
|
||||
<CinematicSection effect="fade-scale">
|
||||
<Experience />
|
||||
</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 />
|
||||
</CinematicSection>
|
||||
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
|
||||
<ScrollVelocityText
|
||||
text="VAMOS CONVERSAR"
|
||||
baseVelocity={-2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CinematicSection effect="fade-scale">
|
||||
<Contact />
|
||||
</CinematicSection>
|
||||
<Contact />
|
||||
</main>
|
||||
<Footer />
|
||||
<div className="relative z-10">
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
"use client";
|
||||
|
||||
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 PageIntro = dynamic(() => import("./PageIntro"), { ssr: false });
|
||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false });
|
||||
const HomeButton = dynamic(() => import("./HomeButton"), { ssr: false });
|
||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function CinematicOverlays() {
|
||||
const pathname = usePathname();
|
||||
const isHome = pathname === "/";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageIntro />
|
||||
<CustomCursor />
|
||||
<FilmGrain />
|
||||
<ScrollProgress />
|
||||
<HomeButton />
|
||||
{isHome ? (
|
||||
<>
|
||||
<FilmGrain />
|
||||
<ScrollProgress />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, type ReactNode } from "react";
|
||||
|
||||
interface CinematicSectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
effect?: "fade-scale" | "clip-reveal" | "parallax-depth" | "rotate-in";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export default function CinematicSection({
|
||||
children,
|
||||
className = "",
|
||||
effect = "fade-scale",
|
||||
id,
|
||||
}: CinematicSectionProps) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Check reduced motion preference
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
switch (effect) {
|
||||
case "fade-scale": {
|
||||
gsap.set(el, {
|
||||
opacity: 0,
|
||||
scale: 0.92,
|
||||
transformOrigin: "center center",
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 85%",
|
||||
end: "top 20%",
|
||||
scrub: 0.8,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
ease: "power2.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "clip-reveal": {
|
||||
gsap.set(el, {
|
||||
clipPath: "inset(15% 5% 15% 5%)",
|
||||
opacity: 0.3,
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 80%",
|
||||
end: "top 15%",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
clipPath: "inset(0% 0% 0% 0%)",
|
||||
opacity: 1,
|
||||
ease: "power3.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "parallax-depth": {
|
||||
gsap.set(el, {
|
||||
y: 80,
|
||||
opacity: 0,
|
||||
rotateX: 4,
|
||||
transformPerspective: 1200,
|
||||
transformOrigin: "center bottom",
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 90%",
|
||||
end: "top 30%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
rotateX: 0,
|
||||
ease: "power2.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "rotate-in": {
|
||||
gsap.set(el, {
|
||||
opacity: 0,
|
||||
rotateY: -5,
|
||||
x: -60,
|
||||
transformPerspective: 1000,
|
||||
transformOrigin: "left center",
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 80%",
|
||||
end: "top 25%",
|
||||
scrub: 0.8,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
opacity: 1,
|
||||
rotateY: 0,
|
||||
x: 0,
|
||||
ease: "power3.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, [effect]);
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className={className} id={id}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let animId: number;
|
||||
let lastFrame = 0;
|
||||
const fps = 24; // Cinematic film fps
|
||||
const frameInterval = 1000 / fps;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth / 2;
|
||||
canvas.height = window.innerHeight / 2;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
const render = (timestamp: number) => {
|
||||
animId = requestAnimationFrame(render);
|
||||
|
||||
const delta = timestamp - lastFrame;
|
||||
if (delta < frameInterval) return;
|
||||
lastFrame = timestamp - (delta % frameInterval);
|
||||
|
||||
const { width, height } = canvas;
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const noise = Math.random() * 20;
|
||||
data[i] = noise;
|
||||
data[i + 1] = noise;
|
||||
data[i + 2] = noise;
|
||||
data[i + 3] = 12; // Very subtle
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
};
|
||||
|
||||
animId = requestAnimationFrame(render);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
return <div aria-hidden="true" className="film-grain" />;
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useMemo } from "react";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { Float, MeshDistortMaterial, MeshTransmissionMaterial } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
|
||||
function GlassSphere({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!meshRef.current) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
meshRef.current.rotation.x = Math.sin(t * 0.3) * 0.2;
|
||||
meshRef.current.rotation.z = Math.cos(t * 0.2) * 0.1;
|
||||
});
|
||||
|
||||
return (
|
||||
<Float speed={1.5} rotationIntensity={0.3} floatIntensity={0.8}>
|
||||
<mesh ref={meshRef} position={position} scale={scale}>
|
||||
<sphereGeometry args={[1, 32, 32]} />
|
||||
<MeshDistortMaterial
|
||||
color="#ccff00"
|
||||
transparent
|
||||
opacity={0.08}
|
||||
distort={0.4}
|
||||
speed={2}
|
||||
wireframe
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
);
|
||||
}
|
||||
|
||||
function WireframeRing({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!meshRef.current) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
meshRef.current.rotation.x = t * 0.1;
|
||||
meshRef.current.rotation.y = t * 0.15;
|
||||
});
|
||||
|
||||
return (
|
||||
<Float speed={1} rotationIntensity={0.5} floatIntensity={0.4}>
|
||||
<mesh ref={meshRef} position={position} scale={scale}>
|
||||
<torusGeometry args={[1, 0.02, 16, 100]} />
|
||||
<meshStandardMaterial
|
||||
color="#ccff00"
|
||||
transparent
|
||||
opacity={0.15}
|
||||
emissive="#ccff00"
|
||||
emissiveIntensity={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
);
|
||||
}
|
||||
|
||||
function DataStream({ count = 50 }: { count?: number }) {
|
||||
const pointsRef = useRef<THREE.Points>(null);
|
||||
|
||||
const positions = useMemo(() => {
|
||||
const pos = new Float32Array(count * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
// Create a spiral/helix pattern
|
||||
const angle = (i / count) * Math.PI * 8;
|
||||
const radius = 2 + Math.sin(angle * 0.5) * 0.5;
|
||||
pos[i3] = Math.cos(angle) * radius;
|
||||
pos[i3 + 1] = (i / count - 0.5) * 12;
|
||||
pos[i3 + 2] = Math.sin(angle) * radius;
|
||||
}
|
||||
return pos;
|
||||
}, [count]);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!pointsRef.current) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
pointsRef.current.rotation.y = t * 0.05;
|
||||
|
||||
const posAttr = pointsRef.current.geometry.getAttribute("position") as THREE.BufferAttribute;
|
||||
const arr = posAttr.array as Float32Array;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
arr[i3 + 1] += 0.02;
|
||||
if (arr[i3 + 1] > 6) arr[i3 + 1] = -6;
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<points ref={pointsRef}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[positions, 3]}
|
||||
count={count}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<pointsMaterial
|
||||
color="#ccff00"
|
||||
size={0.04}
|
||||
transparent
|
||||
opacity={0.4}
|
||||
sizeAttenuation
|
||||
blending={THREE.AdditiveBlending}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
||||
interface FloatingElementsProps {
|
||||
variant?: "sphere" | "ring" | "data" | "combined";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FloatingElements({
|
||||
variant = "combined",
|
||||
className = "",
|
||||
}: FloatingElementsProps) {
|
||||
return (
|
||||
<div className={`pointer-events-none absolute inset-0 ${className}`}>
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 6], fov: 50 }}
|
||||
dpr={[1, 1.5]}
|
||||
gl={{ antialias: false, alpha: true, powerPreference: "high-performance" }}
|
||||
style={{ background: "transparent" }}
|
||||
>
|
||||
<ambientLight intensity={0.2} />
|
||||
<pointLight position={[5, 5, 5]} intensity={0.3} color="#ccff00" />
|
||||
|
||||
{(variant === "sphere" || variant === "combined") && (
|
||||
<>
|
||||
<GlassSphere position={[3, 1, -2]} scale={0.8} />
|
||||
<GlassSphere position={[-4, -1, -3]} scale={1.2} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(variant === "ring" || variant === "combined") && (
|
||||
<>
|
||||
<WireframeRing position={[0, 0, -2]} scale={1.5} />
|
||||
<WireframeRing position={[-3, 2, -4]} scale={0.8} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(variant === "data" || variant === "combined") && (
|
||||
<DataStream count={60} />
|
||||
)}
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useReducedMotion } from "framer-motion";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
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";
|
||||
|
||||
import { useRef, useMemo, useCallback, useEffect } from "react";
|
||||
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";
|
||||
|
||||
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() {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
@@ -226,7 +55,7 @@ function GridFloor() {
|
||||
}
|
||||
`,
|
||||
}),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -266,7 +95,11 @@ function CameraRig() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function HeroScene() {
|
||||
export default function HeroScene({
|
||||
isVisible = true,
|
||||
}: {
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
@@ -275,6 +108,7 @@ export default function HeroScene() {
|
||||
<Canvas
|
||||
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
|
||||
dpr={[1, 1.5]}
|
||||
frameloop={isVisible ? "always" : "never"}
|
||||
gl={{
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
@@ -290,14 +124,16 @@ export default function HeroScene() {
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
|
||||
<ambientLight intensity={0.3} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={0.5} color="#ccff00" />
|
||||
<pointLight position={[-5, 3, -5]} intensity={0.3} color="#ffffff" />
|
||||
<fog attach="fog" args={["#0a0a0a", 8, 30]} />
|
||||
<ambientLight intensity={0.25} />
|
||||
<directionalLight
|
||||
position={[5, 5, 5]}
|
||||
intensity={0.4}
|
||||
color="#ccff00"
|
||||
/>
|
||||
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
|
||||
|
||||
<CameraRig />
|
||||
<ParticleField count={1500} />
|
||||
<FloatingGeometry />
|
||||
<GridFloor />
|
||||
</Canvas>
|
||||
</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";
|
||||
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { type MouseEvent, type ReactNode, useState } from "react";
|
||||
|
||||
interface MagneticButtonProps {
|
||||
children: ReactNode;
|
||||
@@ -13,7 +13,6 @@ interface MagneticButtonProps {
|
||||
target?: string;
|
||||
rel?: string;
|
||||
download?: boolean | string;
|
||||
"data-cursor"?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
wrapperClassName?: string;
|
||||
"aria-label"?: string;
|
||||
@@ -29,19 +28,14 @@ export default function MagneticButton({
|
||||
target,
|
||||
rel,
|
||||
download,
|
||||
"data-cursor": dataCursor,
|
||||
type,
|
||||
wrapperClassName = "",
|
||||
"aria-label": ariaLabel,
|
||||
}: MagneticButtonProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handleMouse = (e: React.MouseEvent) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const handleMouse = (e: MouseEvent<HTMLElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
@@ -55,31 +49,57 @@ export default function MagneticButton({
|
||||
setPosition({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const Tag = as === "a" ? motion.a : as === "button" ? motion.button : as === "div" ? motion.div : as === "span" ? motion.span : motion.li;
|
||||
const Tag =
|
||||
as === "a"
|
||||
? motion.a
|
||||
: as === "button"
|
||||
? motion.button
|
||||
: as === "div"
|
||||
? motion.div
|
||||
: as === "span"
|
||||
? motion.span
|
||||
: motion.li;
|
||||
const linkProps = as === "a" ? { href, target, rel, download } : {};
|
||||
const buttonProps = as === "button" ? { type } : {};
|
||||
|
||||
const motionProps = {
|
||||
animate: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
},
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
stiffness: 350,
|
||||
damping: 15,
|
||||
mass: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
if (as === "li") {
|
||||
return (
|
||||
<Tag
|
||||
className={`${className} ${wrapperClassName}`.trim()}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={`inline-block ${wrapperClassName}`.trim()}
|
||||
>
|
||||
<div className={`inline-block ${wrapperClassName}`.trim()}>
|
||||
<Tag
|
||||
className={className}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
data-cursor={dataCursor}
|
||||
animate={{
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 350,
|
||||
damping: 15,
|
||||
mass: 0.5,
|
||||
}}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -1,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";
|
||||
|
||||
import { motion, useScroll, useSpring, useTransform } from "framer-motion";
|
||||
import { motion, useScroll, useSpring, useTransform } from "motion/react";
|
||||
|
||||
export default function ScrollProgress() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
@@ -10,7 +10,11 @@ export default function ScrollProgress() {
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.02, 0.98, 1], [0, 1, 1, 0]);
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 0.02, 0.98, 1],
|
||||
[0, 1, 1, 0],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import {
|
||||
motion,
|
||||
useAnimationFrame,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
useSpring,
|
||||
useScroll,
|
||||
} from "framer-motion";
|
||||
|
||||
interface ScrollVelocityTextProps {
|
||||
text: string;
|
||||
baseVelocity?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function wrap(min: number, max: number, v: number) {
|
||||
const rangeSize = max - min;
|
||||
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
|
||||
}
|
||||
|
||||
export default function ScrollVelocityText({
|
||||
text,
|
||||
baseVelocity = 2,
|
||||
className = "",
|
||||
}: ScrollVelocityTextProps) {
|
||||
const baseX = useMotionValue(0);
|
||||
const { scrollY } = useScroll();
|
||||
const scrollVelocity = useMotionValue(0);
|
||||
const smoothVelocity = useSpring(scrollVelocity, {
|
||||
damping: 50,
|
||||
stiffness: 300,
|
||||
});
|
||||
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 5], {
|
||||
clamp: false,
|
||||
});
|
||||
|
||||
const [repetitions, setRepetitions] = useState(4);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setRepetitions(Math.ceil(window.innerWidth / 400) + 2);
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
// Track scroll velocity
|
||||
const prevScrollY = useRef(0);
|
||||
useEffect(() => {
|
||||
return scrollY.on("change", (latest) => {
|
||||
const delta = latest - prevScrollY.current;
|
||||
prevScrollY.current = latest;
|
||||
scrollVelocity.set(Math.abs(delta));
|
||||
});
|
||||
}, [scrollY, scrollVelocity]);
|
||||
|
||||
const directionFactor = useRef(1);
|
||||
|
||||
const x = useTransform(baseX, (v) => `${wrap(-25, 0, v)}%`);
|
||||
|
||||
useAnimationFrame((_, delta) => {
|
||||
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
|
||||
|
||||
const vf = velocityFactor.get();
|
||||
if (vf < 0) {
|
||||
directionFactor.current = -1;
|
||||
} else if (vf > 0) {
|
||||
directionFactor.current = 1;
|
||||
}
|
||||
|
||||
moveBy += directionFactor.current * moveBy * vf;
|
||||
baseX.set(baseX.get() + moveBy);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`overflow-hidden whitespace-nowrap ${className}`}
|
||||
>
|
||||
<motion.div className="inline-flex" style={{ x }}>
|
||||
{[...Array(repetitions)].map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="mr-8 inline-block font-display text-[clamp(3rem,10vw,8rem)] font-extrabold uppercase leading-none tracking-[-0.04em]"
|
||||
>
|
||||
{text}
|
||||
<span className="mx-8 inline-block text-[var(--slash)]">•</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,327 @@
|
||||
"use client";
|
||||
|
||||
import { type RefObject, useEffect } from "react";
|
||||
|
||||
type Transition = (
|
||||
tl: gsap.core.Timeline,
|
||||
prev: HTMLElement,
|
||||
next: HTMLElement,
|
||||
) => 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").
|
||||
* Truque de todas as transições: durante a última altura de tela entre duas seções, a que sai é empurrada
|
||||
* para baixo na mesma medida da rolagem (parece parada) e a que entra pode fazer o mesmo. Assim nada
|
||||
* "rola"; só a transição acontece. Sem pin e sem mudar o layout: links internos e medições seguem iguais.
|
||||
*
|
||||
* Regras que mantêm tudo estável:
|
||||
* - cada tween termina até o tempo 1 da timeline (as "travas" de posição duram exatamente 1);
|
||||
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
|
||||
* - a seção que saiu fica invisível depois da passagem, para não aparecer atrás de transparências.
|
||||
* 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). */
|
||||
export 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 sai fica parada na tela enquanto a próxima entra
|
||||
const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) =>
|
||||
tl.fromTo(prev, { y: 0 }, { y: vh }, 0);
|
||||
// a seção que entra também fica parada no topo: a transição acontece "no lugar"
|
||||
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) => {
|
||||
holdPrev(tl, prev);
|
||||
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(
|
||||
prev,
|
||||
{ autoAlpha: 1 },
|
||||
{ autoAlpha: 0, duration: 0.4 },
|
||||
0,
|
||||
).fromTo(
|
||||
next,
|
||||
{ opacity: 0, clipPath: band(0.36) },
|
||||
{ opacity: 1, clipPath: band(1), duration: 0.65 },
|
||||
0.35,
|
||||
);
|
||||
},
|
||||
// projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera
|
||||
(tl, prev, next) => {
|
||||
holdPrev(tl, prev);
|
||||
holdNext(tl, next);
|
||||
const center = () => `at 50% ${vh() / 2}px`;
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
{ scale: 1.18, opacity: 0.2 },
|
||||
0,
|
||||
)
|
||||
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
|
||||
.fromTo(
|
||||
next,
|
||||
{ clipPath: () => `circle(0px ${center()})` },
|
||||
{
|
||||
clipPath: () =>
|
||||
`circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`,
|
||||
ease: "power1.in",
|
||||
},
|
||||
0,
|
||||
);
|
||||
},
|
||||
// modalidades → IA: corte lateral, uma tela empurra a outra
|
||||
(tl, prev, next) => {
|
||||
holdPrev(tl, prev);
|
||||
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 mais rápido que a rolagem, 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) => {
|
||||
holdPrev(tl, prev);
|
||||
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0).fromTo(
|
||||
next,
|
||||
{ yPercent: 0, 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) => {
|
||||
holdPrev(tl, prev);
|
||||
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) => {
|
||||
holdPrev(tl, prev);
|
||||
holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
{ scale: 0.92, opacity: 0.2 },
|
||||
0,
|
||||
)
|
||||
.fromTo(
|
||||
next,
|
||||
{
|
||||
scale: 0.55,
|
||||
clipPath: "inset(0% round 56px)",
|
||||
transformOrigin: () => `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);
|
||||
|
||||
const scenes = Array.from(main.children) as HTMLElement[];
|
||||
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: "relative",
|
||||
zIndex: index === 0 ? 30 : index + 1,
|
||||
});
|
||||
});
|
||||
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: () => pageTop(next) - vh(),
|
||||
end: () => pageTop(next),
|
||||
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);
|
||||
});
|
||||
}, main);
|
||||
|
||||
// Visibilidade pela posição da rolagem, não pelo estado inicial dos tweens: cada cena só existe na
|
||||
// tela da transição em que entra até a transição em que sai. Antes ela não vaza por cima da anterior;
|
||||
// depois não aparece atrás de transparências, e o navegador não pinta o que está coberto.
|
||||
// As posições são lidas a cada quadro (offsetTop é barato com o layout já calculado): guardá-las
|
||||
// deixava a visibilidade errada quando fontes, imagens ou o bloco "Experiência" mudavam a altura.
|
||||
let frame = 0;
|
||||
const syncVisibility = () => {
|
||||
frame = 0;
|
||||
const y = window.scrollY;
|
||||
const tops = scenes.map(pageTop);
|
||||
scenes.forEach((scene, index) => {
|
||||
const from = index === 0 ? -Infinity : tops[index] - vh();
|
||||
const to = index === scenes.length - 1 ? Infinity : tops[index + 1];
|
||||
const visibility = y >= from && y <= to ? "" : "hidden";
|
||||
if (scene.style.visibility !== visibility)
|
||||
scene.style.visibility = visibility;
|
||||
});
|
||||
};
|
||||
const queueSync = () => {
|
||||
if (!frame) frame = requestAnimationFrame(syncVisibility);
|
||||
};
|
||||
syncVisibility();
|
||||
ScrollTrigger.addEventListener("refresh", queueSync);
|
||||
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
|
||||
const scene = scenes.find((candidate) => candidate.contains(target));
|
||||
const offset =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||
) || 0;
|
||||
const top =
|
||||
!scene || scene === scenes[0]
|
||||
? 0
|
||||
: Math.max(
|
||||
pageTop(scene),
|
||||
scene === target ? 0 : pageTop(target) - 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", queueSync);
|
||||
window.removeEventListener("scroll", queueSync);
|
||||
cancelAnimationFrame(frame);
|
||||
clearTimeout(refreshTimer);
|
||||
resizeObserver.disconnect();
|
||||
context.revert();
|
||||
for (const scene of scenes) scene.style.visibility = "";
|
||||
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
|
||||
import React from "react";
|
||||
import type React from "react";
|
||||
|
||||
interface NumberIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
/** Tamanho em pixels. Escala mantendo o viewBox 80x80 */
|
||||
@@ -65,7 +65,7 @@ export const NumberOne = ({
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Syne, sans-serif"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
@@ -94,7 +94,7 @@ export const NumberTwo = ({
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Syne, sans-serif"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
@@ -123,7 +123,7 @@ export const NumberThree = ({
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Syne, sans-serif"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
@@ -152,7 +152,7 @@ export const NumberFour = ({
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Syne, sans-serif"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
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 |
+1364
-214
File diff suppressed because it is too large.
Load diff
@@ -2,14 +2,14 @@ import { renderHook } from "@testing-library/react";
|
||||
import { useReducedMotion } from "./useReducedMotion";
|
||||
|
||||
// Mock do Framer Motion
|
||||
jest.mock("framer-motion", () => ({
|
||||
jest.mock("motion/react", () => ({
|
||||
useReducedMotion: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("useReducedMotion", () => {
|
||||
it("deve retornar false quando prefers-reduced-motion não está ativo", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
@@ -19,7 +19,7 @@ describe("useReducedMotion", () => {
|
||||
|
||||
it("deve retornar true quando prefers-reduced-motion está ativo", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
@@ -29,7 +29,7 @@ describe("useReducedMotion", () => {
|
||||
|
||||
it("deve retornar false quando o hook do Framer Motion retorna null", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
@@ -39,7 +39,7 @@ describe("useReducedMotion", () => {
|
||||
|
||||
it("deve retornar false quando o hook do Framer Motion retorna undefined", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"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.
|
||||
* Wrapper sobre o hook useReducedMotion do Framer Motion.
|
||||
@@ -20,5 +20,12 @@ import { useReducedMotion as useFramerReducedMotion } from "framer-motion";
|
||||
* ```
|
||||
*/
|
||||
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 |
+56
-163
@@ -1,25 +1,24 @@
|
||||
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 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 { RouteTransition } from "./components/RouteTransition";
|
||||
import { AnimationProvider } from "./providers/AnimationProvider";
|
||||
import { SmoothScrollProvider } from "./providers/SmoothScrollProvider";
|
||||
import GoogleAnalytics from "./components/GoogleAnalytics";
|
||||
import { Header } from "./components/Header";
|
||||
import "./globals.css";
|
||||
|
||||
const siteUrl = "https://evandro.dev.br";
|
||||
const seoTitle =
|
||||
"Engenharia de Software, Next.js, Tauri, Rust e Consultoria Tecnica | Evandro Fonseca Junior";
|
||||
const defaultTitle = "Sistemas sob medida para empresas | Evandro.dev.br";
|
||||
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"],
|
||||
weight: ["400", "500", "600", "700", "800"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
display: "swap",
|
||||
variable: "--font-syne",
|
||||
variable: "--font-space-grotesk",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
@@ -47,24 +46,11 @@ const monumentExtended = localFont({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: seoTitle,
|
||||
title: {
|
||||
default: defaultTitle,
|
||||
template: "%s | Evandro Fonseca Junior",
|
||||
},
|
||||
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" }],
|
||||
creator: "Evandro Fonseca Junior",
|
||||
publisher: "Evandro Fonseca Junior",
|
||||
@@ -80,7 +66,7 @@ export const metadata: Metadata = {
|
||||
telephone: false,
|
||||
},
|
||||
openGraph: {
|
||||
title: seoTitle,
|
||||
title: defaultTitle,
|
||||
description: seoDescription,
|
||||
url: siteUrl,
|
||||
siteName: "Evandro.dev.br",
|
||||
@@ -91,16 +77,21 @@ export const metadata: Metadata = {
|
||||
url: "/opengraph-image",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Evandro Fonseca Junior | Engenharia de Software, Next.js, Tauri e Rust",
|
||||
alt: "Evandro.dev.br | Sistemas sob medida para empresas",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: seoTitle,
|
||||
title: defaultTitle,
|
||||
description: seoDescription,
|
||||
creator: "@evandrodevbr",
|
||||
images: ["/opengraph-image"],
|
||||
images: [
|
||||
{
|
||||
url: "/opengraph-image",
|
||||
alt: "Evandro.dev.br | Sistemas sob medida para empresas",
|
||||
},
|
||||
],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
@@ -129,145 +120,47 @@ interface RootLayoutProps {
|
||||
export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"@graph": [buildWebSiteJsonLd(), buildPersonJsonLd()],
|
||||
};
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="pt-BR"
|
||||
className={`${syne.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
||||
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
||||
>
|
||||
<head>
|
||||
<link rel="canonical" href={siteUrl} />
|
||||
{/* Blocking script: creates immediate black overlay BEFORE any paint.
|
||||
This prevents the flash of real content before React hydrates
|
||||
and PageIntro mounts. PageIntro removes the #__loading-blocker div. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){
|
||||
var el=document.createElement('div');
|
||||
el.id='__loading-blocker';
|
||||
el.style.cssText='position:fixed;inset:0;background:#0a0a0a;z-index:99998;pointer-events:none;';
|
||||
document.documentElement.appendChild(el);
|
||||
})();`,
|
||||
}}
|
||||
/>
|
||||
{/* Set arrival policy before native restoration and client-side layout changes. */}
|
||||
<style id="arrival-scroll-policy">{`html { scroll-behavior: auto !important; }`}</style>
|
||||
<script>{`
|
||||
addEventListener("load", () => {
|
||||
const sheet = document.getElementById("arrival-scroll-policy")?.sheet;
|
||||
if (sheet) sheet.disabled = true;
|
||||
}, { once: true });
|
||||
if (location.pathname === "/") {
|
||||
const navigation = performance.getEntriesByType("navigation")[0];
|
||||
if (navigation?.type !== "back_forward") {
|
||||
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>
|
||||
<body>
|
||||
<SmoothScrollProvider>
|
||||
<AnimationProvider>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<CinematicOverlays />
|
||||
<RouteTransition>{children}</RouteTransition>
|
||||
<GoogleAnalytics />
|
||||
</AnimationProvider>
|
||||
</SmoothScrollProvider>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<a className="skip-link" href="#main-content">
|
||||
Pular para o conteúdo
|
||||
</a>
|
||||
<Header />
|
||||
<CinematicOverlays />
|
||||
<div id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</div>
|
||||
<GoogleAnalytics />
|
||||
</body>
|
||||
</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 {
|
||||
return {
|
||||
name: "Evandro Fonseca Junior - Portfólio",
|
||||
name: "Evandro.dev.br — Sistemas sob medida para empresas",
|
||||
short_name: "Evandro.dev",
|
||||
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: "/",
|
||||
display: "standalone",
|
||||
background_color: "#000000",
|
||||
theme_color: "#000000",
|
||||
orientation: "portrait-primary",
|
||||
categories: ["portfolio", "developer", "technology"],
|
||||
categories: ["business", "productivity", "technology"],
|
||||
lang: "pt-BR",
|
||||
dir: "ltr",
|
||||
icons: [
|
||||
{
|
||||
src: "/favicon.ico",
|
||||
sizes: "any",
|
||||
type: "image/x-icon",
|
||||
src: "/brand/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml",
|
||||
src: "/icon.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeft, Code2, Home } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Home, ArrowLeft, Code2 } from "lucide-react";
|
||||
|
||||
export default function NotFound() {
|
||||
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