Compare commits
No files matched your search
@@ -47,6 +47,7 @@ next-env.d.ts
|
||||
.superpowers/
|
||||
/.worktrees/
|
||||
/.impeccable/
|
||||
/.serena/
|
||||
|
||||
# generated analysis artifacts
|
||||
/graphify-out/
|
||||
|
||||
@@ -4,3 +4,8 @@
|
||||
- `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,8 +1,8 @@
|
||||
<div align="center">
|
||||
|
||||
# Evandro.dev.br: portfólio
|
||||
# Evandro.dev.br: software sob medida
|
||||
|
||||
Portfólio profissional de Evandro Fonseca Junior, com projetos, cases, serviços, currículo e contato por WhatsApp.
|
||||
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>
|
||||
|
||||
@@ -11,7 +11,7 @@ Portfólio profissional de Evandro Fonseca Junior, com projetos, cases, serviço
|
||||
- Node.js `22.x`
|
||||
- pnpm `9.15.9`, fixado em `package.json#packageManager`
|
||||
|
||||
O projeto usa Next.js `16.3.0`. Essa versão é intencional: `16.2.11` ainda mantinha três advisories transitivos de severidade alta; `16.3.0` foi a menor versão estável da mesma major que deixou as auditorias atuais sem vulnerabilidades high.
|
||||
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
|
||||
|
||||
@@ -35,7 +35,7 @@ corepack [email protected] start
|
||||
| `pnpm build` | Gera o build de produção com Turbopack. |
|
||||
| `pnpm start` | Serve um build já gerado. |
|
||||
| `pnpm lint` | Executa `biome check`. |
|
||||
| `pnpm typecheck` | Executa TypeScript sem emitir arquivos. |
|
||||
| `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. |
|
||||
@@ -45,10 +45,10 @@ corepack [email protected] start
|
||||
|
||||
| Área | Pacotes usados |
|
||||
| --- | --- |
|
||||
| Aplicação | Next.js 16.3.0, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. |
|
||||
| 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, Drei e Cobe. |
|
||||
| 3D e gráficos | Three.js, React Three Fiber e Drei. |
|
||||
| Interface | Lucide React. |
|
||||
| Qualidade | Biome, Jest, Testing Library e ambiente JSDOM. |
|
||||
|
||||
@@ -58,7 +58,7 @@ Todas as dependências diretas estão fixadas em versões exatas no manifesto; o
|
||||
|
||||
| Rota | Conteúdo |
|
||||
| --- | --- |
|
||||
| `/` | Homepage com hero, notebook de projetos, perfil, competências, experiência, projetos, modalidades de trabalho e contato. |
|
||||
| `/` | 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. |
|
||||
@@ -66,18 +66,19 @@ Todas as dependências diretas estão fixadas em versões exatas no manifesto; o
|
||||
| `/cases/sc-plus` | Case SC Plus Aquicultura. |
|
||||
| `/cases/gcc-installer` | Case MinGW GCC Installer. |
|
||||
| `/cases/as-servicos` | Case AS Serviços. |
|
||||
| `/servicos` | Índice dos seis serviços, com explicações de uso e acesso ao detalhamento técnico. |
|
||||
| `/servicos` | Página de destino comercial: sites, sistemas e aplicativos, contato direto no WhatsApp, cases, processo e acesso aos seis serviços. |
|
||||
| `/servicos/criacao-de-sites` | Criação de sites. |
|
||||
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida. |
|
||||
| `/servicos/sob-medida` | Redirecionamento permanente (308) para `/servicos/engenharia-de-software`, preservando query string, UTMs e identificadores de clique. |
|
||||
| `/servicos/consultoria-nextjs` | Consultoria Next.js. |
|
||||
| `/servicos/tauri-rust` | Aplicativos desktop para Windows e Linux, com compatibilidade definida por projeto. |
|
||||
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT. |
|
||||
| `/servicos/arquitetura-de-software` | Arquitetura de software. |
|
||||
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. |
|
||||
|
||||
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.svg` e `/favicon.ico`. Caminhos desconhecidos usam a página 404 do projeto. Não há API route ativa.
|
||||
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. Sobre e Contato apontam para `/#about` e `/#contact`, com ajuste de chegada após a definição do layout do notebook.
|
||||
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
|
||||
|
||||
@@ -87,12 +88,53 @@ O conteúdo principal vem de `public/portfolio-data.json` e `lib/site-content.ts
|
||||
|
||||
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 3D é carregado no cliente a partir de `1024px`, sem `prefers-reduced-motion: reduce` e sem ponteiro coarse. O notebook 3D exige mais de `1024px`, hover disponível e as mesmas condições de movimento e ponteiro. Nos demais casos, o notebook entrega a navegação HTML completa sem WebGL ou movimento. No desktop, tampa, tela, abas e cursor seguem o mesmo progresso de scroll; a cena renderiza sob demanda e pausa fora do viewport ou com a página oculta.
|
||||
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.
|
||||
@@ -101,6 +143,22 @@ O formulário da homepage valida os campos nativos de nome, e-mail, tipo e conte
|
||||
|
||||
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
|
||||
@@ -119,7 +177,7 @@ 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 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.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -58,16 +58,12 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
|
||||
}
|
||||
|
||||
function expectCaseCtas() {
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: "Avaliar perfil para contratação",
|
||||
}),
|
||||
).toHaveAttribute("href", "/curriculo");
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: "Solicitar consultoria técnica",
|
||||
}),
|
||||
).toHaveAttribute("href", "/#contact");
|
||||
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 () => {
|
||||
@@ -213,7 +209,7 @@ it("renders the authorized detailed case in order with traceable sources", async
|
||||
});
|
||||
|
||||
it("keeps the fallback concise and links its declared services", async () => {
|
||||
const caseStudy = getCase("gcc-installer");
|
||||
const caseStudy = getCase("sc-plus");
|
||||
expect(caseStudy.detail).toBeUndefined();
|
||||
const ui = await CasePage({
|
||||
params: Promise.resolve({ slug: caseStudy.id }),
|
||||
@@ -226,7 +222,9 @@ it("keeps the fallback concise and links its declared services", async () => {
|
||||
for (const technology of caseStudy.stack) {
|
||||
expect(screen.getByText(technology)).toBeVisible();
|
||||
}
|
||||
expect(screen.queryByRole("link", { name: "Abrir demonstração" })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Abrir demonstração" }),
|
||||
).toHaveAttribute("href", caseStudy.demoUrl);
|
||||
expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute(
|
||||
"href",
|
||||
caseStudy.githubUrl,
|
||||
|
||||
@@ -35,10 +35,11 @@ export async function generateMetadata({
|
||||
|
||||
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
return {
|
||||
title: `${caseStudy.title} | Case de Engenharia de Software`,
|
||||
title: `${caseStudy.title} | Case de Engenharia`,
|
||||
description: caseStudy.summary,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: caseStudy.title,
|
||||
description: caseStudy.summary,
|
||||
url,
|
||||
@@ -109,6 +110,14 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
<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}
|
||||
@@ -236,9 +245,9 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
<Link
|
||||
className="ds-link ds-link-accent"
|
||||
href={intentCtas.hiring.href}
|
||||
href={intentCtas.project.href}
|
||||
>
|
||||
{intentCtas.hiring.label}
|
||||
{intentCtas.project.label}
|
||||
</Link>
|
||||
<Link
|
||||
className="ds-link ds-link-muted"
|
||||
|
||||
@@ -18,6 +18,7 @@ export const metadata: Metadata = {
|
||||
canonical: pageUrl,
|
||||
},
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
url: pageUrl,
|
||||
@@ -136,11 +137,11 @@ export default function CasesPage() {
|
||||
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={intentCtas.hiring.href}
|
||||
href={intentCtas.project.href}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
{intentCtas.hiring.label}
|
||||
{intentCtas.project.label}
|
||||
</MagneticButton>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
|
||||
@@ -15,16 +15,16 @@ const STORY_DATA = [
|
||||
"Construo a ponte entre dispositivos, protocolos de baixo nível, backend e interface para que o sistema inteiro pareça uma única experiência coerente.",
|
||||
},
|
||||
{
|
||||
label: "Execution",
|
||||
label: "Decisões técnicas",
|
||||
title: "Entrega clara mesmo sob restrição",
|
||||
content:
|
||||
"Trabalho bem em contextos com legado, integrações complexas e pouca margem para erro, priorizando estabilidade, leitura técnica e evolução sustentável.",
|
||||
"Avalio o que já existe, identifico dependências e combino prioridades antes de alterar sistemas em uso. Nem todo problema exige começar novamente.",
|
||||
},
|
||||
{
|
||||
label: "Production mindset",
|
||||
label: "Operação",
|
||||
title: "Do código ao sistema no ar",
|
||||
content:
|
||||
"Penso além da feature: deploy, observabilidade, manutenção e riscos de operação entram na decisão técnica desde o primeiro corte.",
|
||||
"Publicação, diagnóstico de falhas, documentação e manutenção entram no planejamento, junto com as funcionalidades.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -40,9 +40,9 @@ const PROFILE_POINTS = [
|
||||
"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, implementação, qualidade e operação.",
|
||||
"Contato direto com quem define a arquitetura e desenvolve o sistema.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -86,14 +86,14 @@ export default function About() {
|
||||
return (
|
||||
<Section
|
||||
id="about"
|
||||
eyebrow="Operating context"
|
||||
title="Engenharia de software, arquitetura e integrações complexas"
|
||||
description="Atuo na interseção entre arquitetura de software, integrações 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" 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>
|
||||
}
|
||||
>
|
||||
@@ -140,14 +140,14 @@ export default function About() {
|
||||
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 técnica e postura de entrega</h3>
|
||||
<h3 className="ds-card-title">Uma visão do sistema inteiro</h3>
|
||||
<p className="ds-copy" data-reel="top">
|
||||
Sou engenheiro da computação e desenvolvedor de software full-stack,
|
||||
atuando ha 5 anos com foco em integrar camadas que normalmente ficam
|
||||
separadas: hardware, servicos, operacao e interface. Meu trabalho e
|
||||
dar forma clara ao que costuma nascer fragmentado.
|
||||
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" data-reel-stagger="bottom">
|
||||
{PROFILE_POINTS.map((point) => (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -1,231 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type React from "react";
|
||||
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { trackWhatsAppOpen } from "@/lib/analytics";
|
||||
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { Section } from "./Section";
|
||||
import WhatsAppLink from "./WhatsAppLink";
|
||||
|
||||
// WhatsApp brand silhouette from Simple Icons (simple-icons/simple-icons).
|
||||
function WhatsAppMark({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Contact() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||
|
||||
useEffect(() => {
|
||||
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||
}, []);
|
||||
|
||||
const fieldClassName =
|
||||
"mt-sp-2 w-full border border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.06)] px-sp-4 py-[14px] text-[13px] leading-6 text-white outline-none transition-[border-color,box-shadow] focus-visible:border-[var(--slash)] focus-visible:ring-2 focus-visible:ring-[rgba(204,255,0,0.34)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
|
||||
"mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
|
||||
const labelClassName = "block font-display text-sm font-medium text-white/85";
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "full-stack",
|
||||
subject: "Sistemas web sob medida",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const messageTemplate = `*NOVA SOLICITACAO DE PROJETO*\n\n*Origem:* Portfolio Profissional\n*Prioridade:* Alta\n\n---\n*CLIENTE:* ${formData.name}\n*EMAIL:* ${formData.email}\n*AREA:* ${formData.subject.toUpperCase()}\n\n*DESCRICAO:*\n"${formData.message}"\n---\n_Mensagem gerada via Evandro.dev | Garuva, SC_`;
|
||||
|
||||
const whatsappUrl = buildWhatsAppUrl(messageTemplate);
|
||||
|
||||
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const gridVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.05,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 18, clipPath: "inset(100% 0 0 0)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.75, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
if (!e.currentTarget.reportValidity()) return;
|
||||
const messageTemplate = `Olá, Evandro! Quero conversar sobre um projeto.\n\nNome: ${formData.name}\nE-mail: ${formData.email}\nTipo de projeto: ${formData.subject}\n\nContexto, objetivo e prazo:\n${formData.message}\n\nContato iniciado pelo site evandro.dev.br.`;
|
||||
window.open(
|
||||
buildWhatsAppUrl(messageTemplate),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
<section
|
||||
id="contact"
|
||||
eyebrow="Contact"
|
||||
title="Consultoria técnica para software, arquitetura e integrações críticas"
|
||||
description="Fale comigo se você precisa de engenharia de software, consultoria técnica, 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" data-reel="left">
|
||||
Availability
|
||||
</div>
|
||||
<DrawLine delay={0.04} />
|
||||
<h3 className="ds-card-title" data-reel="top">
|
||||
Resposta inicial em ate 24h
|
||||
</h3>
|
||||
<p className="ds-copy" data-reel="right">
|
||||
Ideal para discovery tecnico, revisao de arquitetura, rescue de
|
||||
projeto, performance ou construcao de produto com ownership real.
|
||||
<div className="ds-section-shell grid items-start gap-sp-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-16">
|
||||
<div className="min-w-0">
|
||||
<p className="ds-eyebrow">Contato</p>
|
||||
<h2
|
||||
id="contact-title"
|
||||
className="ds-section-title mt-sp-4 max-w-[19ch] text-balance"
|
||||
>
|
||||
Vamos conversar sobre o que sua empresa precisa.
|
||||
</h2>
|
||||
<p className="mt-sp-5 max-w-[44ch] font-display text-base leading-8 text-white/70">
|
||||
Conte o que você quer resolver. Pode ser um projeto novo, uma
|
||||
melhoria ou uma integração. Não precisa chegar com a solução
|
||||
definida.
|
||||
</p>
|
||||
<div
|
||||
className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]"
|
||||
data-reel-stagger="bottom"
|
||||
>
|
||||
<div>WhatsApp: {whatsAppDisplay}</div>
|
||||
<div>E-mail: evandrofonsecajunior@gmail.com</div>
|
||||
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
|
||||
<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-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]"
|
||||
data-reel="fade"
|
||||
>
|
||||
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
|
||||
conversa tecnica objetiva.
|
||||
<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>
|
||||
</motion.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>
|
||||
|
||||
<motion.form
|
||||
<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 }}
|
||||
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>
|
||||
<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 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>
|
||||
<label
|
||||
htmlFor="contact-email"
|
||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
||||
<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"
|
||||
>
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
<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 Técnica Sênior</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}
|
||||
>
|
||||
Enviar briefing pelo WhatsApp
|
||||
</MagneticButton>
|
||||
</motion.form>
|
||||
</motion.div>
|
||||
</Section>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,13 @@ import { render, screen } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Footer contact and navigation", () => {
|
||||
it("renders the contact card and a mailto link", () => {
|
||||
it("offers a direct mailto link without repeating the contact call to action", () => {
|
||||
render(<Footer />);
|
||||
|
||||
expect(screen.getByText("Contato")).toBeInTheDocument();
|
||||
// o convite principal fica na secao Contato, logo acima do rodape
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Engenharia com menos ruído e mais clareza de execução",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
screen.queryByRole("link", { name: "Conversar sobre meu projeto" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const emailLink = screen.getByRole("link", {
|
||||
name: "[email protected]",
|
||||
@@ -33,32 +31,32 @@ describe("Footer contact and navigation", () => {
|
||||
"/privacidade",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the résumé page and PDF download as the final secondary links", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const résuméLink = screen.getByRole("link", { name: "Currículo" });
|
||||
const downloadLink = screen.getByRole("link", {
|
||||
name: "Baixar currículo (PDF)",
|
||||
});
|
||||
expect(résuméLink).toHaveAttribute("href", "/curriculo");
|
||||
expect(downloadLink).toHaveAttribute("href", "/Profile.pdf");
|
||||
expect(downloadLink).toHaveAttribute(
|
||||
"download",
|
||||
"Evandro_Fonseca_Junior_CV.pdf",
|
||||
);
|
||||
expect(screen.getAllByRole("link").slice(-2)).toEqual([
|
||||
résuméLink,
|
||||
downloadLink,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer social links", () => {
|
||||
it("renders each social link with an accessible name", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const socialLinks = Array.from(
|
||||
document.querySelectorAll("footer a[aria-label]"),
|
||||
);
|
||||
|
||||
expect(socialLinks.map((link) => link.getAttribute("aria-label"))).toEqual([
|
||||
"Visit GitHub profile",
|
||||
"Visit LinkedIn profile",
|
||||
"Visit Instagram profile",
|
||||
]);
|
||||
expect(
|
||||
new Set(socialLinks.map((link) => link.getAttribute("aria-label"))).size,
|
||||
).toBe(socialLinks.length);
|
||||
});
|
||||
|
||||
it("opens social links securely in a new tab", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const socialLinks = Array.from(
|
||||
document.querySelectorAll("footer a[aria-label]"),
|
||||
);
|
||||
const socialLinks = screen.getAllByRole("link", { name: /profile/i });
|
||||
|
||||
for (const link of socialLinks) {
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
@@ -75,16 +73,3 @@ describe("Footer social links", () => {
|
||||
).toContain("https://www.linkedin.com/in/evandrodevbr/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer copyright", () => {
|
||||
it("renders the current year without hardcoded copyright text", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
expect(
|
||||
screen.getByText(
|
||||
new RegExp(`Copyright ${currentYear} Evandro Fonseca Junior`),
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,9 @@ const quickLinks = [
|
||||
},
|
||||
];
|
||||
|
||||
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();
|
||||
@@ -50,23 +52,14 @@ export default function Footer() {
|
||||
<div>
|
||||
<div className="ds-kicker">Contato</div>
|
||||
<span className="ds-draw-line" />
|
||||
<h3 className="ds-card-title">
|
||||
Engenharia com menos ruído e mais clareza de execução
|
||||
</h3>
|
||||
<p className="ds-copy max-w-[48ch]">
|
||||
Se você precisa de alguém para conectar produto, integração e
|
||||
entrega com mais critério técnico, este portfólio é o ponto de
|
||||
partida.
|
||||
</p>
|
||||
|
||||
<div className="mt-sp-6">
|
||||
<div className="mt-sp-5">
|
||||
<p className="mb-2 text-[12px] tracking-[0.02em] text-[var(--meta)]">
|
||||
E-mail direto
|
||||
</p>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="mailto:[email protected]"
|
||||
className="inline-block text-[18px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)]"
|
||||
className="inline-flex min-h-11 items-center break-all text-[16px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)] sm:text-[18px]"
|
||||
strength={0.2}
|
||||
>
|
||||
evandrofonsecajunior@gmail.com
|
||||
@@ -75,6 +68,13 @@ export default function Footer() {
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-8">
|
||||
<a
|
||||
href="/#home"
|
||||
aria-label="Evandro.dev.br — início"
|
||||
className="mb-sp-6 inline-flex min-h-11 max-w-full items-center"
|
||||
>
|
||||
<Logo theme="light" className="w-60" />
|
||||
</a>
|
||||
<div className="ds-metric">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-slash" />
|
||||
Garuva, SC | Remote worldwide
|
||||
@@ -143,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>
|
||||
);
|
||||
|
||||
@@ -79,10 +79,10 @@ describe("GoogleAnalytics consent", () => {
|
||||
"src",
|
||||
GA_SRC,
|
||||
);
|
||||
const dataLayer = analyticsWindow.dataLayer;
|
||||
expect(dataLayer).toHaveLength(4);
|
||||
expect(dataLayer?.every((entry) => !Array.isArray(entry))).toBe(true);
|
||||
expect(Array.from(dataLayer?.[0] ?? [])).toEqual([
|
||||
const commands = analyticsWindow.dataLayer?.map((entry) =>
|
||||
Array.from(entry),
|
||||
);
|
||||
expect(commands).toContainEqual([
|
||||
"consent",
|
||||
"default",
|
||||
{
|
||||
@@ -92,8 +92,7 @@ describe("GoogleAnalytics consent", () => {
|
||||
ad_personalization: "denied",
|
||||
},
|
||||
]);
|
||||
expect(Array.from(dataLayer?.[1] ?? [])).toEqual(["js", expect.any(Date)]);
|
||||
expect(Array.from(dataLayer?.[2] ?? [])).toEqual([
|
||||
expect(commands).toContainEqual([
|
||||
"consent",
|
||||
"update",
|
||||
{
|
||||
@@ -103,7 +102,7 @@ describe("GoogleAnalytics consent", () => {
|
||||
ad_personalization: "denied",
|
||||
},
|
||||
]);
|
||||
expect(Array.from(dataLayer?.[3] ?? [])).toEqual([
|
||||
expect(commands).toContainEqual([
|
||||
"config",
|
||||
"G-QTT9QT0T3S",
|
||||
{
|
||||
@@ -111,6 +110,12 @@ describe("GoogleAnalytics consent", () => {
|
||||
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();
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
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 CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1";
|
||||
const GA_DISABLE_KEY = "ga-disable-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";
|
||||
|
||||
@@ -16,26 +22,6 @@ const deniedConsent = {
|
||||
ad_personalization: "denied",
|
||||
} as const;
|
||||
|
||||
type AnalyticsConsent = "granted" | "denied";
|
||||
type Gtag = (...args: unknown[]) => void;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: IArguments[];
|
||||
"ga-disable-G-QTT9QT0T3S"?: boolean;
|
||||
gtag?: Gtag;
|
||||
}
|
||||
}
|
||||
|
||||
function readConsent(): AnalyticsConsent | null {
|
||||
try {
|
||||
const value = localStorage.getItem(CONSENT_STORAGE_KEY);
|
||||
return value === "granted" || value === "denied" ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistConsent(value: AnalyticsConsent): boolean {
|
||||
try {
|
||||
localStorage.setItem(CONSENT_STORAGE_KEY, value);
|
||||
@@ -107,6 +93,7 @@ function GoogleAnalyticsConsent() {
|
||||
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;
|
||||
}
|
||||
@@ -118,6 +105,10 @@ function GoogleAnalyticsConsent() {
|
||||
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(() => {
|
||||
@@ -198,8 +189,9 @@ function GoogleAnalyticsConsent() {
|
||||
id="analytics-consent-description"
|
||||
className="mt-sp-2 max-w-[48ch] text-[13px] leading-6 text-white/85"
|
||||
>
|
||||
Você decide se permite métricas anônimas para melhorar este site.
|
||||
Publicidade e personalização continuam desativadas.
|
||||
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
|
||||
@@ -254,7 +246,10 @@ function GoogleAnalyticsConsent() {
|
||||
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)}
|
||||
>
|
||||
Preferências de analytics
|
||||
<span className="max-sm:hidden">Preferências de analytics</span>
|
||||
<span aria-hidden="true" className="sm:hidden">
|
||||
Cookies
|
||||
</span>
|
||||
</button>
|
||||
{preferencesOpen && (
|
||||
<section
|
||||
@@ -276,7 +271,7 @@ function GoogleAnalyticsConsent() {
|
||||
className="mt-sp-2 text-[13px] leading-6 text-white/85"
|
||||
>
|
||||
Analytics está {consent === "granted" ? "ativo" : "desativado"}.
|
||||
Dados de publicidade permanecem negados.
|
||||
Cookies de publicidade e personalização permanecem negados.
|
||||
</p>
|
||||
{consentError && (
|
||||
<p
|
||||
|
||||
@@ -1,32 +1,8 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation";
|
||||
import { Header } from "./Header";
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, tag: string) => {
|
||||
const Component = ({
|
||||
children,
|
||||
animate: _animate,
|
||||
transition: _transition,
|
||||
whileHover: _whileHover,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
React.createElement(tag, props, children);
|
||||
Component.displayName = `motion.${tag}`;
|
||||
return Component;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return { motion, useReducedMotion: () => false };
|
||||
});
|
||||
|
||||
function HomeWithHeader() {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
useEffect(() => {
|
||||
@@ -38,42 +14,38 @@ function HomeWithHeader() {
|
||||
<section id="about" aria-label="Sobre">
|
||||
<h2>Sobre</h2>
|
||||
</section>
|
||||
<section id="contact" aria-label="Contato">
|
||||
<h2>Contato</h2>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Header", () => {
|
||||
it("exposes the primary navigation and the existing CV asset", () => {
|
||||
it("links to commercial destinations without promoting the résumé", () => {
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute(
|
||||
"href",
|
||||
"/#home",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Casos" })).toHaveAttribute(
|
||||
"href",
|
||||
const navigation = screen.getByRole("navigation", {
|
||||
name: "Navegação principal",
|
||||
});
|
||||
expect(
|
||||
within(navigation)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual([
|
||||
"/#modalities",
|
||||
"/cases",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Serviços" })).toHaveAttribute(
|
||||
"href",
|
||||
"/servicos",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Currículo" })).toHaveAttribute(
|
||||
"href",
|
||||
"/curriculo",
|
||||
);
|
||||
|
||||
const cvLink = screen.getByRole("link", { name: "Download CV" });
|
||||
expect(cvLink).toHaveAttribute("href", "/Profile.pdf");
|
||||
expect(cvLink).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf");
|
||||
expect(screen.getByRole("link", { name: "Sobre" })).toHaveAttribute(
|
||||
"href",
|
||||
"/#process",
|
||||
"/#about",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Contato" })).toHaveAttribute(
|
||||
"href",
|
||||
"/#contact",
|
||||
);
|
||||
"/#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", () => {
|
||||
@@ -98,6 +70,23 @@ describe("Header", () => {
|
||||
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",
|
||||
@@ -109,27 +98,34 @@ describe("Header", () => {
|
||||
expect(document.activeElement).toBe(openButton);
|
||||
});
|
||||
|
||||
it("closes the mobile menu without taking focus away from a home destination", () => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
render(<HomeWithHeader />);
|
||||
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).getByRole("link", {
|
||||
name: "Sobre",
|
||||
});
|
||||
mobileLink.focus();
|
||||
fireEvent.click(mobileLink);
|
||||
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: "Sobre" })).toHaveFocus();
|
||||
});
|
||||
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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type KeyboardEvent, useRef, useState } from "react";
|
||||
import {
|
||||
CV_DOWNLOAD_NAME,
|
||||
CV_URL,
|
||||
NAVIGATION_ITEMS,
|
||||
} from "@/lib/site-navigation";
|
||||
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
@@ -13,8 +9,23 @@ 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>) => {
|
||||
@@ -26,49 +37,53 @@ export function Header() {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="ds-section-dark fixed inset-x-0 top-0 z-[9000] h-[52px] w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/95 px-sp-8 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
|
||||
aria-label="Navegação principal"
|
||||
className="site-header-nav items-center"
|
||||
className="hidden items-center xl:flex"
|
||||
>
|
||||
<ul className="flex list-none items-center gap-sp-2">
|
||||
<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">
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={CV_URL}
|
||||
download={CV_DOWNLOAD_NAME}
|
||||
className="site-header-cv ds-link ds-link-accent 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)]"
|
||||
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="site-header-menu-toggle 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)]"
|
||||
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-controls={mobileMenuOpen ? MENU_ID : undefined}
|
||||
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
|
||||
@@ -89,7 +104,7 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
{mobileMenuOpen ? (
|
||||
<div className="site-header-mobile-panel absolute inset-x-0 top-[52px] border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/98 px-sp-6 backdrop-blur-xl lg:px-sp-16">
|
||||
<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}
|
||||
@@ -102,10 +117,11 @@ export function Header() {
|
||||
<li key={item.href}>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
strength={0}
|
||||
wrapperClassName="w-full"
|
||||
href={item.href}
|
||||
className="ds-link block rounded-sm py-sp-3 text-white/85 transition-[padding,color] hover:pl-sp-2 hover:text-white"
|
||||
className="flex min-h-11 items-center rounded-sm px-sp-3 py-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
|
||||
onClick={closeMobileMenu}
|
||||
strength={0.15}
|
||||
>
|
||||
{item.label}
|
||||
</MagneticButton>
|
||||
@@ -114,16 +130,12 @@ export function Header() {
|
||||
<li>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={CV_URL}
|
||||
download={CV_DOWNLOAD_NAME}
|
||||
className="ds-link ds-link-accent mt-sp-2 inline-flex rounded-sm font-semibold"
|
||||
onClick={() => {
|
||||
closeMobileMenu();
|
||||
menuButtonRef.current?.focus();
|
||||
}}
|
||||
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}
|
||||
>
|
||||
Download CV
|
||||
Falar sobre meu projeto
|
||||
</MagneticButton>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -8,13 +8,6 @@ jest.mock("motion/react", () => ({
|
||||
...jest.requireActual("motion/react"),
|
||||
useReducedMotion: jest.fn(),
|
||||
}));
|
||||
jest.mock("next/dynamic", () => () => () => null);
|
||||
jest.mock("./cinematic/ControlRoomGlobe", () => ({
|
||||
ControlRoomGlobe: () => null,
|
||||
}));
|
||||
jest.mock("./cinematic/HeroStarfield", () => ({
|
||||
HeroStarfield: () => null,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
@@ -3,12 +3,6 @@ import type { ReactNode } from "react";
|
||||
|
||||
import Hero from "./Hero";
|
||||
|
||||
jest.mock("next/dynamic", () => () => {
|
||||
const DynamicComponent = () => <div data-testid="hero-scene" />;
|
||||
DynamicComponent.displayName = "MockHeroScene";
|
||||
return DynamicComponent;
|
||||
});
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
@@ -20,9 +14,6 @@ jest.mock("motion/react", () => {
|
||||
children,
|
||||
initial: _initial,
|
||||
animate: _animate,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
variants: _variants,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
@@ -33,53 +24,11 @@ jest.mock("motion/react", () => {
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
|
||||
motion,
|
||||
useInView: () => true,
|
||||
useReducedMotion: () => false,
|
||||
useMotionValue: (initial: number) => ({
|
||||
get: () => initial,
|
||||
set: jest.fn(),
|
||||
}),
|
||||
useSpring: (
|
||||
value: { get: () => number; set: (v: number) => void },
|
||||
_config: unknown,
|
||||
) => value,
|
||||
useTransform: (
|
||||
value: { get: () => number; set: (v: number) => void },
|
||||
_input: number[],
|
||||
_output: number[],
|
||||
) => value,
|
||||
};
|
||||
return { motion, useReducedMotion: () => false };
|
||||
});
|
||||
|
||||
jest.mock("cobe", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
destroy: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
function mockMatchMedia(matches = false) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("Hero", () => {
|
||||
beforeEach(() => {
|
||||
mockMatchMedia(false);
|
||||
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||
});
|
||||
|
||||
@@ -87,37 +36,31 @@ describe("Hero", () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the published command-center content and primary actions", () => {
|
||||
it("routes project conversations to contact without opening an external service", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Disponível para novas oportunidades"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 1,
|
||||
name: "Engenharia de software para sistemas que precisam operar de verdade.",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Conversar sobre uma oportunidade",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Agendar no WhatsApp" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Ver casos selecionados" }),
|
||||
).toHaveAttribute("href", "/cases");
|
||||
expect(screen.getByRole("link", { name: "Download CV" })).toHaveAttribute(
|
||||
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",
|
||||
"/Profile.pdf",
|
||||
"/cases",
|
||||
);
|
||||
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps social destinations, operational proof and WhatsApp action", () => {
|
||||
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(
|
||||
@@ -132,28 +75,10 @@ describe("Hero", () => {
|
||||
"href",
|
||||
"mailto:[email protected]",
|
||||
);
|
||||
expect(screen.getByText("Garuva, SC / Brasil")).toBeInTheDocument();
|
||||
expect(screen.getByText("Operação")).toBeInTheDocument();
|
||||
expect(screen.getByText("Clareza")).toBeInTheDocument();
|
||||
expect(screen.getByText("Resultado")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("region", {
|
||||
name: "Telemetria operacional em tempo real",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Globo interativo centrado em Joinville/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Conversar sobre uma oportunidade",
|
||||
}),
|
||||
);
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining("https://wa.me/"),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
for (const name of ["GitHub", "LinkedIn"]) {
|
||||
const link = screen.getByRole("link", { name });
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,507 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
|
||||
import { CV_URL } from "@/lib/site-navigation";
|
||||
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||
import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe";
|
||||
import { HeroStarfield } from "./cinematic/HeroStarfield";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { TelemetryStrip } from "./cinematic/TelemetryStrip";
|
||||
|
||||
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_CONTENT = {
|
||||
status: "Disponível para novas oportunidades",
|
||||
chip: "Garuva · Joinville · Curitiba · Remoto",
|
||||
eyebrow:
|
||||
"Evandro Fonseca Junior | Engenheiro da Computação e Desenvolvedor de Software",
|
||||
headline:
|
||||
"Engenharia de software para sistemas que precisam operar de verdade.",
|
||||
subtitle:
|
||||
"Profissional disponível para oportunidades CLT e PJ em Garuva, Joinville, Norte de Santa Catarina e Curitiba; também atuo em consultoria para produtos, integrações e sistemas críticos.",
|
||||
cta: "Conversar sobre uma oportunidade",
|
||||
whatsappMessage:
|
||||
"Olá! Quero conversar sobre uma oportunidade profissional em engenharia de software.",
|
||||
sideLabel: "Contato profissional direto",
|
||||
sideTitle: "Procurando um engenheiro da computação para o seu time?",
|
||||
sideText:
|
||||
"Vamos conversar sobre o contexto técnico, o desafio da vaga e como posso contribuir da arquitetura à entrega.",
|
||||
points: [
|
||||
"Engenharia full-stack com base em sistemas e integrações.",
|
||||
"Next.js, TypeScript, Rust, IoT e arquitetura de software.",
|
||||
"Disponível para times do Norte de SC, Curitiba e atuação remota.",
|
||||
],
|
||||
proof: [
|
||||
{
|
||||
label: "Operação",
|
||||
value: "Fluxo pensado para discovery, build, deploy e sustentação.",
|
||||
},
|
||||
{
|
||||
label: "Clareza",
|
||||
value: "Menos ruído entre produto, engenharia e decisão técnica.",
|
||||
},
|
||||
{
|
||||
label: "Resultado",
|
||||
value: "Base pronta para operar, evoluir e receber próximo ciclo.",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
/** 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;
|
||||
};
|
||||
|
||||
function getGreeting(message: string) {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 8 && hour < 12) return `Bom dia! ${message}`;
|
||||
if (hour >= 13 && hour < 18) return `Boa tarde! ${message}`;
|
||||
return message;
|
||||
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 heroRef = useRef<HTMLElement>(null);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
|
||||
const [isHeroVisible, setIsHeroVisible] = useState(true);
|
||||
/** 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]);
|
||||
|
||||
useEffect(() => {
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
/** 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;
|
||||
|
||||
if (
|
||||
!shouldLoadHeroScene({
|
||||
reducedMotion,
|
||||
coarsePointer,
|
||||
width: window.innerWidth,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setShouldRenderHeroScene(true), 1500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const hero = heroRef.current;
|
||||
if (!hero || !("IntersectionObserver" in window)) return undefined;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsHeroVisible(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
observer.observe(hero);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleWhatsAppClick = () => {
|
||||
window.open(
|
||||
buildWhatsAppUrl(getGreeting(HERO_CONTENT.whatsappMessage)),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
};
|
||||
|
||||
const heroContainer: Variants = {
|
||||
hidden: shouldReduceMotion ? { opacity: 1 } : { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: { staggerChildren: 0.1, delayChildren: 0.3 },
|
||||
},
|
||||
};
|
||||
|
||||
const easeOut: [number, number, number, number] = [0.16, 1, 0.3, 1];
|
||||
|
||||
const heroItem: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 1, y: 0, filter: "blur(0px)" }
|
||||
: { opacity: 0, y: 40, filter: "blur(10px)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 1, ease: easeOut },
|
||||
},
|
||||
};
|
||||
|
||||
const heroItemSlow: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 1, y: 0, filter: "blur(0px)" }
|
||||
: { opacity: 0, y: 60, filter: "blur(12px)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 1.2, ease: easeOut },
|
||||
},
|
||||
};
|
||||
export default function Hero({ stats }: { stats?: HeroStats }) {
|
||||
const items = statItems(stats);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={heroRef}
|
||||
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]"
|
||||
>
|
||||
{shouldRenderHeroScene && <HeroScene isVisible={isHeroVisible} />}
|
||||
<div className="hero-stage">
|
||||
<div className="hero-glow" aria-hidden="true" />
|
||||
<HeroField />
|
||||
<div className="hero-scrim" aria-hidden="true" />
|
||||
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
||||
<ol className="hero-captions">
|
||||
{CAPTIONS.map(([title, text], index) => (
|
||||
<li key={title} style={{ "--i": index } as CSSProperties}>
|
||||
<span className="hero-caption-title">{title}</span>
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.4)_50%,var(--ink)_100%)]" />
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.015]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 z-[2] bg-[radial-gradient(circle_at_50%_50%,transparent_40%,rgba(10,10,10,0.6)_100%)]" />
|
||||
|
||||
{/* pt >= altura do header fixo (52px): conteúdo do hero nunca nasce sob a barra */}
|
||||
<div className="relative z-10 mx-auto flex w-full max-w-[1240px] flex-col px-sp-6 pt-sp-16 pb-sp-16 lg:max-h-none lg:px-sp-16 lg:pt-sp-14 lg:pb-sp-12">
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? false : "hidden"}
|
||||
animate="visible"
|
||||
variants={heroContainer}
|
||||
className="grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10"
|
||||
>
|
||||
<div className="max-w-[800px]">
|
||||
<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)]">
|
||||
{HERO_CONTENT.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cinematic-glass inline-flex items-center gap-sp-2 px-4 py-2 text-[11px] text-[rgba(244,242,237,.72)]">
|
||||
{HERO_CONTENT.chip}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
className="mb-sp-4 max-w-[560px] font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]"
|
||||
variants={heroItem}
|
||||
>
|
||||
{HERO_CONTENT.eyebrow}
|
||||
</motion.p>
|
||||
|
||||
<motion.h1
|
||||
className="max-w-[940px] font-display text-[clamp(1.9rem,5.2vw,4.75rem)] font-extrabold leading-[0.95] tracking-[-0.055em] lg:leading-[0.9] lg:text-balance"
|
||||
variants={heroItemSlow}
|
||||
>
|
||||
{HERO_CONTENT.headline}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
className="mt-sp-6 max-w-[700px] text-[16px] leading-8 text-[rgba(244,242,237,.88)] lg:text-[17px]"
|
||||
variants={heroItem}
|
||||
>
|
||||
{HERO_CONTENT.subtitle}
|
||||
</motion.p>
|
||||
|
||||
<motion.ul
|
||||
className="mt-sp-8 grid gap-sp-3 sm:grid-cols-3"
|
||||
variants={heroItem}
|
||||
>
|
||||
{HERO_CONTENT.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
|
||||
? false
|
||||
: { opacity: 0, y: 30, scale: 0.95 }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 0.8,
|
||||
delay: 0.8 + i * 0.1,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}
|
||||
}
|
||||
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-8 lg:px-sp-12">
|
||||
<p className="hero-in mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
|
||||
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
||||
Evandro Fonseca Junior · Engenharia de software
|
||||
</p>
|
||||
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
|
||||
<h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw)] text-[clamp(38px,min(6vw,10.5svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
|
||||
{TITLE.map((word, index) => (
|
||||
<Fragment key={word}>
|
||||
<span
|
||||
className={`hero-word${ACCENT.has(index) ? " text-[var(--slash)]" : ""}`}
|
||||
style={{ "--i": index } as CSSProperties}
|
||||
>
|
||||
<span className="mt-[8px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--slash)] shadow-[0_0_8px_rgba(204,255,0,0.5)]" />
|
||||
<span>{point}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
{word}
|
||||
</span>{" "}
|
||||
</Fragment>
|
||||
))}
|
||||
</h1>
|
||||
<p className="hero-in mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s]">
|
||||
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
|
||||
entendimento do negócio à arquitetura e à entrega em produção.
|
||||
</p>
|
||||
|
||||
<motion.div
|
||||
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
|
||||
variants={heroItem}
|
||||
<div className="hero-in mt-sp-6 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Link
|
||||
href="/#contact"
|
||||
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
||||
>
|
||||
<MagneticButton
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={handleWhatsAppClick}
|
||||
className="cinematic-cta-primary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#0A0A0A] no-underline"
|
||||
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">{HERO_CONTENT.cta}</span>
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/cases"
|
||||
className="cinematic-cta-secondary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#f4f2ed] no-underline"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver casos selecionados
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={CV_URL}
|
||||
download
|
||||
className="inline-flex min-h-12 items-center justify-center px-sp-2 text-[12px] font-medium text-[rgba(244,242,237,.76)] no-underline transition-colors duration-300 hover:text-[var(--slash)]"
|
||||
strength={0.2}
|
||||
>
|
||||
Download CV
|
||||
</MagneticButton>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-sp-10 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3 border-t border-[rgba(255,255,255,.06)] pt-sp-6"
|
||||
variants={heroItem}
|
||||
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/cases"
|
||||
className="inline-flex min-h-12 items-center justify-center border border-white/15 bg-[rgba(10,10,10,.35)] px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline backdrop-blur-sm transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
|
||||
>
|
||||
<div className="inline-flex items-center gap-sp-3 text-[11px] uppercase tracking-[0.08em] text-[rgba(244,242,237,.68)]">
|
||||
<span className="h-2 w-2 rounded-full bg-[var(--slash)] shadow-[0_0_8px_var(--slash)]" />
|
||||
Garuva, SC / Brasil
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-sp-3 text-[11px] uppercase tracking-[0.08em] text-[rgba(244,242,237,.68)]">
|
||||
<span className="h-[1px] w-6 bg-[rgba(255,255,255,.2)]" />
|
||||
Next.js, Tauri, Rust, Node.js
|
||||
</div>
|
||||
<div className="flex items-center gap-sp-2">
|
||||
Ver projetos entregues
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
|
||||
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
|
||||
{items.length > 0 && (
|
||||
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
|
||||
{items.map(([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}
|
||||
aria-label={link.label}
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
link.href.startsWith("http")
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
className="cinematic-social-link inline-flex h-11 w-11 items-center justify-center rounded-full"
|
||||
strength={0.4}
|
||||
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill={link.stroke ? "none" : "currentColor"}
|
||||
stroke={link.stroke ? "currentColor" : "none"}
|
||||
strokeWidth={link.stroke ? "2" : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{link.icon}
|
||||
</svg>
|
||||
</MagneticButton>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
|
||||
Santa Catarina · Atendimento remoto
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.aside
|
||||
className="cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? false
|
||||
: { opacity: 0, x: 60, rotateY: -8, transformPerspective: 1000 }
|
||||
}
|
||||
animate={{ opacity: 1, x: 0, rotateY: 0 }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 1.2,
|
||||
delay: 0.5,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}
|
||||
}
|
||||
<p
|
||||
aria-hidden="true"
|
||||
className="hero-cue hidden items-center gap-sp-3 font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(204,255,0,0.8),transparent)]"
|
||||
initial={shouldReduceMotion ? false : { scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 1.5,
|
||||
delay: 1,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-sp-6 space-y-sp-4">
|
||||
<HeroStarfield />
|
||||
<ControlRoomGlobe autoRotate={!shouldReduceMotion} />
|
||||
<TelemetryStrip reducedMotion={shouldReduceMotion} />
|
||||
</div>
|
||||
|
||||
<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)]">
|
||||
{HERO_CONTENT.sideLabel}
|
||||
</div>
|
||||
<h2 className="mt-sp-3 font-display text-[clamp(1.35rem,3vw,2rem)] font-extrabold leading-[1.04] tracking-[-0.03em] text-[#f7f4ee]">
|
||||
{HERO_CONTENT.sideTitle}
|
||||
</h2>
|
||||
<p className="mt-sp-4 text-[14px] leading-7 text-[rgba(244,242,237,.78)]">
|
||||
{HERO_CONTENT.sideText}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWhatsAppClick}
|
||||
className="mt-sp-5 inline-flex w-full items-center justify-center bg-[var(--slash)] px-sp-5 py-sp-4 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[var(--ink)] transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
|
||||
>
|
||||
{/* Ícone WhatsApp inline: lucide não tem brand icons */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M12.04 2a9.88 9.88 0 0 0-8.41 4.7A9.88 9.88 0 0 0 2.1 12.6a9.8 9.8 0 0 0 1.34 5.02L2 22l4.53-1.38a9.86 9.86 0 0 0 5.51 1.66h.01a9.87 9.87 0 0 0 7.02-2.9 9.79 9.79 0 0 0 2.89-6.98 9.73 9.73 0 0 0-.29-2.35A9.88 9.88 0 0 0 12.04 2Zm5.77 13.94c-.24.68-1.42 1.3-1.96 1.35-.5.05-1.13.24-3.8-.98-3.18-1.44-5.15-4.72-5.31-4.93-.15-.22-1.25-1.75-1.16-3.29.09-1.54.92-2.28 1.23-2.59.31-.31.66-.37.88-.36h.63c.2 0 .47-.03.71.58.26.65.88 2.25.96 2.41.08.17.13.36.02.57-.11.22-.17.35-.33.54-.17.19-.35.43-.5.58-.16.16-.33.34-.15.66.18.32.81 1.42 1.75 2.3 1.2 1.14 2.21 1.49 2.52 1.66.31.17.5.14.68-.09.18-.22.78-.99.99-1.33.21-.34.42-.27.7-.16.29.11 1.82.95 2.13 1.12.31.17.52.26.6.4.07.15.07.83-.17 1.51Z" />
|
||||
</svg>
|
||||
Agendar no WhatsApp
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-6 space-y-sp-5">
|
||||
{HERO_CONTENT.proof.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
|
||||
? false
|
||||
: { opacity: 0, y: 20, filter: "blur(6px)" }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 0.8,
|
||||
delay: 0.8 + index * 0.12,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.56)]">
|
||||
{item.label}
|
||||
</div>
|
||||
<p className="mt-sp-2 text-[14px] leading-7 text-[rgba(244,242,237,.92)]">
|
||||
{item.value}
|
||||
</p>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
Role para mergulhar
|
||||
<span className="hero-cue-line" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2"
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={
|
||||
shouldReduceMotion ? { duration: 0 } : { delay: 2, duration: 1 }
|
||||
}
|
||||
>
|
||||
{shouldReduceMotion ? (
|
||||
<div className="flex flex-col items-center gap-2" aria-hidden="true">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.68)]">
|
||||
Scroll
|
||||
</span>
|
||||
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
|
||||
</div>
|
||||
) : (
|
||||
<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-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.68)]">
|
||||
Scroll
|
||||
</span>
|
||||
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -242,7 +242,7 @@ export default function LighthouseMetrics() {
|
||||
<div className="ds-section-shell">
|
||||
<div className="quality-intro">
|
||||
<header className="quality-copy">
|
||||
<p className="ds-eyebrow">Quality & Performance</p>
|
||||
<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>
|
||||
@@ -252,10 +252,7 @@ export default function LighthouseMetrics() {
|
||||
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="/servicos/consultoria-nextjs"
|
||||
>
|
||||
<a className="ds-link ds-link-accent" href="/#contact">
|
||||
Conversar sobre a qualidade do seu projeto
|
||||
</a>
|
||||
</header>
|
||||
@@ -290,10 +287,7 @@ export default function LighthouseMetrics() {
|
||||
/>
|
||||
<span className="quality-control-content">
|
||||
<span className="quality-control-name">{metric.label}</span>
|
||||
<span className="quality-control-target">
|
||||
Meta <strong>100</strong>
|
||||
<span>/100</span>
|
||||
</span>
|
||||
<span className="quality-control-target">Ver critérios</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -334,8 +328,8 @@ export default function LighthouseMetrics() {
|
||||
</section>
|
||||
|
||||
<p className="quality-disclaimer">
|
||||
Metas de qualidade, não uma auditoria em tempo real. Resultados
|
||||
Lighthouse variam conforme página, dispositivo e condições do teste.
|
||||
Exemplos de critérios avaliados conforme o projeto. Este painel não
|
||||
representa uma auditoria, certificação ou garantia de resultado.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,185 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import Image from "next/image";
|
||||
|
||||
interface LogoProps {
|
||||
variant?: "mark" | "wordmark" | "icon";
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
theme?: "dark" | "light" | "accent";
|
||||
theme?: "dark" | "light";
|
||||
className?: string;
|
||||
showTagline?: boolean;
|
||||
loading?: "eager" | "lazy";
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: { width: 32, height: 32 },
|
||||
md: { width: 48, height: 48 },
|
||||
lg: { width: 64, height: 64 },
|
||||
xl: { width: 120, height: 80 },
|
||||
};
|
||||
|
||||
export function Logo({
|
||||
variant = "mark",
|
||||
size = "md",
|
||||
theme = "dark",
|
||||
className = "",
|
||||
showTagline = false,
|
||||
className = "w-48",
|
||||
loading = "lazy",
|
||||
}: Readonly<LogoProps>) {
|
||||
const dimensions = sizeMap[size];
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const colors = {
|
||||
dark: {
|
||||
primary: "#FFFFFF",
|
||||
slash: "#CCFF00",
|
||||
},
|
||||
light: {
|
||||
primary: "#0A0A0A",
|
||||
slash: "#0A0A0A",
|
||||
},
|
||||
accent: {
|
||||
primary: "#0A0A0A",
|
||||
slash: "rgba(0,0,0,.3)",
|
||||
},
|
||||
};
|
||||
|
||||
const c = colors[theme];
|
||||
|
||||
const hoverMotion = shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: 1.03,
|
||||
rotate: -2,
|
||||
};
|
||||
|
||||
const hoverTransition = {
|
||||
type: "spring" as const,
|
||||
stiffness: 280,
|
||||
damping: 18,
|
||||
};
|
||||
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<motion.svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Evandro icon logo"
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<title>Evandro icon logo</title>
|
||||
<rect width="64" height="64" rx="14" fill={c.primary} />
|
||||
<rect
|
||||
x="10"
|
||||
y="10"
|
||||
width="7"
|
||||
height="44"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<rect
|
||||
x="17"
|
||||
y="10"
|
||||
width="18"
|
||||
height="7"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<rect
|
||||
x="17"
|
||||
y="28.5"
|
||||
width="14"
|
||||
height="7"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<rect
|
||||
x="17"
|
||||
y="47"
|
||||
width="18"
|
||||
height="7"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<polygon points="37,38 41,38 50,26 46,26" fill={c.slash} />
|
||||
<circle
|
||||
cx="52"
|
||||
cy="47"
|
||||
r="5"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "wordmark") {
|
||||
return (
|
||||
<motion.div
|
||||
className={`flex items-center gap-3 ${className}`}
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 48 32"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Evandro wordmark logo"
|
||||
>
|
||||
<title>Evandro wordmark logo</title>
|
||||
<rect x="2" y="2" width="7" height="28" fill={c.primary} />
|
||||
<rect x="9" y="2" width="20" height="7" fill={c.primary} />
|
||||
<rect x="9" y="12.5" width="16" height="7" fill={c.primary} />
|
||||
<rect x="9" y="23" width="20" height="7" fill={c.primary} />
|
||||
<polygon points="30,22 34,22 42,10 38,10" fill={c.slash} />
|
||||
<circle cx="44" cy="25" r="4" fill={c.primary} />
|
||||
</svg>
|
||||
<div>
|
||||
<div
|
||||
className="font-display text-lg font-extrabold tracking-tight"
|
||||
style={{ color: c.primary }}
|
||||
>
|
||||
evandro<span style={{ color: c.slash }}>.</span>dev
|
||||
<span style={{ color: c.slash }}>.</span>br
|
||||
</div>
|
||||
{showTagline && (
|
||||
<div
|
||||
className="mt-1 text-[9px] tracking-widest"
|
||||
style={{
|
||||
color:
|
||||
theme === "dark"
|
||||
? "rgba(255,255,255,.25)"
|
||||
: "rgba(10,10,10,.4)",
|
||||
}}
|
||||
>
|
||||
DESENVOLVIMENTO DE SOFTWARE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 120 80"
|
||||
fill="none"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Evandro mark logo"
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<title>Evandro mark logo</title>
|
||||
<rect x="10" y="8" width="9" height="64" fill={c.primary} />
|
||||
<rect x="19" y="8" width="28" height="9" fill={c.primary} />
|
||||
<rect x="19" y="35.5" width="22" height="9" fill={c.primary} />
|
||||
<rect x="19" y="63" width="28" height="9" fill={c.primary} />
|
||||
<polygon points="50,55 57,55 74,25 67,25" fill={c.slash} />
|
||||
<circle cx="80" cy="63" r="7" fill={c.primary} />
|
||||
</motion.svg>
|
||||
<Image
|
||||
src={`/brand/wordmark-${theme}.webp`}
|
||||
alt="Evandro.dev.br"
|
||||
width={1600}
|
||||
height={330}
|
||||
sizes="240px"
|
||||
loading={loading}
|
||||
className={`block h-auto max-w-full ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
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 MagneticButton from "./cinematic/MagneticButton";
|
||||
import { ProjectCover } from "./ProjectCover";
|
||||
import { Section } from "./Section";
|
||||
|
||||
export default function Projects({
|
||||
@@ -14,24 +16,11 @@ export default function Projects({
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const gridVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.08,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 24, clipPath: "inset(100% 0 0 0)" },
|
||||
hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
|
||||
@@ -41,108 +30,135 @@ export default function Projects({
|
||||
return (
|
||||
<Section
|
||||
id="projects"
|
||||
eyebrow="Selected work"
|
||||
title="Projetos em Next.js, Tauri, Rust e integração de sistemas"
|
||||
description="Cases e produtos que mostram minha atuação em engenharia de software, consultoria técnica e construção de sistemas de alta exigência."
|
||||
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 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">{project.description}</p>
|
||||
|
||||
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
|
||||
{project.technologies.slice(0, 5).map((tech) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
|
||||
{project.githubUrl && (
|
||||
<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="a"
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-muted"
|
||||
strength={0.25}
|
||||
as="span"
|
||||
className="ds-tag"
|
||||
strength={0.15}
|
||||
wrapperClassName="shrink-0 max-sm:ml-auto"
|
||||
>
|
||||
Ver codigo
|
||||
{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>
|
||||
)}
|
||||
{project.demoUrl &&
|
||||
(project.demoAvailability === "available" ||
|
||||
project.demoAvailability === "unknown") && (
|
||||
{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={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
strength={0.2}
|
||||
>
|
||||
Abrir projeto
|
||||
{project.demoAvailability === "unknown" && (
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
Disponibilidade não verificada
|
||||
</span>
|
||||
)}
|
||||
Entender a solução
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoAvailability === "unavailable" && (
|
||||
<span className="ds-link ds-link-accent" aria-disabled="true">
|
||||
Demo indisponível na última verificação
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</motion.div>
|
||||
{project.githubUrl && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-muted"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver codigo
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoUrl &&
|
||||
(project.demoAvailability === "available" ||
|
||||
project.demoAvailability === "unknown") && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
Abrir projeto
|
||||
{project.demoAvailability === "unknown" && (
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
Disponibilidade não verificada
|
||||
</span>
|
||||
)}
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoAvailability === "unavailable" && (
|
||||
<span className="ds-link ds-link-accent" aria-disabled="true">
|
||||
Demo indisponível na última verificação
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
|
||||
<MagneticButton
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { Section } from "./Section";
|
||||
interface Tier {
|
||||
id: string;
|
||||
badge: string;
|
||||
level: string;
|
||||
title: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
@@ -25,64 +24,83 @@ interface Tier {
|
||||
|
||||
const TIERS: Tier[] = [
|
||||
{
|
||||
id: "discovery",
|
||||
badge: "Entrada / Baixo Risco",
|
||||
level: "01",
|
||||
title: "Auditoria & Diagnóstico Técnico",
|
||||
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:
|
||||
"Análise profunda de arquitetura, performance ou segurança com entrega de um plano de ação executável. Ideal para quem precisa de direção antes de contratar execução.",
|
||||
"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: [
|
||||
"Relatório de gargalos e dívida técnica",
|
||||
"Mapeamento de riscos e dependências",
|
||||
"Sugestão de stack e cronograma de mitigação",
|
||||
"Plano de ação 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 único. Entrega em até 5 dias úteis.",
|
||||
featured: false,
|
||||
ctaLabel: "Solicitar diagnóstico",
|
||||
ctaHref: "#contact",
|
||||
},
|
||||
{
|
||||
id: "cycles",
|
||||
badge: "Mais Escolhido",
|
||||
level: "02",
|
||||
title: "Ciclos de Engenharia",
|
||||
tagline: "Capacidade produtiva garantida. Entrega a cada 2 semanas.",
|
||||
description:
|
||||
"Desenvolvimento contínuo com a previsibilidade de orçamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.",
|
||||
deliverables: [
|
||||
"Features em produção a cada ciclo",
|
||||
"Refatoração iterativa com rastreabilidade",
|
||||
"Documentação técnica atualizada",
|
||||
"Capacidade de pivotar prioridades no início 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: "Liderança técnica sênior. 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:
|
||||
"Direção arquitetural, gestão de risco e sustentação de operações críticas. Para empresas que precisam de decisões de alto impacto com responsabilidade contínua.",
|
||||
"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 prioritário 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: "Renovação mensal. Vagas limitadas.",
|
||||
featured: false,
|
||||
ctaLabel: "Agendar conversa estratégica",
|
||||
ctaHref: "#contact",
|
||||
pricing: "Escopo de análise definido",
|
||||
pricingNote: "Profundidade e prazo combinados conforme o sistema.",
|
||||
ctaLabel: "Avaliar meu projeto",
|
||||
ctaHref: "/#contact",
|
||||
},
|
||||
{
|
||||
id: "evolution",
|
||||
badge: "Continuidade",
|
||||
title: "Integração e evolução",
|
||||
tagline: "Faça o que já existe trabalhar melhor.",
|
||||
description:
|
||||
"Conecte ferramentas, reduza tarefas manuais e acrescente funcionalidades ao sistema atual, com atenção às dependências e ao impacto de cada mudança.",
|
||||
deliverables: [
|
||||
"Integração entre sistemas e equipamentos",
|
||||
"Novos fluxos e automações",
|
||||
"Testes das partes afetadas",
|
||||
"Planejamento de manutenção e operação",
|
||||
],
|
||||
pricing: "Etapas acordadas",
|
||||
pricingNote:
|
||||
"Prioridades, suporte e responsabilidades definidos em contrato.",
|
||||
ctaLabel: "Planejar a próxima etapa",
|
||||
ctaHref: "/#contact",
|
||||
},
|
||||
];
|
||||
|
||||
const PROCESS = [
|
||||
{
|
||||
title: "Entender a operação",
|
||||
description:
|
||||
"Quem usa, qual problema precisa resolver e quais dados e ferramentas já existem.",
|
||||
},
|
||||
{
|
||||
title: "Definir a entrega",
|
||||
description:
|
||||
"Escopo, arquitetura, riscos, investimento e critérios de aceite antes de desenvolver.",
|
||||
},
|
||||
{
|
||||
title: "Construir e validar",
|
||||
description:
|
||||
"Entregas por etapa, demonstrações e testes dos fluxos, das permissões e das integrações.",
|
||||
},
|
||||
{
|
||||
title: "Publicar e dar continuidade",
|
||||
description:
|
||||
"Implantação, documentação e condições de manutenção combinadas para a operação.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -112,9 +130,6 @@ 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} />
|
||||
@@ -150,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>
|
||||
@@ -199,23 +214,23 @@ export function WorkModalities() {
|
||||
return (
|
||||
<Section
|
||||
id="modalities"
|
||||
eyebrow="Serviços e contratação"
|
||||
title="Sites, sistemas e aplicativos para o seu negócio."
|
||||
description="Desenvolvimento para web, mobile e desktop Windows e Linux. Da primeira ideia à melhoria de um sistema existente, o trabalho começa pelo que você precisa resolver."
|
||||
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">O que você pode contratar</div>
|
||||
<div className="ds-kicker">Reconhece algum destes cenários?</div>
|
||||
<h3 className="ds-card-title !mt-sp-3" data-reel="left">
|
||||
Uma solução para a sua rotina, não uma lista de tecnologias.
|
||||
Planilhas demais. Sistemas isolados. Trabalho repetido.
|
||||
</h3>
|
||||
<p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
|
||||
Apresentar sua empresa na internet, organizar a operação, criar um
|
||||
aplicativo ou conectar ferramentas. Veja exemplos, etapas e
|
||||
limites de cada serviço; os detalhes técnicos estão disponíveis
|
||||
nas páginas para quem quiser aprofundar.
|
||||
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
|
||||
@@ -238,10 +253,10 @@ export function WorkModalities() {
|
||||
href={service.href}
|
||||
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-base font-semibold leading-snug 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-sm leading-6 text-[var(--ink-80)]">
|
||||
<div className="mt-sp-2 font-display text-sm leading-6 text-[var(--ink-80)]">
|
||||
{service.description}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -262,6 +277,28 @@ export function WorkModalities() {
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div id="process" className="mt-sp-12 scroll-mt-24">
|
||||
<h3 className="ds-card-title">Como o projeto acontece</h3>
|
||||
<p className="ds-copy max-w-[65ch]">
|
||||
Você acompanha as decisões e valida cada etapa. Prazo e investimento
|
||||
dependem do escopo, das integrações e dos riscos identificados.
|
||||
</p>
|
||||
<ol className="mt-sp-6 grid gap-sp-6 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{PROCESS.map((step, index) => (
|
||||
<li
|
||||
key={step.title}
|
||||
className="border-t border-[var(--border)] pt-sp-4"
|
||||
>
|
||||
<span className="ds-kicker">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<h4 className="mt-sp-3 text-base font-semibold">{step.title}</h4>
|
||||
<p className="ds-copy">{step.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { TextDiffuseReveal } from "./TextDiffuseReveal";
|
||||
|
||||
let mockReducedMotion = false;
|
||||
let mockInView = true;
|
||||
let mockAnimations: Array<{ animate?: Record<string, unknown> }> = [];
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
return {
|
||||
motion: {
|
||||
span: ({
|
||||
children,
|
||||
initial: _initial,
|
||||
animate,
|
||||
transition: _transition,
|
||||
onAnimationComplete: _onAnimationComplete,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
initial?: unknown;
|
||||
animate?: Record<string, unknown>;
|
||||
transition?: unknown;
|
||||
onAnimationComplete?: () => void;
|
||||
} & Record<string, unknown>) => {
|
||||
mockAnimations.push({ animate });
|
||||
return React.createElement("span", props, children);
|
||||
},
|
||||
},
|
||||
useInView: () => mockInView,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
};
|
||||
});
|
||||
|
||||
describe("TextDiffuseReveal reduced motion", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
mockInView = true;
|
||||
mockAnimations = [];
|
||||
});
|
||||
|
||||
it("clears blur and scale when reduced motion resolves after mount", () => {
|
||||
const { rerender } = render(<TextDiffuseReveal text="Reveal" />);
|
||||
|
||||
mockAnimations = [];
|
||||
mockReducedMotion = true;
|
||||
rerender(<TextDiffuseReveal text="Reveal" />);
|
||||
|
||||
expect(mockAnimations).toHaveLength(6);
|
||||
expect(mockAnimations).toEqual(
|
||||
expect.arrayContaining(
|
||||
Array.from({ length: 6 }, () =>
|
||||
expect.objectContaining({
|
||||
animate: {
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
scale: 1,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the first render independent of the reduced-motion preference", () => {
|
||||
mockReducedMotion = true;
|
||||
mockInView = false;
|
||||
|
||||
render(<TextDiffuseReveal text="Reveal" />);
|
||||
|
||||
expect(mockAnimations[0]).toEqual({
|
||||
animate: {
|
||||
filter: "blur(12px)",
|
||||
opacity: 0,
|
||||
scale: 0.9,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { TextDiffuseReveal } from "./TextDiffuseReveal";
|
||||
|
||||
let mockInView = false;
|
||||
jest.mock("motion/react", () => ({ useInView: () => mockInView }));
|
||||
|
||||
describe("TextDiffuseReveal", () => {
|
||||
beforeEach(() => {
|
||||
mockInView = false;
|
||||
});
|
||||
|
||||
it("keeps the whole sentence readable once for assistive technology", () => {
|
||||
const { container } = render(
|
||||
<TextDiffuseReveal text="Software feito a partir do seu negócio" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Software feito a partir do seu negócio"),
|
||||
).toHaveClass("sr-only");
|
||||
const words = container.querySelectorAll(".text-reveal-word");
|
||||
expect(words).toHaveLength(7);
|
||||
expect(words[0].closest("[aria-hidden='true']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks the title as shown only after it enters the viewport", () => {
|
||||
const { container, rerender } = render(
|
||||
<TextDiffuseReveal text="Reveal me" />,
|
||||
);
|
||||
expect(container.querySelector(".text-reveal")).not.toHaveAttribute(
|
||||
"data-shown",
|
||||
);
|
||||
|
||||
mockInView = true;
|
||||
rerender(<TextDiffuseReveal text="Reveal me" />);
|
||||
expect(container.querySelector(".text-reveal")).toHaveAttribute(
|
||||
"data-shown",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useInView } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { useInView } from "motion/react";
|
||||
import { type CSSProperties, useRef } from "react";
|
||||
|
||||
interface TextDiffuseRevealProps {
|
||||
text: string;
|
||||
@@ -12,8 +11,12 @@ interface TextDiffuseRevealProps {
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
const EXPO_OUT = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
/**
|
||||
* Título que entra palavra por palavra. A animação é CSS (.text-reveal em globals.css) com transparência
|
||||
* e deslocamento: a placa de vídeo compõe sem redesenhar. A versão anterior animava cada letra com
|
||||
* desfoque e renderizava o React de novo a cada letra, o que pesava justamente durante as transições.
|
||||
* Movimento reduzido: o CSS mostra o texto direto, sem animação.
|
||||
*/
|
||||
export function TextDiffuseReveal({
|
||||
text,
|
||||
className = "",
|
||||
@@ -22,111 +25,35 @@ 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(() => {
|
||||
let offset = 0;
|
||||
return text
|
||||
.split(/(\n|\s+)/)
|
||||
.filter(Boolean)
|
||||
.map((value) => {
|
||||
const key = `${offset}:${value}`;
|
||||
offset += value.length;
|
||||
return { key, value };
|
||||
});
|
||||
}, [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}
|
||||
style={{
|
||||
willChange: hasActiveWillChange
|
||||
? "filter, opacity, transform"
|
||||
: undefined,
|
||||
}}
|
||||
className={`text-reveal ${className}`.trim()}
|
||||
data-shown={isInView ? "" : undefined}
|
||||
style={{ "--d": `${startDelay}s` } as CSSProperties}
|
||||
>
|
||||
<span className="sr-only">{text}</span>
|
||||
<span aria-hidden="true">
|
||||
{tokens.map(({ key, value: token }) => {
|
||||
if (token === "\n") {
|
||||
return <br key={key} />;
|
||||
}
|
||||
|
||||
if (/^\s+$/.test(token)) {
|
||||
return <span key={key}>{token.replace(/ /g, "\u00A0")}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={key} className="inline-block whitespace-nowrap">
|
||||
{Array.from(token).map((char) => {
|
||||
const currentIndex = animatedIndex;
|
||||
animatedIndex += 1;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
key={`${key}-${currentIndex}-${char}`}
|
||||
className="inline-block"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
||||
}
|
||||
animate={
|
||||
isInView
|
||||
? { filter: "blur(0px)", opacity: 1, scale: 1 }
|
||||
: shouldReduceMotion
|
||||
? { filter: "blur(0px)", opacity: 0, scale: 1 }
|
||||
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
||||
}
|
||||
transition={{
|
||||
duration: shouldReduceMotion ? 0.3 : 0.6,
|
||||
ease: EXPO_OUT,
|
||||
delay: startDelay + currentIndex * staggerDelay,
|
||||
}}
|
||||
onAnimationComplete={() => {
|
||||
if (!shouldReduceMotion) {
|
||||
setCompletedChars((count) =>
|
||||
count < animatedChars ? count + 1 : count,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{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,103 +1,13 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { render } from "@testing-library/react";
|
||||
import FilmGrain from "./FilmGrain";
|
||||
import ScrollVelocityText from "./ScrollVelocityText";
|
||||
import SectionDivider from "./SectionDivider";
|
||||
|
||||
function mockReducedMotion(matches: boolean) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("prefers-reduced-motion") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("static cinematic effects", () => {
|
||||
beforeEach(() => mockReducedMotion(false));
|
||||
|
||||
it("renders grain and every divider without a canvas", () => {
|
||||
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();
|
||||
grain.unmount();
|
||||
|
||||
for (const variant of ["gradient", "pulse", "waveform"] as const) {
|
||||
const divider = render(<SectionDivider variant={variant} />);
|
||||
expect(divider.container.firstElementChild).toHaveAttribute(
|
||||
"aria-hidden",
|
||||
"true",
|
||||
);
|
||||
expect(divider.container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
expect(divider.container.firstElementChild).not.toHaveAttribute(
|
||||
"tabindex",
|
||||
);
|
||||
divider.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes a persistent pause control for an automatic marquee", () => {
|
||||
render(<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />);
|
||||
|
||||
const pause = screen.getByRole("button", { name: "Pausar animação" });
|
||||
const track = document.getElementById(
|
||||
pause.getAttribute("aria-controls") ?? "",
|
||||
);
|
||||
expect(pause).toHaveAttribute("aria-pressed", "false");
|
||||
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
|
||||
expect(
|
||||
screen.getByText("SOFTWARE ENGINEERING", {
|
||||
selector: '[aria-hidden="false"]',
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.getAllByText("SOFTWARE ENGINEERING")).toHaveLength(2);
|
||||
expect(
|
||||
screen
|
||||
.getAllByText("SOFTWARE ENGINEERING")
|
||||
.slice(1)
|
||||
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(pause);
|
||||
|
||||
const resume = screen.getByRole("button", { name: "Retomar animação" });
|
||||
expect(resume).toHaveAttribute("aria-pressed", "true");
|
||||
expect(track).toHaveStyle({ animationPlayState: "paused" });
|
||||
|
||||
fireEvent.click(resume);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Pausar animação" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
|
||||
});
|
||||
|
||||
it("starts static under reduced motion without a redundant control", () => {
|
||||
mockReducedMotion(true);
|
||||
render(<ScrollVelocityText text="VAMOS CONVERSAR" direction="right" />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /animação/i }),
|
||||
).not.toBeInTheDocument();
|
||||
const copies = screen.getAllByText("VAMOS CONVERSAR");
|
||||
expect(copies).toHaveLength(2);
|
||||
expect(copies[0]).toBeVisible();
|
||||
expect(copies[0]).toHaveAttribute("aria-hidden", "false");
|
||||
expect(copies[0].closest("[style]")).toHaveStyle({
|
||||
animationPlayState: "paused",
|
||||
});
|
||||
expect(
|
||||
copies
|
||||
.slice(1)
|
||||
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
import {
|
||||
resetHomeArrivalState,
|
||||
useHomeFragmentNavigation,
|
||||
} from "./useHomeFragmentNavigation";
|
||||
|
||||
function HomeNavigation() {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={onLayoutReady}>
|
||||
Establish notebook layout
|
||||
Establish layout
|
||||
</button>
|
||||
<a href="/#about">Sobre</a>
|
||||
<a href="/#contact">Contato</a>
|
||||
@@ -23,11 +26,13 @@ function HomeNavigation() {
|
||||
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;
|
||||
@@ -37,20 +42,59 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("home fragment navigation", () => {
|
||||
it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
|
||||
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 notebook layout" }),
|
||||
);
|
||||
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 notebook layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Read biography" }),
|
||||
).toHaveFocus();
|
||||
@@ -62,18 +106,14 @@ describe("home fragment navigation", () => {
|
||||
const message = screen.getByRole("button", { name: "Send message" });
|
||||
message.focus();
|
||||
fireEvent.wheel(window, { deltaY: 200 });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
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 notebook layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
const link = screen.getByRole("link", { name: "Sobre" });
|
||||
link.focus();
|
||||
expect(fireEvent.click(link)).toBe(true);
|
||||
@@ -95,9 +135,7 @@ describe("home fragment navigation", () => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(message).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type {
|
||||
PortfolioExperience,
|
||||
PortfolioProjectView,
|
||||
@@ -7,73 +8,63 @@ import type {
|
||||
} 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 from "../Hero";
|
||||
import Hero, { type HeroStats } from "../Hero";
|
||||
import LighthouseMetrics from "../LighthouseMetrics";
|
||||
import Projects from "../Projects";
|
||||
import Skills from "../Skills";
|
||||
import NotebookStory from "../story/NotebookStory";
|
||||
import { WorkModalities } from "../WorkModalities";
|
||||
import ScrollVelocityText from "./ScrollVelocityText";
|
||||
import SectionDivider from "./SectionDivider";
|
||||
import ExperienceDisclosure from "./ExperienceDisclosure";
|
||||
import { useCinematicScroll } from "./useCinematicScroll";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||
<main className="ds-main">
|
||||
<Hero />
|
||||
|
||||
{/* Marquee divider after hero */}
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
|
||||
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
|
||||
</div>
|
||||
|
||||
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
|
||||
|
||||
<About />
|
||||
|
||||
<SectionDivider variant="waveform" />
|
||||
|
||||
<Skills categories={skillCategories} />
|
||||
|
||||
<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"
|
||||
direction="right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Experience experiences={experiences} />
|
||||
|
||||
<SectionDivider variant="gradient" />
|
||||
{/* 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} />
|
||||
|
||||
<Projects projects={projects} />
|
||||
|
||||
<LighthouseMetrics />
|
||||
|
||||
<SectionDivider variant="pulse" />
|
||||
|
||||
<WorkModalities />
|
||||
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
|
||||
<ScrollVelocityText text="VAMOS CONVERSAR" direction="left" />
|
||||
<EngineeringWithAI />
|
||||
|
||||
<LighthouseMetrics />
|
||||
|
||||
<div className="bg-[var(--paper)]">
|
||||
<About />
|
||||
|
||||
<ExperienceDisclosure>
|
||||
<Skills categories={skillCategories} />
|
||||
<Experience experiences={experiences} />
|
||||
</ExperienceDisclosure>
|
||||
</div>
|
||||
|
||||
<Contact />
|
||||
</main>
|
||||
<Footer />
|
||||
<div className="relative z-10">
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import createGlobe from "cobe";
|
||||
import { ControlRoomGlobe } from "./ControlRoomGlobe";
|
||||
|
||||
const mockDestroy = jest.fn();
|
||||
|
||||
jest.mock("cobe", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ destroy: mockDestroy })),
|
||||
}));
|
||||
|
||||
jest.mock("motion/react", () => ({
|
||||
useMotionValue: (initial: number) => ({
|
||||
get: () => initial,
|
||||
set: jest.fn(),
|
||||
}),
|
||||
useSpring: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
let intersectionCallback: IntersectionObserverCallback | undefined;
|
||||
let resizeCallback: ResizeObserverCallback | undefined;
|
||||
let measuredWidth = 400;
|
||||
|
||||
function setDocumentHidden(hidden: boolean) {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
value: hidden,
|
||||
});
|
||||
}
|
||||
|
||||
function setGlobeIntersects(isIntersecting: boolean) {
|
||||
const entry = { isIntersecting } as IntersectionObserverEntry;
|
||||
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
|
||||
}
|
||||
|
||||
function renderCallback() {
|
||||
expect(createGlobe).toHaveBeenCalledTimes(1);
|
||||
const call = jest.mocked(createGlobe).mock.calls[0];
|
||||
if (!call) throw new Error("Globe was not initialized");
|
||||
return call[1].onRender;
|
||||
}
|
||||
|
||||
describe("ControlRoomGlobe lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setDocumentHidden(false);
|
||||
intersectionCallback = undefined;
|
||||
resizeCallback = undefined;
|
||||
measuredWidth = 400;
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, "offsetWidth", "get")
|
||||
.mockImplementation(() => measuredWidth);
|
||||
jest
|
||||
.spyOn(HTMLCanvasElement.prototype, "getContext")
|
||||
.mockReturnValue({} as unknown as GPUCanvasContext);
|
||||
|
||||
class MockIntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "";
|
||||
readonly thresholds = [];
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
|
||||
disconnect = jest.fn();
|
||||
observe = jest.fn();
|
||||
takeRecords = () => [];
|
||||
unobserve = jest.fn();
|
||||
}
|
||||
global.IntersectionObserver =
|
||||
MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
|
||||
disconnect = jest.fn();
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
}
|
||||
global.ResizeObserver =
|
||||
MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("pauses for a hidden tab without losing the intersection flag", async () => {
|
||||
render(<ControlRoomGlobe />);
|
||||
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
|
||||
const renderFrame = renderCallback();
|
||||
const state = { phi: 0, theta: 0, width: 0, height: 0 };
|
||||
|
||||
setGlobeIntersects(true);
|
||||
renderFrame(state);
|
||||
expect(state.phi).not.toBe(0);
|
||||
|
||||
setDocumentHidden(true);
|
||||
const pausedPhi = state.phi;
|
||||
renderFrame(state);
|
||||
expect(state.phi).toBe(pausedPhi);
|
||||
|
||||
setDocumentHidden(false);
|
||||
renderFrame(state);
|
||||
expect(state.phi).not.toBe(pausedPhi);
|
||||
|
||||
setGlobeIntersects(false);
|
||||
const offscreenPhi = state.phi;
|
||||
setDocumentHidden(true);
|
||||
setDocumentHidden(false);
|
||||
renderFrame(state);
|
||||
expect(state.phi).toBe(offscreenPhi);
|
||||
});
|
||||
|
||||
it("keeps one Cobe instance while ResizeObserver updates render dimensions", async () => {
|
||||
render(<ControlRoomGlobe />);
|
||||
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
|
||||
|
||||
measuredWidth = 520;
|
||||
const entry = {
|
||||
contentRect: { width: measuredWidth },
|
||||
} as ResizeObserverEntry;
|
||||
act(() => resizeCallback?.([entry], {} as ResizeObserver));
|
||||
setGlobeIntersects(true);
|
||||
const state = { phi: 0, theta: 0, width: 0, height: 0 };
|
||||
renderCallback()(state);
|
||||
|
||||
expect(createGlobe).toHaveBeenCalledTimes(1);
|
||||
expect(state.width).toBe(1040);
|
||||
expect(state.height).toBe(1040);
|
||||
});
|
||||
|
||||
it("keeps the reduced-motion globe static while tracking dimensions", async () => {
|
||||
render(<ControlRoomGlobe autoRotate={false} />);
|
||||
await waitFor(() => expect(createGlobe).toHaveBeenCalled());
|
||||
setGlobeIntersects(true);
|
||||
const state = { phi: 0, theta: 0, width: 0, height: 0 };
|
||||
|
||||
renderCallback()(state);
|
||||
|
||||
expect(state).toEqual({ phi: 0, theta: 0, width: 800, height: 800 });
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import createGlobe, { type COBEOptions } from "cobe";
|
||||
import { useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MOVEMENT_DAMPING = 1400;
|
||||
|
||||
const JOINVILLE_LAT = -26.3044;
|
||||
const JOINVILLE_LNG = -48.8456;
|
||||
// cobe espera radianos: theta = latitude da banda de frente (Trópico de Capricórnio), phi = longitude de frente (Joinville)
|
||||
const TROPIC_OF_CAPRICORN_RAD = (-23.4364 * Math.PI) / 180;
|
||||
const JOINVILLE_LNG_RAD = (JOINVILLE_LNG * Math.PI) / 180;
|
||||
|
||||
const CONTROL_ROOM_GLOBE_CONFIG: COBEOptions = {
|
||||
width: 800,
|
||||
height: 800,
|
||||
onRender: () => {},
|
||||
devicePixelRatio: 2,
|
||||
phi: JOINVILLE_LNG_RAD,
|
||||
theta: TROPIC_OF_CAPRICORN_RAD,
|
||||
dark: 0,
|
||||
diffuse: 1.2,
|
||||
mapSamples: 16000,
|
||||
mapBrightness: 6,
|
||||
baseColor: [0.32, 0.32, 0.32],
|
||||
markerColor: [204 / 255, 255 / 255, 0 / 255],
|
||||
glowColor: [204 / 255, 255 / 255, 0 / 255],
|
||||
markers: [
|
||||
{ location: [JOINVILLE_LAT, JOINVILLE_LNG], size: 0.12 },
|
||||
{ location: [-25.4284, -49.2733], size: 0.06 },
|
||||
{ location: [-26.0167, -48.5], size: 0.05 },
|
||||
{ location: [-25.9333, -48.7833], size: 0.04 },
|
||||
],
|
||||
} as const satisfies COBEOptions;
|
||||
|
||||
export function ControlRoomGlobe({
|
||||
className,
|
||||
config = CONTROL_ROOM_GLOBE_CONFIG,
|
||||
autoRotate = true,
|
||||
rotationSpeed = 0.003,
|
||||
}: {
|
||||
className?: string;
|
||||
config?: COBEOptions;
|
||||
autoRotate?: boolean;
|
||||
rotationSpeed?: number;
|
||||
}) {
|
||||
const widthRef = useRef(0);
|
||||
const phiRef = useRef(config.phi ?? JOINVILLE_LNG_RAD);
|
||||
// Rotação livre: oscilação de theta (inclinação multi-eixo) + arraste vertical
|
||||
const thetaPhaseRef = useRef(0);
|
||||
const thetaDragRef = useRef(0);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointerInteracting = useRef<number | null>(null);
|
||||
const pointerYOrigin = useRef<number | null>(null);
|
||||
const intersectsRef = useRef(false);
|
||||
|
||||
const r = useMotionValue(0);
|
||||
const rs = useSpring(r, {
|
||||
mass: 1,
|
||||
damping: 30,
|
||||
stiffness: 100,
|
||||
});
|
||||
|
||||
const updatePointerInteraction = (value: number | null) => {
|
||||
pointerInteracting.current = value;
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab";
|
||||
}
|
||||
};
|
||||
|
||||
const updateMovement = (clientX: number, clientY?: number) => {
|
||||
if (pointerInteracting.current !== null) {
|
||||
const delta = clientX - pointerInteracting.current;
|
||||
r.set(r.get() + delta / MOVEMENT_DAMPING);
|
||||
// Eixo vertical: arrasto inclina o globo (limitado p/ não virar de cabeça pra baixo)
|
||||
if (clientY !== undefined && pointerYOrigin.current !== null) {
|
||||
const deltaY = clientY - pointerYOrigin.current;
|
||||
thetaDragRef.current = Math.max(
|
||||
-0.9,
|
||||
Math.min(0.9, thetaDragRef.current + deltaY / MOVEMENT_DAMPING),
|
||||
);
|
||||
pointerYOrigin.current = clientY;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
|
||||
const dpr = config.devicePixelRatio;
|
||||
const updateWidth = (entry?: ResizeObserverEntry) => {
|
||||
const nextWidth =
|
||||
entry?.contentRect.width || canvas.offsetWidth || config.width / dpr;
|
||||
if (nextWidth > 0) widthRef.current = nextWidth;
|
||||
};
|
||||
updateWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(([entry]) => updateWidth(entry));
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
intersectsRef.current = entry.isIntersecting;
|
||||
},
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
intersectionObserver.observe(canvas);
|
||||
|
||||
// Falha suave: sem WebGL (ou canvas já vinculado a outro tipo de contexto) o phenomenon
|
||||
// lança "reading 'enable' of null" e derrubaria a árvore React inteira — card ficaria preto.
|
||||
let globe: { destroy(): void } | null = null;
|
||||
try {
|
||||
const supportsWebGL =
|
||||
canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||
if (supportsWebGL) {
|
||||
const initialRenderWidth = widthRef.current * dpr;
|
||||
globe = createGlobe(canvas, {
|
||||
...config,
|
||||
width: initialRenderWidth,
|
||||
height: initialRenderWidth,
|
||||
onRender: (state) => {
|
||||
const renderWidth = widthRef.current * dpr;
|
||||
state.width = renderWidth;
|
||||
state.height = renderWidth;
|
||||
if (!autoRotate || !intersectsRef.current || document.hidden)
|
||||
return;
|
||||
if (!pointerInteracting.current) {
|
||||
phiRef.current += rotationSpeed;
|
||||
thetaPhaseRef.current += rotationSpeed * 0.9;
|
||||
}
|
||||
state.phi = phiRef.current + rs.get();
|
||||
// Rotação livre em todos os eixos: spin (phi) + guinada senoidal (theta) + inclinação por arraste vertical
|
||||
state.theta =
|
||||
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
|
||||
Math.sin(thetaPhaseRef.current) * 0.35 +
|
||||
thetaDragRef.current;
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
globe = null;
|
||||
}
|
||||
|
||||
if (!globe) {
|
||||
// Sem globo: fallback visível (gradiente do card permanece), página viva.
|
||||
canvas.style.opacity = "1";
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
const revealTimer = window.setTimeout(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.opacity = "1";
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(revealTimer);
|
||||
globe.destroy();
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}, [rs, config, autoRotate, rotationSpeed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto aspect-[1/1] w-full max-w-[520px]",
|
||||
className,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<canvas
|
||||
className={cn(
|
||||
"size-full opacity-0 transition-opacity duration-700 [contain:layout_paint_size]",
|
||||
)}
|
||||
ref={canvasRef}
|
||||
onPointerDown={(e) => {
|
||||
pointerInteracting.current = e.clientX;
|
||||
pointerYOrigin.current = e.clientY;
|
||||
updatePointerInteraction(e.clientX);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
updatePointerInteraction(null);
|
||||
pointerYOrigin.current = null;
|
||||
}}
|
||||
onPointerOut={() => updatePointerInteraction(null)}
|
||||
onPointerLeave={() => {
|
||||
updatePointerInteraction(null);
|
||||
pointerYOrigin.current = null;
|
||||
}}
|
||||
onMouseMove={(e) => updateMovement(e.clientX, e.clientY)}
|
||||
onTouchMove={(e) =>
|
||||
e.touches[0] &&
|
||||
updateMovement(e.touches[0].clientX, e.touches[0].clientY)
|
||||
}
|
||||
/>
|
||||
<span className="sr-only">
|
||||
Globo interativo centrado em Joinville, Santa Catarina, com marcadores
|
||||
indicando presença operacional na região Norte de SC e Curitiba.
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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,404 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const MAX_DPR = 1.5;
|
||||
const ASSEMBLE_MS = 2000;
|
||||
const REPEL_RADIUS_PX = 80;
|
||||
// Densidade: ~1 star por STAR_AREA_DIVISOR px² CSS, entre piso e teto absolutos
|
||||
const STAR_AREA_DIVISOR = 700;
|
||||
const MIN_STARS = 300;
|
||||
const MAX_STARS = 850;
|
||||
const OFFSCREEN_POINTER = -100000;
|
||||
|
||||
type Particle = {
|
||||
sx: number; // origem da assemblei (espalhada) no primeiro load
|
||||
sy: number;
|
||||
tx: number; // âncora na grade com jitter
|
||||
ty: number;
|
||||
ox: number; // deslocamento acumulado do repel do ponteiro
|
||||
oy: number;
|
||||
delay: number;
|
||||
fillStyle: string;
|
||||
baseAlpha: number;
|
||||
size: number;
|
||||
phase: number;
|
||||
speed: number;
|
||||
amp: number;
|
||||
ttl: number;
|
||||
birth: number;
|
||||
};
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
const FALLBACK_SLASH: Rgb = [204, 255, 0];
|
||||
const FALLBACK_PAPER: Rgb = [244, 242, 237];
|
||||
|
||||
function hexToRgb(hex: string): Rgb | null {
|
||||
const value = hex.trim().replace("#", "");
|
||||
const full =
|
||||
value.length === 3
|
||||
? value
|
||||
.split("")
|
||||
.map((char) => char + char)
|
||||
.join("")
|
||||
: value;
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
|
||||
return [
|
||||
Number.parseInt(full.slice(0, 2), 16),
|
||||
Number.parseInt(full.slice(2, 4), 16),
|
||||
Number.parseInt(full.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function easeOutQuint(progress: number) {
|
||||
return 1 - (1 - progress) ** 5;
|
||||
}
|
||||
|
||||
export function HeroStarfield() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
let rafId = 0;
|
||||
let running = false;
|
||||
let cancelled = false;
|
||||
let resampleTimer = 0;
|
||||
let sampleRetryTimer = 0;
|
||||
let sampleAttempts = 0;
|
||||
let particles: Particle[] = [];
|
||||
let hasAssembled = false;
|
||||
let elapsed = 0;
|
||||
let lastTime = 0;
|
||||
let dpr = 1;
|
||||
let intersecting = true;
|
||||
const pointer = { x: OFFSCREEN_POINTER, y: OFFSCREEN_POINTER };
|
||||
let rect = container.getBoundingClientRect();
|
||||
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const slashRgb =
|
||||
hexToRgb(rootStyles.getPropertyValue("--slash")) ?? FALLBACK_SLASH;
|
||||
const paperRgb =
|
||||
hexToRgb(rootStyles.getPropertyValue("--paper")) ?? FALLBACK_PAPER;
|
||||
|
||||
// Grade estratificada: uma star por célula com jitter interno → cobertura
|
||||
// uniforme do card, sem os aglomerados/vazios do random puro
|
||||
const grid = {
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
cellW: canvas.width || 1,
|
||||
cellH: canvas.height || 1,
|
||||
};
|
||||
|
||||
const jitteredCellPosition = (cell: number): [number, number] => {
|
||||
const cx = cell % grid.cols;
|
||||
const cy = Math.floor(cell / grid.cols);
|
||||
return [
|
||||
(cx + Math.random()) * grid.cellW,
|
||||
(cy + Math.random()) * grid.cellH,
|
||||
];
|
||||
};
|
||||
|
||||
const makeParticle = (tx: number, ty: number): Particle => {
|
||||
const useSlash = Math.random() < 0.45;
|
||||
const rgb = useSlash ? slashRgb : paperRgb;
|
||||
return {
|
||||
tx,
|
||||
ty,
|
||||
sx: hasAssembled ? tx : (Math.random() * 1.3 - 0.15) * canvas.width,
|
||||
sy: hasAssembled ? ty : (Math.random() * 1.3 - 0.15) * canvas.height,
|
||||
ox: 0,
|
||||
oy: 0,
|
||||
delay: Math.random() * 0.35,
|
||||
fillStyle: `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`,
|
||||
baseAlpha: 0.5 + Math.random() * 0.45,
|
||||
size: (Math.random() < 0.72 ? 2 : 3) * dpr,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
// deriva lenta: período ~2–5s com amplitude 2,5–5,5px CSS → "levemente"
|
||||
speed: 0.0011 + Math.random() * 0.0016,
|
||||
amp: (2.5 + Math.random() * 3) * dpr,
|
||||
ttl: 4000 + Math.random() * 6000,
|
||||
// birth negativo: estagia os ciclos de vida para o spawn/death já rodar disperso no load
|
||||
birth: -Math.random() * 10000,
|
||||
};
|
||||
};
|
||||
|
||||
const sample = () => {
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
if (width < 60 || height < 60) return;
|
||||
|
||||
dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||
canvas.width = Math.round(width * dpr);
|
||||
canvas.height = Math.round(height * dpr);
|
||||
rect = container.getBoundingClientRect();
|
||||
|
||||
const count = Math.round(
|
||||
Math.min(
|
||||
MAX_STARS,
|
||||
Math.max(MIN_STARS, (width * height) / STAR_AREA_DIVISOR),
|
||||
),
|
||||
);
|
||||
// células quadradas-ish para a proporção do card
|
||||
grid.cols = Math.max(
|
||||
1,
|
||||
Math.round(Math.sqrt(count * (canvas.width / canvas.height))),
|
||||
);
|
||||
grid.rows = Math.max(1, Math.ceil(count / grid.cols));
|
||||
grid.cellW = canvas.width / grid.cols;
|
||||
grid.cellH = canvas.height / grid.rows;
|
||||
|
||||
// ordem embaralhada das células: cada star ocupa uma célula distinta no spawn
|
||||
const cells = Array.from({ length: grid.cols * grid.rows }, (_, i) => i);
|
||||
for (let i = cells.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const tmp = cells[i];
|
||||
cells[i] = cells[j];
|
||||
cells[j] = tmp;
|
||||
}
|
||||
particles = cells.slice(0, count).map((cell) => {
|
||||
const [tx, ty] = jitteredCellPosition(cell);
|
||||
return makeParticle(tx, ty);
|
||||
});
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const pointerX = (pointer.x - rect.left) * dpr;
|
||||
const pointerY = (pointer.y - rect.top) * dpr;
|
||||
const radius = REPEL_RADIUS_PX * dpr;
|
||||
const radiusSq = radius * radius;
|
||||
let allSettled = true;
|
||||
|
||||
for (const particle of particles) {
|
||||
const progress = Math.min(
|
||||
Math.max(
|
||||
(elapsed - particle.delay * ASSEMBLE_MS) / (ASSEMBLE_MS * 0.75),
|
||||
0,
|
||||
),
|
||||
1,
|
||||
);
|
||||
if (progress < 1) allSettled = false;
|
||||
const eased = easeOutQuint(progress);
|
||||
const baseX = particle.sx + (particle.tx - particle.sx) * eased;
|
||||
const baseY = particle.sy + (particle.ty - particle.sy) * eased;
|
||||
|
||||
// wander Lissajous: harmônico principal + secundário em razão
|
||||
// não-inteira → trajetória contínua sem repetição óbvia
|
||||
const t = elapsed * particle.speed;
|
||||
const driftX =
|
||||
(Math.sin(t + particle.phase) +
|
||||
0.5 * Math.sin(t * 0.43 + particle.phase * 1.7)) *
|
||||
particle.amp *
|
||||
progress;
|
||||
const driftY =
|
||||
(Math.cos(t * 0.9 + particle.phase) +
|
||||
0.5 * Math.cos(t * 0.51 + particle.phase * 2.3)) *
|
||||
particle.amp *
|
||||
progress;
|
||||
|
||||
const dx = baseX + driftX - pointerX;
|
||||
const dy = baseY + driftY - pointerY;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
if (distSq < radiusSq) {
|
||||
const dist = Math.sqrt(distSq) || 1;
|
||||
const force = (1 - dist / radius) * 10 * dpr;
|
||||
particle.ox += (dx / dist) * force;
|
||||
particle.oy += (dy / dist) * force;
|
||||
}
|
||||
particle.ox *= 0.86;
|
||||
particle.oy *= 0.86;
|
||||
|
||||
const fadeIn = Math.min(elapsed / 1200, 1);
|
||||
const twinkle = 0.6 + 0.4 * Math.sin(elapsed * 0.003 + particle.phase);
|
||||
let life = elapsed - particle.birth;
|
||||
if (life > particle.ttl) {
|
||||
// renasce em outra célula da grade → uniforme também entre ciclos
|
||||
const [tx, ty] = jitteredCellPosition(
|
||||
Math.floor(Math.random() * grid.cols * grid.rows),
|
||||
);
|
||||
particle.tx = tx;
|
||||
particle.ty = ty;
|
||||
particle.sx = tx;
|
||||
particle.sy = ty;
|
||||
particle.phase = Math.random() * Math.PI * 2;
|
||||
particle.baseAlpha = 0.5 + Math.random() * 0.45;
|
||||
particle.size = (Math.random() < 0.72 ? 2 : 3) * dpr;
|
||||
particle.ttl = 4000 + Math.random() * 6000;
|
||||
particle.birth = elapsed;
|
||||
life = 0;
|
||||
}
|
||||
// fade-in de 800ms ao nascer, fade-out nos últimos 800ms de vida
|
||||
const lifeFade = Math.min(life / 800, (particle.ttl - life) / 800, 1);
|
||||
|
||||
ctx.globalAlpha = Math.min(
|
||||
1,
|
||||
particle.baseAlpha * fadeIn * twinkle * lifeFade,
|
||||
);
|
||||
ctx.fillStyle = particle.fillStyle;
|
||||
ctx.fillRect(
|
||||
baseX + driftX + particle.ox,
|
||||
baseY + driftY + particle.oy,
|
||||
particle.size,
|
||||
particle.size,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (allSettled && particles.length > 0) hasAssembled = true;
|
||||
};
|
||||
|
||||
const drawStaticFrame = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
for (const particle of particles) {
|
||||
ctx.fillStyle = particle.fillStyle;
|
||||
ctx.globalAlpha = particle.baseAlpha;
|
||||
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
};
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (!running) return;
|
||||
elapsed += Math.min(now - lastTime, 50);
|
||||
lastTime = now;
|
||||
render();
|
||||
rafId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const syncLoop = () => {
|
||||
const shouldRun = !reducedMotion && intersecting && !document.hidden;
|
||||
if (shouldRun && !running) {
|
||||
running = true;
|
||||
lastTime = performance.now();
|
||||
rafId = window.requestAnimationFrame(tick);
|
||||
} else if (!shouldRun && running) {
|
||||
running = false;
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
|
||||
// Warm-up determinístico: corrida de layout pode deixar o container com
|
||||
// 0px no primeiro sample; re-tenta até existirem partículas
|
||||
const sampleUntilBuilt = (onBuilt?: () => void) => {
|
||||
sample();
|
||||
if (particles.length > 0 || cancelled) {
|
||||
onBuilt?.();
|
||||
return;
|
||||
}
|
||||
if (sampleAttempts++ > 50) return;
|
||||
sampleRetryTimer = window.setTimeout(
|
||||
() => sampleUntilBuilt(onBuilt),
|
||||
120,
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleResample = () => {
|
||||
window.clearTimeout(resampleTimer);
|
||||
resampleTimer = window.setTimeout(() => {
|
||||
sampleUntilBuilt(() => {
|
||||
if (reducedMotion) drawStaticFrame();
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
pointer.x = event.clientX;
|
||||
pointer.y = event.clientY;
|
||||
};
|
||||
const handlePointerLeave = () => {
|
||||
pointer.x = OFFSCREEN_POINTER;
|
||||
pointer.y = OFFSCREEN_POINTER;
|
||||
};
|
||||
const handleScroll = () => {
|
||||
rect = container.getBoundingClientRect();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
syncLoop();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleResample);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
if (reducedMotion) {
|
||||
// Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro
|
||||
hasAssembled = true;
|
||||
sampleUntilBuilt(() => drawStaticFrame());
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(resampleTimer);
|
||||
window.clearTimeout(sampleRetryTimer);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
// Start determinístico: intersecting derivado de geometry no setup (o callback do IO
|
||||
// pode chegar tarde e deixar o loop rAF morto na carga)
|
||||
const syncIntersecting = () => {
|
||||
const current = container.getBoundingClientRect();
|
||||
intersecting =
|
||||
current.bottom > 0 &&
|
||||
current.right > 0 &&
|
||||
current.top < window.innerHeight &&
|
||||
current.left < window.innerWidth;
|
||||
};
|
||||
syncIntersecting();
|
||||
sampleUntilBuilt(() => syncLoop());
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||
intersecting = entries.some((entry) => entry.isIntersecting);
|
||||
syncLoop();
|
||||
});
|
||||
intersectionObserver.observe(container);
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove, {
|
||||
passive: true,
|
||||
});
|
||||
document.documentElement.addEventListener(
|
||||
"pointerleave",
|
||||
handlePointerLeave,
|
||||
);
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.clearTimeout(resampleTimer);
|
||||
window.clearTimeout(sampleRetryTimer);
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
document.documentElement.removeEventListener(
|
||||
"pointerleave",
|
||||
handlePointerLeave,
|
||||
);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
>
|
||||
<canvas ref={canvasRef} className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
interface ScrollVelocityTextProps {
|
||||
text: string;
|
||||
direction: "left" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GROUPS = [0, 1] as const;
|
||||
|
||||
export default function ScrollVelocityText({
|
||||
text,
|
||||
direction,
|
||||
className = "",
|
||||
}: ScrollVelocityTextProps) {
|
||||
const trackId = useId();
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setShouldReduceMotion(media.matches);
|
||||
const updatePreference = (event: MediaQueryListEvent) => {
|
||||
setShouldReduceMotion(event.matches);
|
||||
};
|
||||
media.addEventListener("change", updatePreference);
|
||||
return () => media.removeEventListener("change", updatePreference);
|
||||
}, []);
|
||||
|
||||
const animationPaused = shouldReduceMotion || isPaused;
|
||||
|
||||
return (
|
||||
<div className={`scroll-velocity-text ${className}`.trim()}>
|
||||
<div className="scroll-velocity-viewport">
|
||||
<div
|
||||
id={trackId}
|
||||
className={`scroll-velocity-track ${
|
||||
direction === "left" ? "animate-marquee" : "animate-marquee-reverse"
|
||||
}`}
|
||||
style={{
|
||||
animationPlayState: animationPaused ? "paused" : undefined,
|
||||
}}
|
||||
>
|
||||
{GROUPS.map((group) => (
|
||||
<span
|
||||
key={group}
|
||||
className="scroll-velocity-group"
|
||||
aria-hidden={group === 0 ? undefined : "true"}
|
||||
>
|
||||
<span className="scroll-velocity-item">
|
||||
<span aria-hidden={group === 0 ? "false" : "true"}>{text}</span>
|
||||
<span aria-hidden="true" className="scroll-velocity-dot">
|
||||
•
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!shouldReduceMotion ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-velocity-control"
|
||||
aria-controls={trackId}
|
||||
aria-pressed={isPaused}
|
||||
onClick={() => setIsPaused((paused) => !paused)}
|
||||
>
|
||||
{isPaused ? "Retomar animação" : "Pausar animação"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
interface SectionDividerProps {
|
||||
variant?: "gradient" | "pulse" | "waveform";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PULSE_DOTS = ["dot-1", "dot-2", "dot-3", "dot-4", "dot-5"] as const;
|
||||
|
||||
export default function SectionDivider({
|
||||
variant = "gradient",
|
||||
className = "",
|
||||
}: SectionDividerProps) {
|
||||
const rootClassName =
|
||||
`section-divider section-divider-${variant} ${className}`.trim();
|
||||
|
||||
if (variant === "gradient") {
|
||||
return (
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "pulse") {
|
||||
return (
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
{PULSE_DOTS.map((dot) => (
|
||||
<span key={dot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
<svg viewBox="0 0 1200 60" preserveAspectRatio="none" focusable="false">
|
||||
<title>Divisor decorativo</title>
|
||||
<path d="M0 30 C100 8 180 52 280 30 S460 8 560 30 740 52 840 30 1020 8 1200 30" />
|
||||
<path d="M0 30 C120 48 220 12 340 30 S560 48 680 30 900 12 1020 30 1140 48 1200 30" />
|
||||
<path d="M0 30 C80 20 160 40 240 30 S400 20 480 30 640 40 720 30 880 20 960 30 1120 40 1200 30" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useMotionValue, useSpring, useTransform } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TelemetryMetric {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
precision?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const METRICS_CONFIG: TelemetryMetric[] = [
|
||||
{
|
||||
label: "UPTIME",
|
||||
value: 99.97,
|
||||
unit: "%",
|
||||
precision: 2,
|
||||
min: 99.9,
|
||||
max: 100,
|
||||
},
|
||||
{
|
||||
label: "INTEGRAÇÕES",
|
||||
value: 12,
|
||||
unit: " ativas",
|
||||
precision: 0,
|
||||
min: 8,
|
||||
max: 16,
|
||||
},
|
||||
{
|
||||
label: "BUILDS",
|
||||
value: 247,
|
||||
unit: " /dia",
|
||||
precision: 0,
|
||||
min: 200,
|
||||
max: 300,
|
||||
},
|
||||
{ label: "LATÊNCIA", value: 12, unit: " ms", precision: 0, min: 8, max: 28 },
|
||||
];
|
||||
|
||||
const UPDATE_INTERVAL = 3000;
|
||||
|
||||
const springConfigs = [
|
||||
{ stiffness: 300, damping: 30, mass: 0.8 },
|
||||
{ stiffness: 200, damping: 25, mass: 1 },
|
||||
{ stiffness: 150, damping: 20, mass: 1.2 },
|
||||
{ stiffness: 150, damping: 20, mass: 1.2 },
|
||||
];
|
||||
|
||||
export function TelemetryStrip({
|
||||
className,
|
||||
reducedMotion = false,
|
||||
}: {
|
||||
className?: string;
|
||||
reducedMotion?: boolean;
|
||||
}) {
|
||||
const [_metrics, setMetrics] = useState<TelemetryMetric[]>(METRICS_CONFIG);
|
||||
const pointerX = useMotionValue(0);
|
||||
const pointerY = useMotionValue(0);
|
||||
const isHovering = useRef(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const metricValue0 = useSpring(
|
||||
useMotionValue(METRICS_CONFIG[0].value),
|
||||
springConfigs[0],
|
||||
);
|
||||
const metricValue1 = useSpring(
|
||||
useMotionValue(METRICS_CONFIG[1].value),
|
||||
springConfigs[1],
|
||||
);
|
||||
const metricValue2 = useSpring(
|
||||
useMotionValue(METRICS_CONFIG[2].value),
|
||||
springConfigs[2],
|
||||
);
|
||||
const metricValue3 = useSpring(
|
||||
useMotionValue(METRICS_CONFIG[3].value),
|
||||
springConfigs[3],
|
||||
);
|
||||
const metricValues = [metricValue0, metricValue1, metricValue2, metricValue3];
|
||||
|
||||
// Parallax sutil em px, sinal alternado por métrica — assinatura "reage ao ponteiro"
|
||||
const pointerShiftX = useTransform(pointerX, [-1, 1], [-8, 8]);
|
||||
const pointerShiftY = useTransform(pointerY, [-1, 1], [-5, 5]);
|
||||
const pointerShiftXNeg = useTransform(pointerShiftX, (v) => -v);
|
||||
const pointerShiftYNeg = useTransform(pointerShiftY, (v) => -v);
|
||||
|
||||
const generateVariation = useRef((metric: TelemetryMetric): number => {
|
||||
const range =
|
||||
(metric.max ?? metric.value * 1.1) - (metric.min ?? metric.value * 0.9);
|
||||
const variation = (Math.random() - 0.5) * range * 0.15;
|
||||
const newValue = metric.value + variation;
|
||||
return Math.max(
|
||||
metric.min ?? -Infinity,
|
||||
Math.min(metric.max ?? Infinity, newValue),
|
||||
);
|
||||
});
|
||||
|
||||
const updateMetrics = useRef(() => {
|
||||
setMetrics((prev) =>
|
||||
prev.map((metric) => ({
|
||||
...metric,
|
||||
value: generateVariation.current(metric),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion) return;
|
||||
|
||||
intervalRef.current = setInterval(updateMetrics.current, UPDATE_INTERVAL);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [reducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (e: MouseEvent) => {
|
||||
const x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
const y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
pointerX.set(x);
|
||||
pointerY.set(y);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
return () => window.removeEventListener("pointermove", handlePointerMove);
|
||||
}, [pointerX, pointerY]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"cinematic-glass border-[rgba(204,255,0,0.18)] bg-[rgba(10,10,10,0.82)] backdrop-blur-[24px] rounded-[var(--r-lg)] p-sp-4 lg:p-sp-5 grid grid-cols-2 gap-sp-4 lg:gap-sp-6",
|
||||
className,
|
||||
)}
|
||||
aria-label="Telemetria operacional em tempo real"
|
||||
onPointerEnter={() => {
|
||||
isHovering.current = true;
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
isHovering.current = false;
|
||||
}}
|
||||
>
|
||||
{METRICS_CONFIG.map((metric, index) => (
|
||||
<motion.div
|
||||
key={metric.label}
|
||||
className="relative flex flex-col items-start gap-1"
|
||||
style={
|
||||
reducedMotion
|
||||
? undefined
|
||||
: {
|
||||
x: index % 2 === 0 ? pointerShiftX : pointerShiftXNeg,
|
||||
y: index % 2 === 0 ? pointerShiftY : pointerShiftYNeg,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.7)]">
|
||||
{metric.label}
|
||||
</div>
|
||||
<div
|
||||
className="whitespace-nowrap font-display font-extrabold text-[clamp(1.35rem,2.6vw,1.875rem)] leading-none text-[var(--paper)] tabular-nums"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{reducedMotion
|
||||
? metric.value.toFixed(metric.precision ?? 0)
|
||||
: metricValues[index].get().toFixed(metric.precision ?? 0)}
|
||||
<span className="font-mono text-[11px] font-normal text-[rgba(244,242,237,.58)]">
|
||||
{metric.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-px w-full bg-[linear-gradient(90deg,var(--slash),transparent)] opacity-30"
|
||||
style={{
|
||||
transformOrigin: "left center",
|
||||
transform: reducedMotion
|
||||
? "scaleX(1)"
|
||||
: `scaleX(${Math.max(0.15, Math.min(1, metric.value / (metric.max ?? 100)))})`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
<span className="sr-only">
|
||||
Painel de telemetria operacional: uptime 99,97 por cento, doze
|
||||
integrações ativas, duzentos e quarenta e sete builds por dia, latência
|
||||
doze milissegundos. Valores decorativos simulando uma sala de controle
|
||||
real.
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import { type RefObject, useEffect } from "react";
|
||||
|
||||
type Transition = (
|
||||
tl: gsap.core.Timeline,
|
||||
prev: HTMLElement,
|
||||
next: HTMLElement,
|
||||
touch: boolean,
|
||||
) => void;
|
||||
|
||||
/*
|
||||
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
|
||||
* da rolagem (rolar para cima volta o "vídeo").
|
||||
*
|
||||
* Quem segura as seções paradas durante uma passagem:
|
||||
* - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim
|
||||
* dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem;
|
||||
* - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis
|
||||
* rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento
|
||||
* chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o
|
||||
* dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala).
|
||||
*
|
||||
* Regras que mantêm tudo estável:
|
||||
* - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
|
||||
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
|
||||
* - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não
|
||||
* aparecer atrás de transparências e para o navegador não pintar o que está coberto.
|
||||
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
|
||||
*/
|
||||
|
||||
const vh = () => window.innerHeight;
|
||||
// origem no meio da parte visível da seção que sai (a última altura de tela dela)
|
||||
const visibleCenter = (el: HTMLElement) => () =>
|
||||
`50% ${el.offsetHeight - vh() / 2}px`;
|
||||
|
||||
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
|
||||
function pageTop(el: HTMLElement) {
|
||||
let top = 0;
|
||||
for (
|
||||
let node: HTMLElement | null = el;
|
||||
node;
|
||||
node = node.offsetParent as HTMLElement | null
|
||||
)
|
||||
top += node.offsetTop;
|
||||
return top;
|
||||
}
|
||||
|
||||
// a seção que entra fica parada no topo (só desktop, ver o comentário acima)
|
||||
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
|
||||
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
|
||||
|
||||
// uma linguagem diferente por passagem, na ordem das seções da home
|
||||
const TRANSITIONS: Transition[] = [
|
||||
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
|
||||
(tl, prev, next, touch) => {
|
||||
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0);
|
||||
if (touch) {
|
||||
tl.fromTo(
|
||||
next,
|
||||
{
|
||||
opacity: 0.3,
|
||||
clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)",
|
||||
},
|
||||
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
|
||||
0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
holdNext(tl, next);
|
||||
const band = (open: number) => () =>
|
||||
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
|
||||
tl.fromTo(
|
||||
next,
|
||||
{ opacity: 0, clipPath: band(0.36) },
|
||||
{ opacity: 1, clipPath: band(1), duration: 0.65 },
|
||||
0.35,
|
||||
);
|
||||
},
|
||||
// projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera
|
||||
(tl, prev, next, touch) => {
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
{ scale: 1.18, opacity: 0.2 },
|
||||
0,
|
||||
);
|
||||
// no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo
|
||||
const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`);
|
||||
const radius = () =>
|
||||
touch
|
||||
? Math.hypot(window.innerWidth / 2, vh())
|
||||
: Math.hypot(window.innerWidth / 2, vh() / 2);
|
||||
if (!touch) holdNext(tl, next);
|
||||
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
|
||||
tl.fromTo(
|
||||
next,
|
||||
{ clipPath: () => `circle(0px ${center()})` },
|
||||
{
|
||||
clipPath: () => `circle(${radius()}px ${center()})`,
|
||||
ease: "power1.in",
|
||||
},
|
||||
0,
|
||||
);
|
||||
},
|
||||
// modalidades → IA: corte lateral, uma tela empurra a outra
|
||||
(tl, prev, next, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ xPercent: 0, opacity: 1 },
|
||||
{ xPercent: -35, opacity: 0.3 },
|
||||
0,
|
||||
).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0);
|
||||
},
|
||||
// IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece.
|
||||
// Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o
|
||||
// desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha
|
||||
// uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter).
|
||||
(tl, prev, next, touch) => {
|
||||
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0);
|
||||
// no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo
|
||||
if (!touch)
|
||||
tl.fromTo(
|
||||
next,
|
||||
{ y: () => vh() * 0.35 },
|
||||
{ y: 0, ease: "power2.out" },
|
||||
0,
|
||||
);
|
||||
},
|
||||
// qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform)
|
||||
(tl, prev, next, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ opacity: 1, xPercent: 0 },
|
||||
{ opacity: 0.25, xPercent: 20 },
|
||||
0,
|
||||
).fromTo(
|
||||
next,
|
||||
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
|
||||
{ xPercent: 0, rotation: 0, ease: "power2.out" },
|
||||
0,
|
||||
);
|
||||
},
|
||||
// sobre → contato: o contato cresce do centro, como um zoom de câmera
|
||||
(tl, prev, next, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
{ scale: 0.92, opacity: 0.2 },
|
||||
0,
|
||||
)
|
||||
.fromTo(
|
||||
next,
|
||||
{
|
||||
scale: touch ? 0.8 : 0.55,
|
||||
clipPath: "inset(0% round 56px)",
|
||||
transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`),
|
||||
},
|
||||
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
|
||||
0,
|
||||
)
|
||||
.fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0);
|
||||
},
|
||||
];
|
||||
|
||||
const easeInOutCubic = (t: number) =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
|
||||
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const main = mainRef.current;
|
||||
if (!main) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
let cancelled = false;
|
||||
let cleanup = () => {};
|
||||
|
||||
(async () => {
|
||||
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] =
|
||||
await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
import("lenis"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
|
||||
lenis.on("scroll", ScrollTrigger.update);
|
||||
const tick = (time: number) => lenis.raf(time * 1000);
|
||||
gsap.ticker.add(tick);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
// toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript
|
||||
const touch = window.matchMedia(
|
||||
"(hover: none) and (pointer: coarse)",
|
||||
).matches;
|
||||
const scenes = Array.from(main.children) as HTMLElement[];
|
||||
// Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o
|
||||
// offsetTop de uma cena presa passa a incluir o deslocamento dela.
|
||||
const sceneTops = () => {
|
||||
let top = pageTop(main);
|
||||
return scenes.map((scene) => {
|
||||
const current = top;
|
||||
top += scene.offsetHeight;
|
||||
return current;
|
||||
});
|
||||
};
|
||||
const stick = () => {
|
||||
for (const scene of scenes)
|
||||
scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`;
|
||||
};
|
||||
|
||||
const context = gsap.context(() => {
|
||||
scenes.forEach((scene, index) => {
|
||||
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
|
||||
gsap.set(scene, {
|
||||
position: "sticky",
|
||||
zIndex: index === 0 ? 30 : index + 1,
|
||||
});
|
||||
});
|
||||
stick();
|
||||
TRANSITIONS.forEach((transition, index) => {
|
||||
const prev = scenes[index];
|
||||
const next = scenes[index + 1];
|
||||
if (!prev || !next) return;
|
||||
const tl = gsap.timeline({
|
||||
defaults: { ease: "none", duration: 1 },
|
||||
scrollTrigger: {
|
||||
trigger: next,
|
||||
start: () => sceneTops()[index + 1] - vh(),
|
||||
end: () => sceneTops()[index + 1],
|
||||
scrub: true,
|
||||
invalidateOnRefresh: true,
|
||||
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
|
||||
onLeave: () => gsap.set(next, { clipPath: "none" }),
|
||||
// diagrama de qualidade: desenha uma vez quando a seção começa a entrar
|
||||
onEnter: () => next.classList.add("is-drawing"),
|
||||
},
|
||||
});
|
||||
transition(tl, prev, next, touch);
|
||||
});
|
||||
}, main);
|
||||
|
||||
// Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a
|
||||
// passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto
|
||||
// de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no
|
||||
// desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa).
|
||||
let frame = 0;
|
||||
const syncVisibility = () => {
|
||||
frame = 0;
|
||||
const y = window.scrollY;
|
||||
const tops = sceneTops();
|
||||
scenes.forEach((scene, index) => {
|
||||
const from =
|
||||
index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1);
|
||||
const to =
|
||||
index === scenes.length - 1
|
||||
? Infinity
|
||||
: tops[index + 1] + vh() * 0.25;
|
||||
const visibility = y >= from && y <= to ? "" : "hidden";
|
||||
if (scene.style.visibility !== visibility)
|
||||
scene.style.visibility = visibility;
|
||||
});
|
||||
};
|
||||
const queueSync = () => {
|
||||
if (!frame) frame = requestAnimationFrame(syncVisibility);
|
||||
};
|
||||
const onRefresh = () => {
|
||||
stick();
|
||||
queueSync();
|
||||
};
|
||||
syncVisibility();
|
||||
ScrollTrigger.addEventListener("refresh", onRefresh);
|
||||
window.addEventListener("scroll", queueSync, { passive: true });
|
||||
// a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150);
|
||||
});
|
||||
resizeObserver.observe(main);
|
||||
|
||||
// botões e links internos: em vez do salto, a página "avança o vídeo" até o destino
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const link =
|
||||
event.target instanceof Element
|
||||
? event.target.closest("a[href]")
|
||||
: null;
|
||||
if (
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
(link.target && link.target !== "_self")
|
||||
)
|
||||
return;
|
||||
const url = new URL(link.href);
|
||||
if (
|
||||
url.origin !== location.origin ||
|
||||
url.pathname !== location.pathname ||
|
||||
!url.hash
|
||||
)
|
||||
return;
|
||||
let target: HTMLElement | null = null;
|
||||
try {
|
||||
target = document.getElementById(
|
||||
decodeURIComponent(url.hash.slice(1)),
|
||||
);
|
||||
} catch {}
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
// nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo;
|
||||
// alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa)
|
||||
const index = scenes.findIndex((candidate) =>
|
||||
candidate.contains(target),
|
||||
);
|
||||
const offset =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||
) || 0;
|
||||
const sceneTop = index < 0 ? 0 : sceneTops()[index];
|
||||
const top =
|
||||
index <= 0
|
||||
? 0
|
||||
: Math.max(
|
||||
sceneTop,
|
||||
scenes[index] === target
|
||||
? 0
|
||||
: sceneTop +
|
||||
pageTop(target) -
|
||||
pageTop(scenes[index]) -
|
||||
offset,
|
||||
);
|
||||
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
|
||||
lenis.scrollTo(top, {
|
||||
duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000),
|
||||
easing: easeInOutCubic,
|
||||
// o foco vai depois: antes de a cena entrar ela está invisível e não aceita foco
|
||||
onComplete: () => {
|
||||
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
|
||||
target.focus({ preventScroll: true });
|
||||
},
|
||||
});
|
||||
};
|
||||
document.addEventListener("click", onClick, true);
|
||||
|
||||
cleanup = () => {
|
||||
document.removeEventListener("click", onClick, true);
|
||||
ScrollTrigger.removeEventListener("refresh", onRefresh);
|
||||
window.removeEventListener("scroll", queueSync);
|
||||
cancelAnimationFrame(frame);
|
||||
clearTimeout(refreshTimer);
|
||||
resizeObserver.disconnect();
|
||||
context.revert();
|
||||
for (const scene of scenes) {
|
||||
scene.style.visibility = "";
|
||||
scene.style.top = "";
|
||||
}
|
||||
gsap.ticker.remove(tick);
|
||||
lenis.destroy();
|
||||
};
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [mainRef]);
|
||||
}
|
||||
@@ -16,6 +16,17 @@ function focusFragment(target: HTMLElement) {
|
||||
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);
|
||||
@@ -26,22 +37,49 @@ export function useHomeFragmentNavigation() {
|
||||
| PerformanceNavigationTiming
|
||||
| undefined;
|
||||
// A history restoration owns its saved position, not the URL's old fragment.
|
||||
let pending =
|
||||
navigation?.type === "back_forward" ? "" : window.location.hash;
|
||||
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 || !pending) return;
|
||||
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);
|
||||
target.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
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 (
|
||||
@@ -88,11 +126,13 @@ export function useHomeFragmentNavigation() {
|
||||
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);
|
||||
@@ -109,6 +149,7 @@ export function useHomeFragmentNavigation() {
|
||||
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);
|
||||
@@ -121,5 +162,7 @@ export function useHomeFragmentNavigation() {
|
||||
return useCallback(() => {
|
||||
ready.current = true;
|
||||
resolveArrival.current?.();
|
||||
arrivalSettled = true;
|
||||
window.history.scrollRestoration = "auto";
|
||||
}, []);
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
const frame: NotebookBrowserProps["frame"] = {
|
||||
cursorVisible: true,
|
||||
cursorX: 0.4,
|
||||
cursorY: 0.6,
|
||||
cursorClicking: false,
|
||||
};
|
||||
|
||||
function renderBrowser(overrides: Partial<NotebookBrowserProps> = {}) {
|
||||
return render(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={0}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function imageFor(container: HTMLElement, index: number) {
|
||||
const source = new URL(projects[index].screenshotSrc, document.baseURI).href;
|
||||
const image = Array.from(container.querySelectorAll("img")).find(
|
||||
(candidate) => candidate.src === source,
|
||||
);
|
||||
if (!image) throw new Error(`Missing screenshot for ${projects[index].id}`);
|
||||
return image;
|
||||
}
|
||||
|
||||
describe("NotebookBrowser", () => {
|
||||
it("renders the active project as an accessible tabpanel", () => {
|
||||
renderBrowser({ activeIndex: 1 });
|
||||
|
||||
expect(
|
||||
screen.getByRole("tablist", { name: /projetos em produção/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("tab")[1]).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
|
||||
/SC Plus Aquicultura/i,
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Projeto ativo: SC Plus Aquicultura",
|
||||
);
|
||||
});
|
||||
it("keeps decoded content visible and ignores a superseded decode", async () => {
|
||||
const view = renderBrowser();
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 0));
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
|
||||
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={1}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
let finishDecode!: () => void;
|
||||
imageFor(view.container, 1).decode = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDecode = resolve;
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 1));
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
|
||||
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={2}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 2));
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
|
||||
await act(async () => {
|
||||
finishDecode();
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
|
||||
});
|
||||
|
||||
it("keeps the last capture with a truthful notice when the requested image fails", async () => {
|
||||
const view = renderBrowser();
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 0));
|
||||
});
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={1}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.error(imageFor(view.container, 1));
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
|
||||
expect(
|
||||
screen.getByText(/não foi possível carregar.*SC Plus/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/SC Plus/i);
|
||||
});
|
||||
|
||||
it("can reverse to an already-loaded outgoing image without another load event", async () => {
|
||||
const view = renderBrowser();
|
||||
const first = imageFor(view.container, 0);
|
||||
Object.defineProperties(first, {
|
||||
complete: { value: true },
|
||||
naturalWidth: { value: 1280 },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.load(first);
|
||||
});
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={1}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 1));
|
||||
});
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={0}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("img", { name: /NutriScan/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it("moves between tabs with arrow keys and Home/End", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectTab = jest.fn();
|
||||
renderBrowser({ onSelectTab });
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
|
||||
await user.click(tabs[1]);
|
||||
await user.keyboard("{ArrowRight}");
|
||||
await user.keyboard("{Home}");
|
||||
await user.keyboard("{End}");
|
||||
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(2, 2);
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(3, 0);
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
|
||||
});
|
||||
it("moves DOM focus with the active tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tabs =
|
||||
renderBrowser().container.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="tab"]',
|
||||
);
|
||||
|
||||
tabs[0].focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
|
||||
expect(document.activeElement).toBe(tabs[1]);
|
||||
});
|
||||
|
||||
it("removes the closed browser from the accessibility tree", () => {
|
||||
renderBrowser({ interactive: false });
|
||||
|
||||
const browser = screen.getByTestId("notebook-browser");
|
||||
expect(browser).toHaveAttribute("aria-hidden", "true");
|
||||
expect(browser).toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("bounds arrow navigation at the first and last tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectTab = jest.fn();
|
||||
renderBrowser({ onSelectTab });
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
|
||||
await user.click(tabs[0]);
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
await user.click(tabs.at(-1) as HTMLElement);
|
||||
await user.keyboard("{ArrowRight}");
|
||||
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(2, 0);
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
|
||||
});
|
||||
|
||||
it("keeps an unknown demo link and announces its state", () => {
|
||||
const unknownProjects = projects.map((project, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...project,
|
||||
demoAvailability: "unknown" as const,
|
||||
demoCheckedAt: null,
|
||||
}
|
||||
: project,
|
||||
);
|
||||
renderBrowser({ projects: unknownProjects });
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: /abrir projeto.*disponibilidade não verificada/i,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", projects[0].demoUrl);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("renders an unavailable demo as non-navigable text", () => {
|
||||
const unavailableProjects = projects.map((project, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...project,
|
||||
demoAvailability: "unavailable" as const,
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
}
|
||||
: project,
|
||||
);
|
||||
renderBrowser({ projects: unavailableProjects });
|
||||
|
||||
const unavailable = screen.getByText(
|
||||
"Demo indisponível na última verificação",
|
||||
);
|
||||
expect(unavailable).toHaveAttribute("aria-disabled", "true");
|
||||
expect(unavailable.closest("a")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /abrir projeto/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no demo action for a project without a demo", () => {
|
||||
const noDemoProjects = [
|
||||
{
|
||||
...projects[0],
|
||||
demoUrl: null,
|
||||
demoAvailability: "none",
|
||||
demoCheckedAt: null,
|
||||
},
|
||||
] as unknown as NotebookBrowserProps["projects"];
|
||||
renderBrowser({ projects: noDemoProjects });
|
||||
|
||||
expect(screen.queryByText(/abrir projeto/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Demo indisponível na última verificação"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the selected tab to label the panel and decoded screenshot", async () => {
|
||||
const view = renderBrowser({ activeIndex: 2 });
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 2));
|
||||
});
|
||||
|
||||
const tab = screen.getAllByRole("tab")[2];
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
const image = screen.getByRole("img");
|
||||
|
||||
expect(tab).toHaveAttribute("aria-controls", panel.id);
|
||||
expect(panel).toHaveAttribute("aria-labelledby", tab.id);
|
||||
expect(image).toHaveAccessibleName(/SEV Moda Íntima/i);
|
||||
expect(image).toHaveProperty(
|
||||
"src",
|
||||
new URL(projects[2].screenshotSrc, document.baseURI).href,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,427 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { NotebookProject } from "./notebookProjects";
|
||||
import type { NotebookFrame } from "./notebookTimeline";
|
||||
|
||||
type CursorFrame = Pick<
|
||||
NotebookFrame,
|
||||
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
|
||||
>;
|
||||
export type NotebookBrowserHandle = {
|
||||
updateFrame: (frame: CursorFrame) => void;
|
||||
};
|
||||
|
||||
export type NotebookBrowserProps = {
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
frame: CursorFrame;
|
||||
ref?: Ref<NotebookBrowserHandle>;
|
||||
onSelectTab: (index: number) => void;
|
||||
interactive?: boolean;
|
||||
};
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length < 1) return -1;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
function toDomId(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
export default function NotebookBrowser({
|
||||
projects,
|
||||
activeIndex,
|
||||
frame,
|
||||
onSelectTab,
|
||||
interactive = true,
|
||||
ref,
|
||||
}: NotebookBrowserProps) {
|
||||
const selectedIndex = clampIndex(activeIndex, projects.length);
|
||||
const selectedProject =
|
||||
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const cursorRef = useRef<SVGSVGElement>(null);
|
||||
const cursorFrame = useRef(frame);
|
||||
const tabPoints = useRef<Array<{ x: number; y: number }>>([]);
|
||||
const browserSize = useRef({ width: 0, height: 0 });
|
||||
const [displayed, setDisplayed] = useState<NotebookProject | null>(null);
|
||||
const [outgoing, setOutgoing] = useState<NotebookProject | null>(null);
|
||||
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
||||
const requestedSrc = selectedProject?.screenshotSrc;
|
||||
const requestRef = useRef(requestedSrc);
|
||||
const requestVersion = useRef(0);
|
||||
const displayedRef = useRef(displayed);
|
||||
const mounted = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
requestRef.current = requestedSrc;
|
||||
requestVersion.current += 1;
|
||||
setFailedSrc(null);
|
||||
}, [requestedSrc]);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Only the immediate neighbours are fetched; the browser cache handles reuse.
|
||||
useEffect(() => {
|
||||
const images = [projects[selectedIndex - 1], projects[selectedIndex + 1]]
|
||||
.filter((project): project is NotebookProject => Boolean(project))
|
||||
.map((project) => {
|
||||
const image = new window.Image();
|
||||
image.decoding = "async";
|
||||
image.src = project.screenshotSrc;
|
||||
return image;
|
||||
});
|
||||
return () => {
|
||||
images.forEach((image) => {
|
||||
image.removeAttribute("src");
|
||||
});
|
||||
};
|
||||
}, [projects, selectedIndex]);
|
||||
|
||||
const updateFrame = useCallback((next: CursorFrame) => {
|
||||
cursorFrame.current = next;
|
||||
const cursor = cursorRef.current;
|
||||
const points = tabPoints.current;
|
||||
if (!cursor || points.length === 0) return;
|
||||
const position = Math.max(
|
||||
0,
|
||||
Math.min(points.length - 1, next.cursorX * points.length - 0.5),
|
||||
);
|
||||
const left = Math.floor(position);
|
||||
const right = Math.min(left + 1, points.length - 1);
|
||||
const blend = position - left;
|
||||
const entry = Math.max(0, Math.min(1, (0.95 - next.cursorY) / 0.65));
|
||||
const tabX = points[left].x + (points[right].x - points[left].x) * blend;
|
||||
const tabY = points[left].y + (points[right].y - points[left].y) * blend;
|
||||
const x = browserSize.current.width * 0.5 * (1 - entry) + tabX * entry;
|
||||
const y = browserSize.current.height * 0.95 * (1 - entry) + tabY * entry;
|
||||
cursor.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${next.cursorClicking ? 0.88 : 1})`;
|
||||
cursor.style.opacity = next.cursorVisible ? "1" : "0";
|
||||
cursor.classList.toggle("is-clicking", next.cursorClicking);
|
||||
}, []);
|
||||
useImperativeHandle(ref, () => ({ updateFrame }), [updateFrame]);
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return undefined;
|
||||
const measure = () => {
|
||||
browserSize.current = {
|
||||
width: root.clientWidth,
|
||||
height: root.clientHeight,
|
||||
};
|
||||
const points = [];
|
||||
for (const project of projects) {
|
||||
const tab: HTMLButtonElement | null =
|
||||
root.querySelector<HTMLButtonElement>(
|
||||
`#notebook-tab-${toDomId(project.id)}`,
|
||||
);
|
||||
if (!tab) continue;
|
||||
let x = tab.offsetWidth / 2;
|
||||
let y = tab.offsetHeight / 2;
|
||||
let element: HTMLElement | null = tab;
|
||||
while (element && element !== root) {
|
||||
x += element.offsetLeft;
|
||||
y += element.offsetTop;
|
||||
element = element.offsetParent as HTMLElement | null;
|
||||
}
|
||||
points.push({ x, y });
|
||||
}
|
||||
tabPoints.current = points;
|
||||
updateFrame(cursorFrame.current);
|
||||
};
|
||||
measure();
|
||||
if (!("ResizeObserver" in window)) return undefined;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(root);
|
||||
return () => observer.disconnect();
|
||||
}, [projects, updateFrame]);
|
||||
useLayoutEffect(() => {
|
||||
updateFrame(frame);
|
||||
}, [frame, updateFrame]);
|
||||
|
||||
const revealImage = useCallback(
|
||||
async (image: HTMLImageElement, project: NotebookProject) => {
|
||||
const version = requestVersion.current;
|
||||
try {
|
||||
if (typeof image.decode === "function") await image.decode();
|
||||
} catch {
|
||||
if (
|
||||
mounted.current &&
|
||||
version === requestVersion.current &&
|
||||
requestRef.current === project.screenshotSrc
|
||||
) {
|
||||
setFailedSrc(project.screenshotSrc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!mounted.current ||
|
||||
version !== requestVersion.current ||
|
||||
requestRef.current !== project.screenshotSrc
|
||||
)
|
||||
return;
|
||||
if (displayedRef.current?.screenshotSrc === project.screenshotSrc) return;
|
||||
setOutgoing(
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
? null
|
||||
: displayedRef.current,
|
||||
);
|
||||
displayedRef.current = project;
|
||||
setDisplayed(project);
|
||||
setFailedSrc(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (!selectedProject) return;
|
||||
// A quick reversal can select the still-mounted outgoing image: no new load event.
|
||||
const source = new URL(selectedProject.screenshotSrc, document.baseURI)
|
||||
.href;
|
||||
const image = Array.from(
|
||||
rootRef.current?.querySelectorAll("img") ?? [],
|
||||
).find((item) => item.src === source);
|
||||
if (image?.complete && image.naturalWidth > 0) {
|
||||
void revealImage(image, selectedProject);
|
||||
}
|
||||
}, [selectedProject, revealImage]);
|
||||
|
||||
const imageProjects = [outgoing, displayed, selectedProject].filter(
|
||||
(project, index, list): project is NotebookProject =>
|
||||
Boolean(project) &&
|
||||
list.findIndex(
|
||||
(item) => item?.screenshotSrc === project?.screenshotSrc,
|
||||
) === index,
|
||||
);
|
||||
const waiting = displayed?.screenshotSrc !== requestedSrc;
|
||||
const interactionProps = interactive
|
||||
? {}
|
||||
: {
|
||||
"aria-hidden": true,
|
||||
inert: true,
|
||||
};
|
||||
|
||||
function handleTabKeyDown(
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
index: number,
|
||||
) {
|
||||
if (projects.length < 1) return;
|
||||
|
||||
let nextIndex: number | null = null;
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
||||
nextIndex = Math.min(index + 1, projects.length - 1);
|
||||
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
||||
nextIndex = Math.max(index - 1, 0);
|
||||
} else if (event.key === "Home") {
|
||||
nextIndex = 0;
|
||||
} else if (event.key === "End") {
|
||||
nextIndex = projects.length - 1;
|
||||
}
|
||||
|
||||
if (nextIndex === null) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.parentElement
|
||||
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
|
||||
[nextIndex]?.focus({ preventScroll: true });
|
||||
onSelectTab(nextIndex);
|
||||
}
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div
|
||||
{...interactionProps}
|
||||
className="notebook-browser"
|
||||
data-testid="notebook-browser"
|
||||
data-empty
|
||||
>
|
||||
<p aria-live="polite">Nenhum projeto disponível para esta cena.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedId = toDomId(selectedProject.id);
|
||||
const panelId = `notebook-panel-${selectedId}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...interactionProps}
|
||||
className="notebook-browser"
|
||||
data-testid="notebook-browser"
|
||||
ref={rootRef}
|
||||
>
|
||||
<div className="notebook-browser-bar" aria-hidden="true">
|
||||
<span className="notebook-browser-dot" />
|
||||
<span className="notebook-browser-dot" />
|
||||
<span className="notebook-browser-dot" />
|
||||
<span className="notebook-browser-address">
|
||||
evandro.dev.br / projetos
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="notebook-browser-body">
|
||||
<div className="notebook-browser-heading">
|
||||
<span className="notebook-browser-eyebrow">Projetos em produção</span>
|
||||
<span className="notebook-browser-counter">
|
||||
{String(selectedIndex + 1).padStart(2, "0")} /{" "}
|
||||
{String(projects.length).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<output className="sr-only" aria-live="polite">
|
||||
Projeto ativo: {selectedProject.title}
|
||||
</output>
|
||||
|
||||
<div
|
||||
className="notebook-tablist"
|
||||
role="tablist"
|
||||
aria-label="Projetos em produção"
|
||||
>
|
||||
{projects.map((project, index) => {
|
||||
const tabId = `notebook-tab-${toDomId(project.id)}`;
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-selected={isSelected}
|
||||
className="notebook-tab"
|
||||
id={tabId}
|
||||
key={project.id}
|
||||
onClick={() => onSelectTab(index)}
|
||||
onKeyDown={(event) => handleTabKeyDown(event, index)}
|
||||
role="tab"
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<span className="notebook-tab-number">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="notebook-tab-title">{project.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-labelledby={`notebook-tab-${selectedId}`}
|
||||
className="notebook-panel"
|
||||
id={panelId}
|
||||
role="tabpanel"
|
||||
>
|
||||
<div className="notebook-panel-copy">
|
||||
<h3>{selectedProject.title}</h3>
|
||||
<p>{selectedProject.subtitle}</p>
|
||||
<ul aria-label={`Tecnologias de ${selectedProject.title}`}>
|
||||
{selectedProject.technologies.slice(0, 4).map((technology) => (
|
||||
<li key={technology}>{technology}</li>
|
||||
))}
|
||||
</ul>
|
||||
{selectedProject.demoAvailability === "unavailable" ? (
|
||||
<span aria-disabled="true">
|
||||
Demo indisponível na última verificação
|
||||
</span>
|
||||
) : selectedProject.demoAvailability === "none" ||
|
||||
!selectedProject.demoUrl ? null : (
|
||||
<a
|
||||
href={selectedProject.demoUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Abrir projeto <span aria-hidden="true">↗</span>
|
||||
{selectedProject.demoAvailability === "unknown" ? (
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
Disponibilidade não verificada
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="notebook-screenshot-wrap"
|
||||
aria-busy={waiting && failedSrc !== requestedSrc}
|
||||
>
|
||||
{imageProjects.map((project) => {
|
||||
const current =
|
||||
project.screenshotSrc === displayed?.screenshotSrc;
|
||||
const previous =
|
||||
project.screenshotSrc === outgoing?.screenshotSrc;
|
||||
return (
|
||||
<Image
|
||||
key={project.screenshotSrc}
|
||||
alt={current ? `Captura de tela de ${project.title}` : ""}
|
||||
aria-hidden={!current}
|
||||
className={`notebook-screenshot ${current ? "notebook-screenshot-current" : previous ? "notebook-screenshot-outgoing" : "notebook-screenshot-pending"}`}
|
||||
width={project.screenshotWidth}
|
||||
height={project.screenshotHeight}
|
||||
loading="eager"
|
||||
sizes="(min-width: 1025px) 800px, 100vw"
|
||||
src={project.screenshotSrc}
|
||||
unoptimized
|
||||
onLoad={(event) => {
|
||||
void revealImage(event.currentTarget, project);
|
||||
}}
|
||||
onError={() => {
|
||||
if (project.screenshotSrc === requestRef.current)
|
||||
setFailedSrc(project.screenshotSrc);
|
||||
}}
|
||||
onAnimationEnd={() => {
|
||||
if (current) setOutgoing(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{waiting ? (
|
||||
<p className="notebook-screenshot-status" aria-live="polite">
|
||||
{failedSrc === requestedSrc
|
||||
? `Não foi possível carregar a captura de ${selectedProject.title}.`
|
||||
: `Carregando captura de ${selectedProject.title}…`}
|
||||
{displayed
|
||||
? ` Exibindo ${displayed.title} enquanto isso.`
|
||||
: null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="notebook-cursor"
|
||||
ref={cursorRef}
|
||||
viewBox="0 0 28 36"
|
||||
width="28"
|
||||
height="36"
|
||||
>
|
||||
<title>Cursor decorativo</title>
|
||||
<path
|
||||
d="M1 1 L1 26 L8 20 L14 33 L19 30 L13 18 L23 18 Z"
|
||||
fill="#0a0a0a"
|
||||
stroke="#f4f2ed"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
className="notebook-cursor-pulse"
|
||||
cx="2"
|
||||
cy="2"
|
||||
r="8"
|
||||
fill="none"
|
||||
stroke="#ccff00"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
|
||||
describe("NotebookFallback", () => {
|
||||
it("keeps project content and controls in normal document flow", () => {
|
||||
const { container } = render(<NotebookFallback projects={projects} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
||||
expect(screen.getByRole("tabpanel")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /abrir projeto/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows manual tab changes without an animated stage", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<NotebookFallback projects={projects} />);
|
||||
|
||||
await user.click(screen.getAllByRole("tab")[2]);
|
||||
|
||||
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
|
||||
/SEV Moda Íntima/i,
|
||||
);
|
||||
expect(container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects the requested initial project", () => {
|
||||
render(<NotebookFallback initialIndex={3} projects={projects} />);
|
||||
|
||||
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/AS Serviços/i);
|
||||
expect(screen.getAllByRole("tab")[3]).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import NotebookBrowser from "./NotebookBrowser";
|
||||
import type { NotebookProject } from "./notebookProjects";
|
||||
|
||||
export type NotebookFallbackProps = {
|
||||
projects: NotebookProject[];
|
||||
initialIndex?: number;
|
||||
onSelectTab?: (index: number) => void;
|
||||
};
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length < 1) return -1;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
export default function NotebookFallback({
|
||||
projects,
|
||||
initialIndex = 0,
|
||||
onSelectTab,
|
||||
}: NotebookFallbackProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(() =>
|
||||
clampIndex(initialIndex, projects.length),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="notebook-fallback" data-testid="notebook-fallback">
|
||||
<div className="notebook-fallback-intro">
|
||||
<span className="notebook-fallback-kicker">Interface em produção</span>
|
||||
<p>
|
||||
Navegue pelos projetos com as abas abaixo. A experiência permanece
|
||||
completa sem movimento ou WebGL.
|
||||
</p>
|
||||
</div>
|
||||
<NotebookBrowser
|
||||
activeIndex={activeIndex}
|
||||
frame={{
|
||||
cursorClicking: false,
|
||||
cursorVisible: false,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
}}
|
||||
onSelectTab={(index) => {
|
||||
setActiveIndex(index);
|
||||
onSelectTab?.(index);
|
||||
}}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStage3D, {
|
||||
getNotebookLidRotation,
|
||||
type NotebookStage3DProps,
|
||||
updateNotebookScreenMatrix,
|
||||
} from "./NotebookStage3D";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
type MockCanvasProps = {
|
||||
children: React.ReactNode;
|
||||
frameloop?: string;
|
||||
};
|
||||
|
||||
const mockCanvas = jest.fn(({ children, frameloop }: MockCanvasProps) => (
|
||||
<div data-frameloop={frameloop} data-testid="mock-canvas">
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
Canvas: (props: MockCanvasProps) => mockCanvas(props),
|
||||
useFrame: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@react-three/drei", () => ({
|
||||
Html: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="mock-html">{children}</div>
|
||||
),
|
||||
RoundedBox: () => <div data-testid="mock-roundedbox" />,
|
||||
}));
|
||||
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
const props: NotebookStage3DProps = {
|
||||
activeIndex: 0,
|
||||
isVisible: true,
|
||||
onSelectTab: jest.fn(),
|
||||
progress: { current: 0.56 },
|
||||
requestRender: { current: null },
|
||||
projects,
|
||||
};
|
||||
|
||||
describe("getNotebookLidRotation", () => {
|
||||
it("keeps the lid closed at rest and opens around the hinge", () => {
|
||||
expect(getNotebookLidRotation(0)).toBe(0);
|
||||
expect(getNotebookLidRotation(112)).toBeCloseTo(
|
||||
-THREE.MathUtils.degToRad(112),
|
||||
6,
|
||||
);
|
||||
expect(getNotebookLidRotation(56)).toBeCloseTo(
|
||||
-THREE.MathUtils.degToRad(56),
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps angles outside the physical hinge range", () => {
|
||||
expect(getNotebookLidRotation(-10)).toBe(0);
|
||||
expect(getNotebookLidRotation(200)).toBe(getNotebookLidRotation(180));
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps browser corners on the physical LCD after opening and resizing", () => {
|
||||
const matrix = new THREE.Matrix4();
|
||||
const rig = new THREE.Group();
|
||||
rig.position.set(0, -0.45, 0);
|
||||
const hinge = new THREE.Group();
|
||||
hinge.position.set(0, 0.16, -1.06);
|
||||
rig.add(hinge);
|
||||
const lcd = new THREE.Object3D();
|
||||
lcd.position.set(0, -0.028, 1.1);
|
||||
lcd.rotation.x = Math.PI / 2;
|
||||
hinge.add(lcd);
|
||||
const domWidth = 800;
|
||||
const domHeight = (domWidth * 1.86) / 2.8;
|
||||
|
||||
for (const size of [
|
||||
{ width: 939.390625, height: 738, lidAngle: 74 },
|
||||
{ width: 1123.375, height: 629.75, lidAngle: 112 },
|
||||
]) {
|
||||
const camera = new THREE.PerspectiveCamera(30, size.width / size.height);
|
||||
camera.position.set(0, 1.65, 5.7);
|
||||
camera.rotation.x = -0.19;
|
||||
hinge.rotation.x = getNotebookLidRotation(size.lidAngle);
|
||||
updateNotebookScreenMatrix(matrix, lcd, camera, size);
|
||||
|
||||
for (const [u, v] of [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
]) {
|
||||
const expected = new THREE.Vector3((u - 0.5) * 2.8, (0.5 - v) * 1.86, 0)
|
||||
.applyMatrix4(lcd.matrixWorld)
|
||||
.project(camera);
|
||||
const actual = new THREE.Vector3(
|
||||
u * domWidth,
|
||||
v * domHeight,
|
||||
0,
|
||||
).applyMatrix4(matrix);
|
||||
expect(actual.x).toBeCloseTo(((expected.x + 1) * size.width) / 2, 6);
|
||||
expect(actual.y).toBeCloseTo(((1 - expected.y) * size.height) / 2, 6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("NotebookStage3D", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns usable browser content when the stage boundary fails", () => {
|
||||
const onStageError = jest.fn();
|
||||
mockCanvas.mockImplementation(() => {
|
||||
throw new Error("canvas failed");
|
||||
});
|
||||
|
||||
render(<NotebookStage3D {...props} onStageError={onStageError} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /abrir projeto/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument();
|
||||
expect(onStageError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,445 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Html, RoundedBox } from "@react-three/drei";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
Component,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import NotebookBrowser, { type NotebookBrowserHandle } from "./NotebookBrowser";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import type { NotebookProject } from "./notebookProjects";
|
||||
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
|
||||
|
||||
type ProgressRef = { current: number };
|
||||
|
||||
export type NotebookStage3DProps = {
|
||||
progress: ProgressRef;
|
||||
requestRender: { current: (() => void) | null };
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
onSelectTab: (index: number) => void;
|
||||
isVisible: boolean;
|
||||
onStageError?: () => void;
|
||||
};
|
||||
|
||||
type NotebookSceneProps = NotebookStage3DProps;
|
||||
|
||||
/**
|
||||
* Maps the timeline lid angle onto the hinge rotation. 0° = closed (lid
|
||||
* resting on the deck), 112° = open (screen facing the camera, reclined
|
||||
* ~22° past vertical). The lid pivots around the hinge axis — never around
|
||||
* the display center.
|
||||
*/
|
||||
export function getNotebookLidRotation(lidAngle: number): number {
|
||||
const angle = THREE.MathUtils.clamp(lidAngle, 0, 180);
|
||||
const radians = THREE.MathUtils.degToRad(angle);
|
||||
return radians === 0 ? 0 : -radians;
|
||||
}
|
||||
|
||||
// The laptop and its DOM screen share the same world-space geometry.
|
||||
const RIG_POSITION: [number, number, number] = [0, -0.45, 0];
|
||||
const CAMERA_POSITION: [number, number, number] = [0, 1.65, 5.7];
|
||||
const CAMERA_ROTATION: [number, number, number] = [-0.19, 0, 0];
|
||||
|
||||
const BASE = { width: 3.2, height: 0.16, depth: 2.2 };
|
||||
const HINGE: [number, number, number] = [0, 0.16, -1.06];
|
||||
const LID = { width: 3.2, thickness: 0.1, depth: 2.2 };
|
||||
const SCREEN_INSET = { width: 2.9, depth: 1.96 };
|
||||
const SCREEN_GLASS = { width: 2.8, depth: 1.86 };
|
||||
|
||||
const KEY_COLUMNS = 12;
|
||||
const KEY_ROWS = 5;
|
||||
const KEY_SIZE: [number, number, number] = [0.185, 0.05, 0.155];
|
||||
const KEY_GAP_X = 0.212;
|
||||
const KEY_GAP_Z = 0.2;
|
||||
const KEYBOARD_Z_START = -0.9;
|
||||
const KEYBOARD_Y = 0.185;
|
||||
const SPACEBAR_Z = 0.12;
|
||||
|
||||
const SCREEN_DOM_WIDTH = 800;
|
||||
const SCREEN_DOM_HEIGHT =
|
||||
(SCREEN_DOM_WIDTH * SCREEN_GLASS.depth) / SCREEN_GLASS.width;
|
||||
const SCREEN_PIXELS_TO_WORLD = new THREE.Matrix4()
|
||||
.makeScale(
|
||||
SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
|
||||
-SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
|
||||
1,
|
||||
)
|
||||
.setPosition(-SCREEN_GLASS.width / 2, SCREEN_GLASS.depth / 2, 0);
|
||||
const SCREEN_ORIGIN: [number, number] = [0, 0];
|
||||
const getScreenOrigin = () => SCREEN_ORIGIN;
|
||||
|
||||
export function updateNotebookScreenMatrix(
|
||||
matrix: THREE.Matrix4,
|
||||
screen: THREE.Object3D,
|
||||
camera: THREE.Camera,
|
||||
size: { width: number; height: number },
|
||||
): THREE.Matrix4 {
|
||||
screen.updateWorldMatrix(true, false);
|
||||
camera.updateMatrixWorld();
|
||||
const halfWidth = size.width / 2;
|
||||
const halfHeight = size.height / 2;
|
||||
// One pixel-space matrix keeps CSS paint and hit-testing on the LCD plane.
|
||||
matrix
|
||||
.set(
|
||||
halfWidth,
|
||||
0,
|
||||
0,
|
||||
halfWidth,
|
||||
0,
|
||||
-halfHeight,
|
||||
0,
|
||||
halfHeight,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
.multiply(camera.projectionMatrix)
|
||||
.multiply(camera.matrixWorldInverse)
|
||||
.multiply(screen.matrixWorld)
|
||||
.multiply(SCREEN_PIXELS_TO_WORLD);
|
||||
return matrix.multiplyScalar(1 / matrix.elements[15]);
|
||||
}
|
||||
|
||||
type LaptopMaterials = {
|
||||
chassis: THREE.MeshStandardMaterial;
|
||||
keys: THREE.MeshStandardMaterial;
|
||||
dark: THREE.MeshStandardMaterial;
|
||||
accent: THREE.MeshStandardMaterial;
|
||||
screen: THREE.MeshStandardMaterial;
|
||||
};
|
||||
|
||||
function createLaptopMaterials(): LaptopMaterials {
|
||||
return {
|
||||
chassis: new THREE.MeshStandardMaterial({
|
||||
color: "#3f545b",
|
||||
metalness: 0.35,
|
||||
roughness: 0.32,
|
||||
transparent: true,
|
||||
}),
|
||||
keys: new THREE.MeshStandardMaterial({
|
||||
color: "#5a747c",
|
||||
metalness: 0.2,
|
||||
roughness: 0.5,
|
||||
transparent: true,
|
||||
}),
|
||||
dark: new THREE.MeshStandardMaterial({
|
||||
color: "#131f24",
|
||||
metalness: 0.15,
|
||||
roughness: 0.55,
|
||||
transparent: true,
|
||||
}),
|
||||
accent: new THREE.MeshStandardMaterial({
|
||||
color: "#465f66",
|
||||
metalness: 0.3,
|
||||
roughness: 0.4,
|
||||
transparent: true,
|
||||
}),
|
||||
screen: new THREE.MeshStandardMaterial({
|
||||
color: "#0d1417",
|
||||
emissive: "#22343a",
|
||||
emissiveIntensity: 0.55,
|
||||
metalness: 0.1,
|
||||
roughness: 0.4,
|
||||
transparent: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function setLaptopOpacity(materials: LaptopMaterials, opacity: number) {
|
||||
materials.chassis.opacity = opacity;
|
||||
materials.keys.opacity = opacity;
|
||||
materials.dark.opacity = opacity;
|
||||
materials.accent.opacity = opacity;
|
||||
materials.screen.opacity = opacity;
|
||||
}
|
||||
|
||||
function disposeLaptopMaterials(materials: LaptopMaterials) {
|
||||
for (const material of Object.values(materials)) {
|
||||
material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function LaptopKeys({ material }: { material: THREE.MeshStandardMaterial }) {
|
||||
const ref = useRef<THREE.InstancedMesh>(null);
|
||||
const positions = useMemo(() => {
|
||||
const list: Array<[number, number]> = [];
|
||||
const startX = (-(KEY_COLUMNS - 1) * KEY_GAP_X) / 2;
|
||||
for (let row = 0; row < KEY_ROWS; row += 1) {
|
||||
for (let column = 0; column < KEY_COLUMNS; column += 1) {
|
||||
list.push([
|
||||
startX + column * KEY_GAP_X,
|
||||
KEYBOARD_Z_START + row * KEY_GAP_Z,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mesh = ref.current;
|
||||
if (!(mesh instanceof THREE.InstancedMesh)) return;
|
||||
|
||||
const matrix = new THREE.Matrix4();
|
||||
positions.forEach(([x, z], index) => {
|
||||
matrix.makeTranslation(x, KEYBOARD_Y, z);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}, [positions]);
|
||||
|
||||
return (
|
||||
<instancedMesh
|
||||
args={[undefined, undefined, positions.length]}
|
||||
material={material}
|
||||
ref={ref}
|
||||
>
|
||||
<boxGeometry args={KEY_SIZE} />
|
||||
</instancedMesh>
|
||||
);
|
||||
}
|
||||
|
||||
function LaptopBase({ materials }: { materials: LaptopMaterials }) {
|
||||
return (
|
||||
<group>
|
||||
<RoundedBox
|
||||
args={[BASE.width, BASE.height, BASE.depth]}
|
||||
material={materials.chassis}
|
||||
position={[0, BASE.height / 2, 0]}
|
||||
radius={0.05}
|
||||
smoothness={3}
|
||||
/>
|
||||
<RoundedBox
|
||||
args={[2.66, 0.05, 1.3]}
|
||||
material={materials.dark}
|
||||
position={[0, 0.155, -0.38]}
|
||||
radius={0.02}
|
||||
smoothness={2}
|
||||
/>
|
||||
<LaptopKeys material={materials.keys} />
|
||||
<mesh material={materials.keys} position={[0, KEYBOARD_Y, SPACEBAR_Z]}>
|
||||
<boxGeometry args={[1.05, KEY_SIZE[1], KEY_SIZE[2]]} />
|
||||
</mesh>
|
||||
<RoundedBox
|
||||
args={[1.15, 0.02, 0.6]}
|
||||
material={materials.accent}
|
||||
position={[0, 0.17, 0.68]}
|
||||
radius={0.01}
|
||||
smoothness={2}
|
||||
/>
|
||||
<mesh
|
||||
material={materials.dark}
|
||||
position={[0, 0.16, -1.06]}
|
||||
rotation={[0, 0, Math.PI / 2]}
|
||||
>
|
||||
<cylinderGeometry args={[0.05, 0.05, 2.9, 16]} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function LaptopLid({ materials }: { materials: LaptopMaterials }) {
|
||||
return (
|
||||
<group>
|
||||
<RoundedBox
|
||||
args={[LID.width, LID.thickness, LID.depth]}
|
||||
material={materials.chassis}
|
||||
position={[0, LID.thickness / 2, LID.depth / 2]}
|
||||
radius={0.05}
|
||||
smoothness={3}
|
||||
/>
|
||||
<RoundedBox
|
||||
args={[SCREEN_INSET.width, 0.05, SCREEN_INSET.depth]}
|
||||
material={materials.dark}
|
||||
position={[0, 0, LID.depth / 2]}
|
||||
radius={0.02}
|
||||
smoothness={2}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenSurface({
|
||||
frame,
|
||||
material,
|
||||
projects,
|
||||
activeIndex,
|
||||
onSelectTab,
|
||||
}: {
|
||||
frame: RefObject<NotebookFrame>;
|
||||
material: THREE.MeshStandardMaterial;
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
onSelectTab: (index: number) => void;
|
||||
}) {
|
||||
const screen = useRef<THREE.Group>(null);
|
||||
const surface = useRef<HTMLDivElement>(null);
|
||||
const browser = useRef<NotebookBrowserHandle>(null);
|
||||
const projection = useMemo(() => new THREE.Matrix4(), []);
|
||||
useFrame(({ camera, size }) => {
|
||||
if (surface.current && screen.current) {
|
||||
updateNotebookScreenMatrix(projection, screen.current, camera, size);
|
||||
surface.current.style.transform = `matrix3d(${projection.elements.join(",")})`;
|
||||
surface.current.style.opacity = String(frame.current.screenOpacity);
|
||||
surface.current.inert = frame.current.screenOpacity < 0.95;
|
||||
surface.current.setAttribute(
|
||||
"aria-hidden",
|
||||
String(frame.current.screenOpacity < 0.95),
|
||||
);
|
||||
}
|
||||
browser.current?.updateFrame(frame.current);
|
||||
});
|
||||
return (
|
||||
<group
|
||||
ref={screen}
|
||||
position={[0, -0.028, LID.depth / 2]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<mesh material={material}>
|
||||
<planeGeometry args={[SCREEN_GLASS.width, SCREEN_GLASS.depth]} />
|
||||
</mesh>
|
||||
<Html calculatePosition={getScreenOrigin} zIndexRange={[20, 0]}>
|
||||
<div
|
||||
ref={surface}
|
||||
className="notebook-screen-surface"
|
||||
style={{
|
||||
width: SCREEN_DOM_WIDTH,
|
||||
height: SCREEN_DOM_HEIGHT,
|
||||
opacity: frame.current.screenOpacity,
|
||||
}}
|
||||
inert={frame.current.screenOpacity < 0.95}
|
||||
aria-hidden={frame.current.screenOpacity < 0.95}
|
||||
>
|
||||
<NotebookBrowser
|
||||
ref={browser}
|
||||
activeIndex={activeIndex}
|
||||
frame={frame.current}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function NotebookScene({
|
||||
activeIndex,
|
||||
onSelectTab,
|
||||
progress,
|
||||
projects,
|
||||
requestRender,
|
||||
isVisible,
|
||||
}: NotebookSceneProps) {
|
||||
const lidRig = useRef<THREE.Group>(null);
|
||||
const frame = useRef(getNotebookFrame(progress.current, projects.length));
|
||||
const materials = useMemo(createLaptopMaterials, []);
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
|
||||
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
|
||||
useEffect(() => {
|
||||
requestRender.current = isVisible ? invalidate : null;
|
||||
if (isVisible) invalidate();
|
||||
return () => {
|
||||
requestRender.current = null;
|
||||
};
|
||||
}, [invalidate, isVisible, requestRender]);
|
||||
|
||||
useFrame(() => {
|
||||
const nextFrame = getNotebookFrame(
|
||||
progress.current,
|
||||
projects.length,
|
||||
frame.current,
|
||||
);
|
||||
if (lidRig.current) {
|
||||
lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle);
|
||||
}
|
||||
setLaptopOpacity(materials, nextFrame.deviceOpacity);
|
||||
}, -1);
|
||||
|
||||
return (
|
||||
<group position={RIG_POSITION}>
|
||||
<ambientLight intensity={1.35} />
|
||||
<hemisphereLight args={["#f4f2ed", "#0a0a0a", 1.15]} />
|
||||
<directionalLight color="#f4f2ed" intensity={2.6} position={[3, 6, 6]} />
|
||||
<directionalLight color="#ccff00" intensity={0.7} position={[-5, 2, 3]} />
|
||||
<LaptopBase materials={materials} />
|
||||
<group position={HINGE} ref={lidRig}>
|
||||
<LaptopLid materials={materials} />
|
||||
<ScreenSurface
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
material={materials.screen}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
class NotebookStageBoundary extends Component<
|
||||
NotebookStage3DProps,
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch() {
|
||||
this.props.onStageError?.();
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.failed) {
|
||||
return (
|
||||
<NotebookFallback
|
||||
initialIndex={this.props.activeIndex}
|
||||
projects={this.props.projects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
camera={{
|
||||
fov: 30,
|
||||
position: CAMERA_POSITION,
|
||||
rotation: CAMERA_ROTATION,
|
||||
}}
|
||||
className="notebook-story-canvas"
|
||||
dpr={[1, 1.5]}
|
||||
frameloop={this.props.isVisible ? "demand" : "never"}
|
||||
gl={{
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
>
|
||||
<NotebookScene {...this.props} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotebookStage3D(props: NotebookStage3DProps) {
|
||||
return (
|
||||
<div className="notebook-story-stage" data-testid="notebook-stage">
|
||||
<NotebookStageBoundary {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStory, { canUseNotebook3D } from "./NotebookStory";
|
||||
|
||||
const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
|
||||
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
|
||||
));
|
||||
|
||||
jest.mock("next/dynamic", () => () => {
|
||||
return function DynamicNotebookStage(props: { isVisible: boolean }) {
|
||||
return mockNotebookStage(props);
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("gsap", () => ({
|
||||
gsap: {
|
||||
registerPlugin: jest.fn(),
|
||||
timeline: jest.fn(() => ({ to: jest.fn().mockReturnThis() })),
|
||||
context: jest.fn((callback: () => void) => {
|
||||
callback();
|
||||
return { revert: jest.fn() };
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("gsap/ScrollTrigger", () => ({
|
||||
ScrollTrigger: { getAll: jest.fn(() => []) },
|
||||
}));
|
||||
const projects = getFeaturedProjects(null);
|
||||
|
||||
let intersectionCallback: IntersectionObserverCallback | undefined;
|
||||
const mockObserve = jest.fn();
|
||||
const mockDisconnect = jest.fn();
|
||||
|
||||
function setDocumentHidden(hidden: boolean) {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
value: hidden,
|
||||
});
|
||||
}
|
||||
|
||||
function setSectionIntersects(isIntersecting: boolean) {
|
||||
const entry = { isIntersecting } as IntersectionObserverEntry;
|
||||
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
|
||||
}
|
||||
|
||||
function mockMatchMedia({
|
||||
reduced = false,
|
||||
coarse = false,
|
||||
hover = true,
|
||||
}: {
|
||||
reduced?: boolean;
|
||||
coarse?: boolean;
|
||||
hover?: boolean;
|
||||
} = {}) {
|
||||
const matcher = jest.isMockFunction(window.matchMedia)
|
||||
? window.matchMedia
|
||||
: jest.spyOn(window, "matchMedia");
|
||||
matcher.mockImplementation((query: string) => ({
|
||||
matches:
|
||||
(query === "(prefers-reduced-motion: reduce)" && reduced) ||
|
||||
(query === "(pointer: coarse)" && coarse) ||
|
||||
(query === "(hover: hover)" && hover),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("NotebookStory", () => {
|
||||
beforeEach(() => {
|
||||
mockMatchMedia();
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 1280,
|
||||
});
|
||||
setDocumentHidden(false);
|
||||
intersectionCallback = undefined;
|
||||
mockObserve.mockReset();
|
||||
mockDisconnect.mockReset();
|
||||
class MockIntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "";
|
||||
readonly thresholds = [];
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
|
||||
disconnect = mockDisconnect;
|
||||
observe = mockObserve;
|
||||
takeRecords = () => [];
|
||||
unobserve = jest.fn();
|
||||
}
|
||||
global.IntersectionObserver =
|
||||
MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the static fallback when reduced motion is enabled", async () => {
|
||||
mockMatchMedia({ reduced: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not initialize the 3D stage on coarse pointers", async () => {
|
||||
mockMatchMedia({ coarse: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays paused when the tab returns while the section is offscreen", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
const stage = await screen.findByTestId("notebook-stage");
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false");
|
||||
|
||||
setSectionIntersects(true);
|
||||
await waitFor(() =>
|
||||
expect(stage).toHaveAttribute("data-is-visible", "true"),
|
||||
);
|
||||
|
||||
setDocumentHidden(true);
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
await waitFor(() =>
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false"),
|
||||
);
|
||||
|
||||
setSectionIntersects(false);
|
||||
setDocumentHidden(false);
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false");
|
||||
});
|
||||
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the static fallback when no mapped projects are available", async () => {
|
||||
mockMatchMedia();
|
||||
|
||||
render(<NotebookStory projects={[]} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("Projetos em produção no notebook"),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires desktop hover input without reduced motion", () => {
|
||||
const matchMedia = (query: string) =>
|
||||
({
|
||||
matches: query === "(hover: hover)",
|
||||
}) as MediaQueryList;
|
||||
|
||||
expect(canUseNotebook3D({ innerWidth: 1280, matchMedia })).toBe(true);
|
||||
expect(canUseNotebook3D({ innerWidth: 1024, matchMedia })).toBe(false);
|
||||
expect(
|
||||
canUseNotebook3D({
|
||||
innerWidth: 1280,
|
||||
matchMedia: (query) =>
|
||||
({
|
||||
matches:
|
||||
query === "(hover: hover)" ||
|
||||
query === "(prefers-reduced-motion: reduce)",
|
||||
}) as MediaQueryList,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("passes only mapped projects into the fallback browser", () => {
|
||||
mockMatchMedia({ reduced: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: /NutriScan/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: /Twins Lab/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
|
||||
|
||||
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
|
||||
|
||||
export type NotebookStoryProps = {
|
||||
projects: PortfolioProjectView[];
|
||||
onLayoutReady?: () => void;
|
||||
};
|
||||
|
||||
export function canUseNotebook3D(view: CapabilityWindow): boolean {
|
||||
if (typeof view.matchMedia !== "function") return false;
|
||||
return (
|
||||
view.innerWidth >= 1025 &&
|
||||
!view.matchMedia("(prefers-reduced-motion: reduce)").matches &&
|
||||
!view.matchMedia("(pointer: coarse)").matches &&
|
||||
view.matchMedia("(hover: hover)").matches
|
||||
);
|
||||
}
|
||||
|
||||
export function getNotebookRunwayVh(tabCount: number): number {
|
||||
const count = Math.max(
|
||||
1,
|
||||
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
|
||||
);
|
||||
return 220 + count * 85;
|
||||
}
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length < 1) return -1;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
export default function NotebookStory({
|
||||
projects,
|
||||
onLayoutReady,
|
||||
}: NotebookStoryProps) {
|
||||
const notebookProjects = useMemo(
|
||||
() => selectNotebookProjects(projects),
|
||||
[projects],
|
||||
);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const requestRenderRef = useRef<(() => void) | null>(null);
|
||||
const seekRef = useRef<((progress: number) => void) | null>(null);
|
||||
const layoutReadyRef = useRef(onLayoutReady);
|
||||
const failedRef = useRef(false);
|
||||
const frameRef = useRef(getNotebookFrame(0, notebookProjects.length));
|
||||
const [capabilityResolved, setCapabilityResolved] = useState(false);
|
||||
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
|
||||
const [engine, setEngine] = useState(false);
|
||||
const [sectionIntersects, setSectionIntersects] = useState(false);
|
||||
const [isPageVisible, setIsPageVisible] = useState(
|
||||
() => typeof document === "undefined" || !document.hidden,
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
|
||||
useEffect(() => {
|
||||
layoutReadyRef.current = onLayoutReady;
|
||||
}, [onLayoutReady]);
|
||||
useEffect(() => {
|
||||
if (!engine && capabilityResolved) layoutReadyRef.current?.();
|
||||
}, [engine, capabilityResolved]);
|
||||
useEffect(() => {
|
||||
const updateEngine = () => {
|
||||
const nextEngine =
|
||||
!failedRef.current &&
|
||||
notebookProjects.length > 0 &&
|
||||
canUseNotebook3D(window);
|
||||
if (!nextEngine) setSectionIntersects(false);
|
||||
setEngine(nextEngine);
|
||||
setCapabilityResolved(true);
|
||||
};
|
||||
updateEngine();
|
||||
if (typeof window.matchMedia !== "function") return undefined;
|
||||
|
||||
const mediaQueries = [
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)"),
|
||||
window.matchMedia("(pointer: coarse)"),
|
||||
window.matchMedia("(hover: hover)"),
|
||||
];
|
||||
window.addEventListener("resize", updateEngine, { passive: true });
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
mediaQuery.addEventListener("change", updateEngine);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateEngine);
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
mediaQuery.removeEventListener("change", updateEngine);
|
||||
});
|
||||
};
|
||||
}, [notebookProjects.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setIsPageVisible(!document.hidden);
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!engine || !section) return undefined;
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
setSectionIntersects(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setSectionIntersects(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
observer.observe(section);
|
||||
return () => observer.disconnect();
|
||||
}, [engine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!engine) return undefined;
|
||||
const sectionElement = sectionRef.current;
|
||||
if (!sectionElement) return undefined;
|
||||
const sectionRoot: HTMLElement = sectionElement;
|
||||
|
||||
let cancelled = false;
|
||||
let cleanup = () => {};
|
||||
|
||||
async function setupTimeline() {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
const context = gsap.context(() => {
|
||||
const driver = { value: 0 };
|
||||
const applyProgress = () => {
|
||||
progressRef.current = driver.value;
|
||||
const nextIndex = getNotebookFrame(
|
||||
driver.value,
|
||||
notebookProjects.length,
|
||||
frameRef.current,
|
||||
).activeTabIndex;
|
||||
if (nextIndex !== activeIndexRef.current) {
|
||||
activeIndexRef.current = nextIndex;
|
||||
setActiveIndex(nextIndex);
|
||||
}
|
||||
requestRenderRef.current?.();
|
||||
};
|
||||
const timeline = gsap.timeline({
|
||||
defaults: { ease: "none" },
|
||||
onUpdate: applyProgress,
|
||||
scrollTrigger: {
|
||||
trigger: sectionRoot,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: true,
|
||||
invalidateOnRefresh: true,
|
||||
onRefresh: () => layoutReadyRef.current?.(),
|
||||
},
|
||||
});
|
||||
timeline.to(driver, { duration: 1, value: 1 });
|
||||
seekRef.current = (progress) => {
|
||||
const trigger = timeline.scrollTrigger;
|
||||
if (!trigger) return;
|
||||
// Instant overrides the site's smooth CSS scrolling: no tour of intermediate tabs.
|
||||
window.scrollTo({
|
||||
top: trigger.start + (trigger.end - trigger.start) * progress,
|
||||
behavior: "instant",
|
||||
});
|
||||
ScrollTrigger.update();
|
||||
timeline.progress(progress);
|
||||
applyProgress();
|
||||
};
|
||||
timeline.scrollTrigger?.refresh();
|
||||
}, sectionRoot);
|
||||
cleanup = () => {
|
||||
seekRef.current = null;
|
||||
context.revert();
|
||||
};
|
||||
}
|
||||
|
||||
void setupTimeline().catch(() => {
|
||||
if (!cancelled) {
|
||||
failedRef.current = true;
|
||||
setEngine(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [engine, notebookProjects.length]);
|
||||
|
||||
const handleStageError = useCallback(() => {
|
||||
failedRef.current = true;
|
||||
setEngine(false);
|
||||
}, []);
|
||||
|
||||
const handleSelectTab = useCallback(
|
||||
(index: number) => {
|
||||
const safeIndex = clampIndex(index, notebookProjects.length);
|
||||
if (safeIndex < 0) return;
|
||||
activeIndexRef.current = safeIndex;
|
||||
setActiveIndex(safeIndex);
|
||||
if (engine) {
|
||||
seekRef.current?.(
|
||||
getNotebookSeekProgress(safeIndex, notebookProjects.length),
|
||||
);
|
||||
}
|
||||
},
|
||||
[engine, notebookProjects.length],
|
||||
);
|
||||
|
||||
if (!engine) {
|
||||
return (
|
||||
<section
|
||||
aria-label="Projetos em produção no notebook"
|
||||
className={`notebook-story notebook-story-static${!capabilityResolved && notebookProjects.length > 0 ? " notebook-story-pending" : ""}`}
|
||||
data-testid="notebook-story"
|
||||
id="story"
|
||||
ref={sectionRef}
|
||||
style={
|
||||
{
|
||||
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="notebook-story-static-content">
|
||||
<div className="notebook-story-copy">
|
||||
<span className="notebook-story-kicker">O notebook</span>
|
||||
<h2>Software que opera.</h2>
|
||||
<p>
|
||||
Uma seleção navegável de sistemas reais, com suas interfaces e
|
||||
tecnologias em produção.
|
||||
</p>
|
||||
</div>
|
||||
<NotebookFallback
|
||||
initialIndex={activeIndex}
|
||||
onSelectTab={handleSelectTab}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Projetos em produção no notebook"
|
||||
className="notebook-story notebook-story-runway"
|
||||
data-notebook-engine="3d"
|
||||
data-notebook-runway-vh={getNotebookRunwayVh(notebookProjects.length)}
|
||||
data-testid="notebook-story"
|
||||
id="story"
|
||||
ref={sectionRef}
|
||||
style={
|
||||
{
|
||||
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="notebook-story-sticky">
|
||||
<div className="notebook-story-grid">
|
||||
<div className="notebook-story-copy">
|
||||
<span className="notebook-story-kicker">O notebook</span>
|
||||
<h2>Software que opera.</h2>
|
||||
<p>
|
||||
Role para abrir a engenharia por trás de cada entrega. O scroll é
|
||||
o controle de play e rebobinar.
|
||||
</p>
|
||||
<span className="notebook-story-count" aria-live="polite">
|
||||
{String(Math.max(activeIndex + 1, 1)).padStart(2, "0")} /{" "}
|
||||
{String(notebookProjects.length).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<NotebookStage3D
|
||||
activeIndex={activeIndex}
|
||||
isVisible={sectionIntersects && isPageVisible}
|
||||
onSelectTab={handleSelectTab}
|
||||
onStageError={handleStageError}
|
||||
progress={progressRef}
|
||||
requestRender={requestRenderRef}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import {
|
||||
getFeaturedProjects,
|
||||
type PortfolioProjectView,
|
||||
} from "@/lib/portfolio-data";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projectsFixture = getFeaturedProjects(null);
|
||||
|
||||
describe("selectNotebookProjects", () => {
|
||||
it("returns featured projects in notebook order", () => {
|
||||
const result = selectNotebookProjects(projectsFixture);
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual([
|
||||
"nutriscan",
|
||||
"sc-plus",
|
||||
"sev-modaintima",
|
||||
"as-servicos",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits a project when its screenshot mapping is missing", () => {
|
||||
const result = selectNotebookProjects([
|
||||
...projectsFixture,
|
||||
{ ...projectsFixture[0], id: "unknown-live-project" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
result.some((project) => project.id === "unknown-live-project"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("omits a project with an unapproved demo URL", () => {
|
||||
const projectsWithUnsafeUrl = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan"
|
||||
? { ...project, demoUrl: "javascript:alert(1)" }
|
||||
: project,
|
||||
);
|
||||
const result = selectNotebookProjects(projectsWithUnsafeUrl);
|
||||
|
||||
expect(result.some((project) => project.id === "nutriscan")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a project without a demo so its local evidence remains visible", () => {
|
||||
const projectsWithoutDemo = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan"
|
||||
? {
|
||||
...project,
|
||||
demoUrl: null,
|
||||
demoAvailability: "none" as const,
|
||||
demoCheckedAt: null,
|
||||
}
|
||||
: project,
|
||||
) as unknown as PortfolioProjectView[];
|
||||
|
||||
expect(
|
||||
selectNotebookProjects(projectsWithoutDemo).some(
|
||||
(project) => project.id === "nutriscan",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an unavailable project when its local evidence and URL exist", () => {
|
||||
const projectsWithUnavailableDemo = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan"
|
||||
? {
|
||||
...project,
|
||||
demoAvailability: "unavailable" as const,
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
}
|
||||
: project,
|
||||
);
|
||||
|
||||
expect(
|
||||
selectNotebookProjects(projectsWithUnavailableDemo).find(
|
||||
(project) => project.id === "nutriscan",
|
||||
),
|
||||
).toMatchObject({
|
||||
demoAvailability: "unavailable",
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
|
||||
export type NotebookProject = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
demoUrl: PortfolioProjectView["demoUrl"];
|
||||
demoAvailability: PortfolioProjectView["demoAvailability"];
|
||||
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
|
||||
technologies: string[];
|
||||
screenshotSrc: string;
|
||||
screenshotWidth: number;
|
||||
screenshotHeight: number;
|
||||
};
|
||||
|
||||
const NOTEBOOK_ORDER = [
|
||||
"nutriscan",
|
||||
"sc-plus",
|
||||
"sev-modaintima",
|
||||
"as-servicos",
|
||||
] as const;
|
||||
|
||||
const SCREENSHOT_BY_PROJECT_ID: Record<
|
||||
(typeof NOTEBOOK_ORDER)[number],
|
||||
string
|
||||
> = {
|
||||
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 isApprovedDemoUrl(
|
||||
value: PortfolioProjectView["demoUrl"],
|
||||
): value is string {
|
||||
return typeof value === "string" && /^https?:\/\//.test(value);
|
||||
}
|
||||
|
||||
export function selectNotebookProjects(
|
||||
projects: readonly PortfolioProjectView[],
|
||||
): NotebookProject[] {
|
||||
return NOTEBOOK_ORDER.flatMap((id) => {
|
||||
const project = projects.find((candidate) => candidate.id === id);
|
||||
const screenshotSrc = SCREENSHOT_BY_PROJECT_ID[id];
|
||||
|
||||
if (
|
||||
!project ||
|
||||
(project.demoUrl !== null && !isApprovedDemoUrl(project.demoUrl))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
subtitle: project.subtitle,
|
||||
demoUrl: project.demoUrl,
|
||||
demoAvailability: project.demoAvailability,
|
||||
demoCheckedAt: project.demoCheckedAt,
|
||||
technologies: [...project.technologies],
|
||||
screenshotSrc,
|
||||
screenshotWidth: 1600,
|
||||
screenshotHeight: 1000,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
|
||||
|
||||
describe("notebook choreography", () => {
|
||||
it("opens and boots before browsing, then blanks the display before closing", () => {
|
||||
const frames = Array.from({ length: 1001 }, (_, index) =>
|
||||
getNotebookFrame(index / 1000, 4),
|
||||
);
|
||||
const phases = [...new Set(frames.map((frame) => frame.phase))];
|
||||
expect(phases).toEqual([
|
||||
"approach",
|
||||
"open",
|
||||
"boot",
|
||||
"cursor-in",
|
||||
"browse",
|
||||
"hold",
|
||||
"close",
|
||||
]);
|
||||
for (const frame of frames) {
|
||||
if (frame.phase === "open") expect(frame.screenOpacity).toBe(0);
|
||||
if (frame.cursorVisible) expect(frame.screenOpacity).toBe(1);
|
||||
if (frame.phase === "close" && frame.lidAngle < 112)
|
||||
expect(frame.screenOpacity).toBe(0);
|
||||
}
|
||||
expect(getNotebookFrame(-1, 4).lidAngle).toBe(0);
|
||||
expect(getNotebookFrame(2, 4).lidAngle).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the previous project until cursor arrival, including reverse scroll", () => {
|
||||
const frames = Array.from({ length: 10001 }, (_, index) =>
|
||||
getNotebookFrame(index / 10000, 4),
|
||||
);
|
||||
for (let selected = 1; selected < 4; selected += 1) {
|
||||
const arrival = frames.findIndex(
|
||||
(frame) => frame.activeTabIndex === selected,
|
||||
);
|
||||
const before = getNotebookFrame((arrival - 1) / 10000, 4);
|
||||
const atClick = frames[arrival];
|
||||
expect(before.activeTabIndex).toBe(selected - 1);
|
||||
expect(before.cursorClicking).toBe(false);
|
||||
expect(atClick.cursorClicking).toBe(true);
|
||||
expect(atClick.cursorX).toBeCloseTo((selected + 0.5) / 4);
|
||||
const reversed = getNotebookFrame((arrival - 2) / 10000, 4);
|
||||
expect(reversed.activeTabIndex).toBe(selected - 1);
|
||||
expect(reversed.cursorX).toBeLessThan(atClick.cursorX);
|
||||
expect(reversed.cursorX).toBeGreaterThan((selected - 0.5) / 4);
|
||||
}
|
||||
});
|
||||
|
||||
it("seeks directly into a readable hold for the requested project", () => {
|
||||
for (const count of [1, 4]) {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const progress = getNotebookSeekProgress(index, count);
|
||||
const frame = getNotebookFrame(progress, count);
|
||||
expect(frame.activeTabIndex).toBe(index);
|
||||
expect(frame.screenOpacity).toBe(1);
|
||||
expect(frame.cursorClicking).toBe(false);
|
||||
expect(getNotebookFrame(progress + 0.03, count).activeTabIndex).toBe(
|
||||
index,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
getNotebookFrame(getNotebookSeekProgress(99, 4), 4).activeTabIndex,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("does not expose a tab or cursor for empty or invalid project counts", () => {
|
||||
expect(getNotebookFrame(0.6, 0)).toMatchObject({
|
||||
activeTabIndex: -1,
|
||||
cursorVisible: false,
|
||||
cursorClicking: false,
|
||||
});
|
||||
expect(getNotebookFrame(0.6, Number.NaN).activeTabIndex).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
export type NotebookPhase =
|
||||
| "approach"
|
||||
| "open"
|
||||
| "boot"
|
||||
| "cursor-in"
|
||||
| "browse"
|
||||
| "hold"
|
||||
| "close";
|
||||
|
||||
export type NotebookFrame = {
|
||||
phase: NotebookPhase;
|
||||
lidAngle: number;
|
||||
deviceOpacity: number;
|
||||
screenOpacity: number;
|
||||
activeTabIndex: number;
|
||||
cursorVisible: boolean;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
cursorClicking: boolean;
|
||||
};
|
||||
|
||||
const OPEN_START = 0.06;
|
||||
const BOOT_START = 0.18;
|
||||
const CURSOR_START = 0.23;
|
||||
const BROWSE_START = 0.3;
|
||||
const BROWSE_END = 0.84;
|
||||
const CLOSE_START = 0.9;
|
||||
const CLOSE_LID_START = 0.93;
|
||||
const OPEN_ANGLE = 112;
|
||||
const ARRIVAL = 0.18;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function between(value: number, start: number, end: number): number {
|
||||
return clamp((value - start) / (end - start), 0, 1);
|
||||
}
|
||||
|
||||
export function getNotebookSeekProgress(
|
||||
index: number,
|
||||
tabCount: number,
|
||||
): number {
|
||||
const count = Number.isFinite(tabCount)
|
||||
? Math.max(0, Math.floor(tabCount))
|
||||
: 0;
|
||||
if (!count) return BROWSE_START;
|
||||
const selected = clamp(
|
||||
Number.isFinite(index) ? Math.floor(index) : 0,
|
||||
0,
|
||||
count - 1,
|
||||
);
|
||||
return (
|
||||
BROWSE_START + ((BROWSE_END - BROWSE_START) * (selected + 0.6)) / count
|
||||
);
|
||||
}
|
||||
|
||||
/** One reversible clock. Each tab arrives/clicks before its long reading hold. */
|
||||
export function getNotebookFrame(
|
||||
progress: number,
|
||||
tabCount: number,
|
||||
target?: NotebookFrame,
|
||||
): NotebookFrame {
|
||||
const p = clamp(Number.isFinite(progress) ? progress : 0, 0, 1);
|
||||
const count = Number.isFinite(tabCount)
|
||||
? Math.max(0, Math.floor(tabCount))
|
||||
: 0;
|
||||
const phase: NotebookPhase =
|
||||
p < OPEN_START
|
||||
? "approach"
|
||||
: p < BOOT_START
|
||||
? "open"
|
||||
: p < CURSOR_START
|
||||
? "boot"
|
||||
: p < BROWSE_START
|
||||
? "cursor-in"
|
||||
: p < BROWSE_END
|
||||
? "browse"
|
||||
: p < CLOSE_START
|
||||
? "hold"
|
||||
: "close";
|
||||
const slot = between(p, BROWSE_START, BROWSE_END) * count;
|
||||
const destination = clamp(Math.floor(slot), 0, Math.max(0, count - 1));
|
||||
const local = slot - destination;
|
||||
const moving = phase === "browse" && destination > 0 && local < ARRIVAL;
|
||||
const activeTabIndex = !count ? -1 : moving ? destination - 1 : destination;
|
||||
const travel = between(local, 0, ARRIVAL);
|
||||
const cursorTab = moving
|
||||
? destination - 1 + travel * travel * (3 - 2 * travel)
|
||||
: destination;
|
||||
const entry = between(p, CURSOR_START, BROWSE_START);
|
||||
const frame = target ?? ({} as NotebookFrame);
|
||||
frame.phase = phase;
|
||||
frame.lidAngle =
|
||||
OPEN_ANGLE *
|
||||
between(p, OPEN_START, BOOT_START) *
|
||||
(1 - between(p, CLOSE_LID_START, 1));
|
||||
frame.deviceOpacity = between(p, 0, OPEN_START);
|
||||
frame.screenOpacity =
|
||||
between(p, BOOT_START, CURSOR_START) *
|
||||
(1 - between(p, CLOSE_START, CLOSE_LID_START));
|
||||
frame.activeTabIndex = activeTabIndex;
|
||||
frame.cursorVisible = count > 0 && p >= CURSOR_START && p < CLOSE_START;
|
||||
frame.cursorX = count
|
||||
? phase === "cursor-in"
|
||||
? 0.5 + (0.5 / count - 0.5) * entry
|
||||
: (cursorTab + 0.5) / count
|
||||
: 0.5;
|
||||
frame.cursorY = phase === "cursor-in" ? 0.95 - 0.65 * entry : 0.3;
|
||||
frame.cursorClicking =
|
||||
count > 0 &&
|
||||
phase === "browse" &&
|
||||
(destination === 0
|
||||
? local < 0.07
|
||||
: local >= ARRIVAL && local < ARRIVAL + 0.07);
|
||||
return frame;
|
||||
}
|
||||
@@ -1,30 +1,6 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { metadata as rootMetadata } from "../layout";
|
||||
import CurriculoPage from "./page";
|
||||
|
||||
jest.mock("next/font/google", () => ({
|
||||
Geist_Mono: () => ({ variable: "font-geist-mono" }),
|
||||
Space_Grotesk: () => ({ variable: "font-space-grotesk" }),
|
||||
}));
|
||||
|
||||
jest.mock("next/font/local", () => () => ({
|
||||
variable: "font-monument-extended",
|
||||
}));
|
||||
|
||||
const authorizedRegion =
|
||||
"Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.";
|
||||
|
||||
it("uses one factual regional description across home metadata", () => {
|
||||
const description = rootMetadata.description;
|
||||
expect(typeof description).toBe("string");
|
||||
expect(description).toContain(authorizedRegion);
|
||||
expect(description).not.toMatch(
|
||||
/Joinville|São Francisco do Sul|Itapoá|Jaraguá do Sul|Curitiba/,
|
||||
);
|
||||
expect(rootMetadata.openGraph).toMatchObject({ description });
|
||||
expect(rootMetadata.twitter).toMatchObject({ description });
|
||||
});
|
||||
|
||||
it("shows a matching breadcrumb and keeps consulting secondary to hiring", () => {
|
||||
render(CurriculoPage());
|
||||
|
||||
|
||||
@@ -8,11 +8,12 @@ import { siteName, siteUrl } from "@/lib/site-content";
|
||||
const pageUrl = `${siteUrl}/curriculo`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Currículo de Evandro Fonseca Junior para Contratação",
|
||||
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.",
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title"><title>Evandro Fonseca Junior</title><rect width="64" height="64" fill="#0a0a0a"/><path fill="#ccff00" d="M12 12h40v8H21v8h25v8H21v8h31v8H12z"/><path fill="#f4f2ed" d="M22 24h22v8H22z"/></svg>
|
||||
|
Before Width: | Height: | Size: 288 B |
@@ -10,9 +10,9 @@ import GoogleAnalytics from "./components/GoogleAnalytics";
|
||||
import { Header } from "./components/Header";
|
||||
import "./globals.css";
|
||||
|
||||
const defaultTitle = "Evandro Fonseca Junior — Engenharia de Software";
|
||||
const defaultTitle = "Sistemas sob medida para empresas | Evandro.dev.br";
|
||||
const seoDescription =
|
||||
"Evandro Fonseca Junior é engenheiro da computação e desenvolvedor de software com atuação em Next.js, TypeScript, Rust, IoT e integrações. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.";
|
||||
"Sistemas web, aplicativos e integrações sob medida, do negócio à produção, com Evandro Fonseca Junior. Base em Santa Catarina, atendimento remoto.";
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
@@ -77,7 +77,7 @@ 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -89,7 +89,7 @@ export const metadata: Metadata = {
|
||||
images: [
|
||||
{
|
||||
url: "/opengraph-image",
|
||||
alt: "Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software",
|
||||
alt: "Evandro.dev.br | Sistemas sob medida para empresas",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -128,6 +128,28 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
lang="pt-BR"
|
||||
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
||||
>
|
||||
<head>
|
||||
{/* 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>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<a className="skip-link" href="#main-content">
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { shouldLoadHeroScene } from "./should-load-hero-scene";
|
||||
|
||||
describe("shouldLoadHeroScene", () => {
|
||||
it("keeps WebGL off for reduced motion, coarse pointers, and narrow screens", () => {
|
||||
expect(
|
||||
shouldLoadHeroScene({
|
||||
reducedMotion: true,
|
||||
coarsePointer: false,
|
||||
width: 1440,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldLoadHeroScene({
|
||||
reducedMotion: false,
|
||||
coarsePointer: true,
|
||||
width: 1440,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldLoadHeroScene({
|
||||
reducedMotion: false,
|
||||
coarsePointer: false,
|
||||
width: 767,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows WebGL only on capable desktop screens", () => {
|
||||
expect(
|
||||
shouldLoadHeroScene({
|
||||
reducedMotion: false,
|
||||
coarsePointer: false,
|
||||
width: 1024,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
export function shouldLoadHeroScene({
|
||||
reducedMotion,
|
||||
coarsePointer,
|
||||
width,
|
||||
}: {
|
||||
reducedMotion: boolean;
|
||||
coarsePointer: boolean;
|
||||
width: number;
|
||||
}) {
|
||||
return !reducedMotion && !coarsePointer && width >= 1024;
|
||||
}
|
||||
@@ -2,28 +2,28 @@ import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Evandro Fonseca Junior — Engenheiro da Computação e Desenvolvedor de Software",
|
||||
name: "Evandro.dev.br — Sistemas sob medida para empresas",
|
||||
short_name: "Evandro.dev",
|
||||
description:
|
||||
"Portfólio profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, Joinville, Norte de Santa Catarina e Curitiba.",
|
||||
"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", "engineering", "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,17 +1,16 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||
|
||||
export const alt =
|
||||
"Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
|
||||
export const alt = "Evandro.dev.br — Sistemas sob medida para empresas";
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = OG_CONTENT_TYPE;
|
||||
|
||||
export default async function Image() {
|
||||
return new ImageResponse(
|
||||
<OgCard
|
||||
eyebrow="Portfólio de engenharia de software"
|
||||
title="Evandro Fonseca Junior"
|
||||
description="Engenheiro da Computação e Desenvolvedor de Software"
|
||||
eyebrow="Engenharia de software para empresas"
|
||||
title="Sistemas sob medida."
|
||||
description="Da ideia à produção. Sistemas web, aplicativos e integrações com Evandro Fonseca Junior."
|
||||
/>,
|
||||
OG_SIZE,
|
||||
);
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
|
||||
import CinematicHome from "./components/cinematic/CinematicHome";
|
||||
|
||||
/** Anos de experiência vêm do próprio texto do portfólio ("5 anos de experiência"); sem o trecho, o número some. */
|
||||
function yearsOfExperience() {
|
||||
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const projects = await loadFeaturedProjects();
|
||||
return (
|
||||
<CinematicHome
|
||||
projects={await loadFeaturedProjects()}
|
||||
heroStats={{
|
||||
years: yearsOfExperience(),
|
||||
projects: projects.length,
|
||||
demos: projects.filter(
|
||||
(project) =>
|
||||
project.demoUrl && project.demoAvailability !== "unavailable",
|
||||
).length,
|
||||
}}
|
||||
projects={projects}
|
||||
experiences={portfolio.experiences}
|
||||
skillCategories={portfolio.skills.technical}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import PrivacyPage, { metadata } from "./page";
|
||||
|
||||
it("publishes factual privacy content and a breadcrumb-only JSON-LD graph", () => {
|
||||
it("preserves breadcrumb navigation and a breadcrumb-only JSON-LD graph", () => {
|
||||
const { container } = render(<PrivacyPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: /privacidade/i }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(/evandro\.analytics-consent\.v1/)).toBeVisible();
|
||||
expect(screen.getAllByText(/WhatsApp/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/nenhuma tag do Google é carregada/i)).toBeVisible();
|
||||
expect(screen.getByText(/2026-09-04/)).toBeVisible();
|
||||
|
||||
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
|
||||
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
|
||||
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
|
||||
|
||||
@@ -12,6 +12,7 @@ export const metadata: Metadata = {
|
||||
"Como o portfólio trata dados de contato, consentimento de analytics, links externos e solicitações de privacidade.",
|
||||
alternates: { canonical: pageUrl },
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: `Privacidade e consentimento | ${siteName}`,
|
||||
description:
|
||||
"Informações factuais sobre contato, analytics e preferências de privacidade no portfólio.",
|
||||
@@ -107,10 +108,26 @@ export default function PrivacyPage() {
|
||||
<code>granted</code> ou <code>denied</code>.
|
||||
</p>
|
||||
<p className="ds-copy mt-sp-4">
|
||||
A publicidade permanece negada. Ao conceder analytics, o Google
|
||||
pode criar cookies <code>_ga*</code>. Ao revogar, novas coletas
|
||||
são desativadas e os cookies de analytics acessíveis são
|
||||
expirados.
|
||||
Cookies de publicidade e personalização permanecem negados. Ao
|
||||
conceder analytics, o Google pode criar cookies{" "}
|
||||
<code>_ga*</code>. Ao revogar, o consentimento é atualizado para
|
||||
negado, o loader é removido e os cookies de analytics acessíveis
|
||||
são expirados.
|
||||
</p>
|
||||
<p className="ds-copy mt-sp-4">
|
||||
A tag compartilhada conecta Google Analytics e Google Ads. O
|
||||
modo de consentimento do Google pode enviar sinais técnicos sem
|
||||
cookies de publicidade após o aceite de analytics, com redação
|
||||
dos dados de anúncios ativada. Não enviamos eventos de venda nem
|
||||
o conteúdo do formulário para esses serviços.
|
||||
</p>
|
||||
<p className="ds-copy mt-sp-4">
|
||||
Após o aceite de analytics, medimos o acionamento do WhatsApp
|
||||
para entender quais serviços e botões geram interesse. Esse
|
||||
evento registra o serviço, a posição do botão e o caminho da
|
||||
página, sem nome, e-mail, mensagem ou parâmetros da URL. O
|
||||
acionamento não confirma envio de mensagem nem contratação. O
|
||||
contato pelo WhatsApp funciona mesmo sem aceitar analytics.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
@@ -141,7 +158,7 @@ export default function PrivacyPage() {
|
||||
<p className="ds-kicker">Vigência</p>
|
||||
<p className="ds-copy mt-sp-2">
|
||||
Esta versão está vigente desde{" "}
|
||||
<time dateTime="2026-09-04">2026-09-04</time>.
|
||||
<time dateTime="2026-09-13">2026-09-13</time>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,12 +104,13 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
|
||||
expect(screen.getByText(detail)).not.toBeVisible();
|
||||
}
|
||||
|
||||
const primaryCtas = screen.getAllByRole("link", {
|
||||
name: service.ctaLabel,
|
||||
});
|
||||
expect(primaryCtas).toHaveLength(2);
|
||||
const primaryCtas = screen.getAllByRole("link", { name: /WhatsApp/i });
|
||||
for (const cta of primaryCtas) {
|
||||
expect(cta).toHaveAttribute("href", "/#contact");
|
||||
const destination = new URL(cta.getAttribute("href") ?? "");
|
||||
expect(destination.origin).toBe("https://wa.me");
|
||||
expect(destination.searchParams.get("text")).toContain(service.navLabel);
|
||||
expect(cta).toHaveAttribute("target", "_blank");
|
||||
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
||||
}
|
||||
|
||||
const expectedCaseLinks = service.relatedCaseIds
|
||||
@@ -209,18 +210,6 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
|
||||
it("keeps hub cards and structured items aligned", () => {
|
||||
const { container } = render(<ServicesIndexPage />);
|
||||
|
||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||
name: "Breadcrumb",
|
||||
});
|
||||
expect(
|
||||
within(visibleBreadcrumb)
|
||||
.getAllByRole("link")
|
||||
.map((link) => [link.textContent, link.getAttribute("href")]),
|
||||
).toEqual([
|
||||
["Início", "/"],
|
||||
["Serviços", "/servicos"],
|
||||
]);
|
||||
|
||||
const graph = readJsonLdEntities(container);
|
||||
expect(graph.map((node) => node["@type"])).toEqual([
|
||||
"CollectionPage",
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { buildBreadcrumbList, buildServiceJsonLd } from "@/lib/seo";
|
||||
import {
|
||||
@@ -44,6 +45,7 @@ export async function generateMetadata({
|
||||
canonical: url,
|
||||
},
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: service.seoTitle,
|
||||
description: service.metaDescription,
|
||||
url,
|
||||
@@ -122,14 +124,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
{service.answer}
|
||||
</p>
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={service.ctaHref}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
<WhatsAppLink
|
||||
service={service.navLabel}
|
||||
placement="service_detail_hero"
|
||||
className="ds-link ds-link-accent min-h-12"
|
||||
>
|
||||
{service.ctaLabel}
|
||||
</MagneticButton>
|
||||
{service.ctaLabel} no WhatsApp
|
||||
</WhatsAppLink>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/cases"
|
||||
@@ -139,6 +140,11 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
Ver casos relacionados
|
||||
</MagneticButton>
|
||||
</div>
|
||||
<p className="mt-sp-4 text-sm leading-6 text-white/70">
|
||||
Fale diretamente com Evandro. Abre uma conversa, sem precisar
|
||||
preencher um formulário. Atendimento remoto a partir de Garuva,
|
||||
SC.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -305,14 +311,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
remotamente.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={service.ctaHref}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
<WhatsAppLink
|
||||
service={service.navLabel}
|
||||
placement="service_detail_footer"
|
||||
className="ds-link ds-link-accent min-h-12"
|
||||
>
|
||||
{service.ctaLabel}
|
||||
</MagneticButton>
|
||||
{service.ctaLabel} no WhatsApp
|
||||
</WhatsAppLink>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/servicos"
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { buildBreadcrumbList } from "@/lib/seo";
|
||||
import { serviceHubLinks, siteName, siteUrl } from "@/lib/site-content";
|
||||
import {
|
||||
caseStudies,
|
||||
serviceHubLinks,
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
|
||||
const pageUrl = `${siteUrl}/servicos`;
|
||||
const pageTitle = "Sites, Sistemas Web e Aplicativos Mobile e Desktop";
|
||||
const pageTitle = "Sites, Sistemas e Aplicativos sob Medida";
|
||||
const pageDescription =
|
||||
"Desenvolvimento de sites, sistemas web, aplicativos mobile e desktop para Windows e Linux. Integrações, melhorias e consultoria para o seu negócio.";
|
||||
"Sites, sistemas web e aplicativos sob medida com Evandro, desenvolvedor em Garuva-SC e atendimento remoto no Brasil. Conheça projetos e converse pelo WhatsApp.";
|
||||
|
||||
const businessIntents = [
|
||||
{
|
||||
title: "Quero um site para meu negócio",
|
||||
description:
|
||||
"Apresente seus serviços, produtos e diferenciais em um endereço próprio, com leitura no celular e um caminho claro para o cliente entrar em contato.",
|
||||
href: "/servicos/criacao-de-sites",
|
||||
linkLabel: "Conhecer a criação de sites",
|
||||
service: "Sites profissionais",
|
||||
ctaLabel: "Conversar sobre meu site",
|
||||
},
|
||||
{
|
||||
title: "Preciso organizar minha operação",
|
||||
description:
|
||||
"Um sistema web para acompanhar pedidos, atendimentos ou informações hoje espalhadas em planilhas. Também posso conectar ferramentas e melhorar o software que você já usa.",
|
||||
href: "/servicos/engenharia-de-software",
|
||||
linkLabel: "Conhecer o desenvolvimento de sistemas",
|
||||
service: "Sistemas web",
|
||||
ctaLabel: "Conversar sobre meu sistema",
|
||||
},
|
||||
{
|
||||
title: "Quero desenvolver um aplicativo",
|
||||
description:
|
||||
"Leve um fluxo de trabalho para o celular ou computador. Avaliamos quem vai usar, os recursos necessários e a plataforma antes de definir o desenvolvimento e a distribuição.",
|
||||
href: "/servicos/engenharia-de-software",
|
||||
linkLabel: "Entender o escopo de um app mobile",
|
||||
service: "Aplicativos mobile e desktop",
|
||||
ctaLabel: "Conversar sobre meu aplicativo",
|
||||
},
|
||||
];
|
||||
|
||||
const featuredCases = caseStudies.filter((caseStudy) =>
|
||||
["sc-plus", "nutriscan"].includes(caseStudy.id),
|
||||
);
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: pageTitle,
|
||||
@@ -17,6 +56,7 @@ export const metadata: Metadata = {
|
||||
canonical: pageUrl,
|
||||
},
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
url: pageUrl,
|
||||
@@ -56,89 +96,234 @@ export default function ServicesIndexPage() {
|
||||
<main>
|
||||
<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="/servicos"
|
||||
>
|
||||
Serviços
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<p className="ds-eyebrow">Serviços de desenvolvimento</p>
|
||||
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
|
||||
Da presença na internet ao sistema que move seu negócio.
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
||||
Sites e sistemas web, aplicativos mobile e aplicativos desktop
|
||||
para Windows e Linux. Também integro ferramentas e melhoro
|
||||
software que já está em uso. Começamos pelo que você precisa
|
||||
resolver, não pela tecnologia.
|
||||
<section className="ds-section ds-section-dark">
|
||||
<div className="ds-section-shell">
|
||||
<nav aria-label="Breadcrumb" className="mb-sp-6">
|
||||
<ol className="flex flex-wrap items-center gap-sp-3 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
className="inline-flex min-h-11 items-center underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
|
||||
href="/"
|
||||
>
|
||||
Início
|
||||
</Link>
|
||||
</li>
|
||||
<li className="flex items-center gap-sp-3">
|
||||
<span aria-hidden="true">/</span>
|
||||
<span aria-current="page">Serviços</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 className="ds-section-title max-w-[22ch] text-balance">
|
||||
Sites, sistemas e aplicativos sob medida.
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[62ch]">
|
||||
Apresente seu negócio, organize a operação ou conecte ferramentas
|
||||
que hoje dão trabalho. Desenvolvo a solução a partir do que você
|
||||
precisa resolver, não de uma lista de tecnologias.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-4">
|
||||
<WhatsAppLink
|
||||
className="ds-link ds-link-accent min-h-12"
|
||||
service="Sites, sistemas e aplicativos"
|
||||
placement="service_hub_hero"
|
||||
>
|
||||
Falar com Evandro no WhatsApp
|
||||
</WhatsAppLink>
|
||||
<a
|
||||
href="#projetos"
|
||||
className="ds-link ds-link-muted min-h-12 focus-visible:outline-2 focus-visible:outline-offset-4"
|
||||
>
|
||||
Ver projetos realizados
|
||||
</a>
|
||||
</div>
|
||||
<p className="ds-copy max-w-[62ch]">
|
||||
Sou Evandro, desenvolvedor em Garuva, Santa Catarina. Atendimento
|
||||
remoto para todo o Brasil, com conversa direta comigo.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section" aria-labelledby="necessidade-title">
|
||||
<div className="ds-section-shell grid items-start gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-sp-16">
|
||||
<div>
|
||||
<h2 id="necessidade-title" className="ds-section-title">
|
||||
O que você precisa fazer?
|
||||
</h2>
|
||||
<p className="ds-copy">
|
||||
Não precisa chegar com um projeto técnico pronto. Começamos pela
|
||||
sua rotina e pelo que precisa mudar.
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{businessIntents.map((intent) => (
|
||||
<article
|
||||
key={intent.service}
|
||||
className="py-sp-6 first:pt-0 last:pb-0"
|
||||
>
|
||||
<h3 className="ds-card-title">{intent.title}</h3>
|
||||
<p className="ds-copy">{intent.description}</p>
|
||||
<div className="mt-sp-4 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-2">
|
||||
<WhatsAppLink
|
||||
className="ds-link ds-link-accent min-h-11"
|
||||
service={intent.service}
|
||||
placement="service_hub_intent"
|
||||
>
|
||||
{intent.ctaLabel}
|
||||
</WhatsAppLink>
|
||||
<Link
|
||||
href={intent.href}
|
||||
className="inline-flex min-h-11 items-center text-sm underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
|
||||
>
|
||||
{intent.linkLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="projetos"
|
||||
className="ds-section"
|
||||
aria-labelledby="projetos-title"
|
||||
>
|
||||
<div className="ds-section-shell">
|
||||
<h2 id="projetos-title" className="ds-section-title">
|
||||
Veja o trabalho antes de conversar.
|
||||
</h2>
|
||||
<p className="ds-copy max-w-[64ch]">
|
||||
Dois projetos com demonstrações publicadas. Os cases mostram o que
|
||||
foi implementado e as tecnologias usadas, sem promessas de resultado
|
||||
comercial.
|
||||
</p>
|
||||
<div className="mt-sp-8 grid gap-sp-8 md:grid-cols-2">
|
||||
{featuredCases.map((caseStudy) => (
|
||||
<article
|
||||
key={caseStudy.id}
|
||||
className="border-t border-[var(--border)] pt-sp-6"
|
||||
>
|
||||
<p className="mb-sp-3 text-sm text-[var(--meta)]">
|
||||
Demonstração publicada
|
||||
</p>
|
||||
<h3 className="ds-card-title">{caseStudy.title}</h3>
|
||||
<p className="ds-copy">{caseStudy.summary}</p>
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className="mt-sp-4 inline-flex min-h-11 items-center text-sm underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
|
||||
>
|
||||
Ver o case {caseStudy.title}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section">
|
||||
<div className="ds-section-shell">
|
||||
<div className="max-w-[72ch]">
|
||||
<h2 className="ds-card-title !mt-0">O que você precisa fazer?</h2>
|
||||
<p className="ds-copy">
|
||||
Conheça as possibilidades abaixo. Cada serviço explica para quem
|
||||
faz sentido, exemplos de uso, escopo e etapas de trabalho. Os
|
||||
detalhes técnicos ficam disponíveis para aprofundar a conversa.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
|
||||
{serviceHubLinks.map((service, index) => (
|
||||
<article
|
||||
key={service.href}
|
||||
className="grid gap-sp-4 py-sp-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:gap-sp-8"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="ds-kicker" aria-hidden="true">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</p>
|
||||
<h3 className="ds-card-title">
|
||||
<Link
|
||||
href={service.href}
|
||||
className="underline decoration-[var(--slash)] decoration-2 underline-offset-4 hover:decoration-[var(--ink)]"
|
||||
>
|
||||
{service.label}
|
||||
</Link>
|
||||
</h3>
|
||||
</div>
|
||||
<p className="ds-copy !mt-0">{service.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-sp-8 max-w-[72ch]">
|
||||
<h2 className="ds-card-title">
|
||||
Ainda não sabe qual caminho seguir?
|
||||
<div className="ds-section-shell grid gap-sp-10 lg:grid-cols-2 lg:gap-sp-16">
|
||||
<div>
|
||||
<h2 className="ds-section-title">
|
||||
Como saímos da conversa para a entrega.
|
||||
</h2>
|
||||
<p className="ds-copy">
|
||||
Conte como funciona sua rotina e o que gostaria de mudar.
|
||||
Definimos juntos a primeira entrega, as plataformas atendidas e os
|
||||
limites do projeto antes de fechar a proposta.
|
||||
</p>
|
||||
<Link className="ds-link ds-link-accent mt-sp-5" href="/#contact">
|
||||
Conversar sobre minha ideia
|
||||
</Link>
|
||||
<ol className="ds-list space-y-sp-4 list-decimal">
|
||||
<li>
|
||||
<strong>Entender o problema.</strong> Conversamos sobre o
|
||||
negócio, quem vai usar e o que já existe.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Combinar o escopo.</strong> Definimos prioridades,
|
||||
plataformas, responsabilidades, orçamento e prazo na proposta.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Construir e revisar.</strong> Você acompanha as etapas.
|
||||
Validamos a entrega e combinamos publicação, suporte e evolução.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="ds-card-title">Antes de contratar</h2>
|
||||
<dl className="mt-sp-5 space-y-sp-6 text-sm">
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Quanto custa e quanto tempo leva?
|
||||
</dt>
|
||||
<dd className="ds-copy !mt-sp-2">
|
||||
Depende das funcionalidades, conteúdo e integrações. O valor e
|
||||
o prazo são definidos depois de entender os requisitos, antes
|
||||
de iniciar o desenvolvimento.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Preciso estar perto de Garuva?
|
||||
</dt>
|
||||
<dd className="ds-copy !mt-sp-2">
|
||||
Não. O atendimento é remoto, com conversas, demonstrações e
|
||||
revisões on-line para projetos em todo o Brasil.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
E se eu já tiver um site ou sistema?
|
||||
</dt>
|
||||
<dd className="ds-copy !mt-sp-2">
|
||||
Avalio a base atual para propor melhorias, correções ou
|
||||
integrações. Recomeçar do zero não é uma condição para
|
||||
trabalhar comigo.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section" aria-labelledby="servicos-title">
|
||||
<div className="ds-section-shell">
|
||||
<h2 id="servicos-title" className="ds-section-title">
|
||||
Quer aprofundar o escopo técnico?
|
||||
</h2>
|
||||
<p className="ds-copy max-w-[64ch]">
|
||||
Consulte as áreas de atuação, incluindo desktop, integrações,
|
||||
consultoria e IoT. Cada página explica possibilidades e limites.
|
||||
</p>
|
||||
<ul className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
|
||||
{serviceHubLinks.map((service) => (
|
||||
<li
|
||||
key={service.href}
|
||||
className="grid items-start gap-sp-3 py-sp-5 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-sp-8"
|
||||
>
|
||||
<Link
|
||||
href={service.href}
|
||||
className="inline-flex min-h-11 items-center font-semibold underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
|
||||
>
|
||||
{service.label}
|
||||
</Link>
|
||||
<p className="ds-copy !mt-0">{service.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section ds-section-dark">
|
||||
<div className="ds-section-shell">
|
||||
<h2 className="ds-section-title max-w-[24ch]">
|
||||
Conte o que você quer resolver.
|
||||
</h2>
|
||||
<p className="ds-copy max-w-[62ch]">
|
||||
Pode ser uma ideia nova, uma rotina manual ou um sistema que precisa
|
||||
melhorar. Me conte pelo WhatsApp; não precisa preencher um
|
||||
formulário para começar.
|
||||
</p>
|
||||
<WhatsAppLink
|
||||
className="ds-link ds-link-accent mt-sp-6 min-h-12"
|
||||
service="Sites, sistemas e aplicativos"
|
||||
placement="service_hub_footer"
|
||||
>
|
||||
Conversar com Evandro no WhatsApp
|
||||
</WhatsAppLink>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ViewTransition } from "react";
|
||||
|
||||
/** O template remonta a cada navegação; o conteúdo leva o nome "page" e anima com page-out/page-in (globals.css). */
|
||||
export default function Template({ children }: { children: React.ReactNode }) {
|
||||
return <ViewTransition name="page">{children}</ViewTransition>;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# Prompts de logo para os projetos do portfólio
|
||||
|
||||
Um prompt por projeto listado no site (`public/portfolio-data.json`). Cada um parte do que o projeto realmente é e das cores que ele já usa nas capturas de tela do repositório (`public/assets/prints/`). Quando não existe captura pública (Twins Lab, Reddit Posts Explorer, MinGW GCC Installer), a paleta abaixo é **proposta**, não extraída.
|
||||
|
||||
## Antes de gerar
|
||||
|
||||
- **Marcas de clientes.** SEV Moda Íntima, SC Plus e AS Serviços têm identidade própria (a SEV e a AS Serviços já têm logo). Os prompts abaixo geram um **selo de portfólio** (miniatura do projeto nos cards e no favicon), não uma nova marca para o cliente. Se o cliente autorizar, use o logo oficial dele.
|
||||
- **Texto em imagem.** Geradores erram letras. Todos os prompts pedem marca sem palavras (só monograma quando indicado). O nome do projeto entra depois, como texto no site.
|
||||
- **Formato.** Gere 1:1, em 4 variações por prompt, escolha uma e vetorize (SVG). Exporte `512 px`, `192 px` e `32 px` para testar legibilidade.
|
||||
- **Onde salvar.** `public/assets/projects/<id>.svg` (e `.png`), com o `id` do projeto: `sev-modaintima`, `nutriscan`, `twins-lab`, `reddit-explorer`, `sc-plus`, `gcc-installer`, `as-servicos`.
|
||||
|
||||
## Bloco de estilo comum (cole no início de cada prompt)
|
||||
|
||||
```
|
||||
Minimal flat vector logo mark, geometric, strong silhouette, 2 to 3 flat colors, no gradients, no shadows, no 3D, no bevel, centered on a square 1:1 canvas with 12% padding, solid background, must stay recognizable at 32x32 pixels, clean even stroke weight, no text.
|
||||
```
|
||||
|
||||
## Prompt negativo comum
|
||||
|
||||
```
|
||||
text, words, letters (except the monogram when requested), photo, realistic, 3D render, drop shadow, glow, bevel, gradient mesh, watermark, mockup, busy details, thin hairlines, multiple logos, border frame, QR code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. SEV Moda Íntima (`sev-modaintima`)
|
||||
|
||||
Loja virtual de moda íntima com vitrine cinematográfica e pedido pelo WhatsApp. Identidade observada: vinho/vermelho profundo, creme, serifa elegante de alto contraste.
|
||||
|
||||
```
|
||||
[bloco de estilo comum]
|
||||
Logo mark for a premium lingerie e-commerce brand. A monogram of the letters "S" and "V" in an elegant high-contrast serif, the two letters interlocked inside a soft arch that echoes the curve of lace trim. Palette: deep wine red #9B1C31 and warm cream #F6EFE6 on a #1A0F12 near-black background. Refined, feminine, confident, not cartoonish.
|
||||
```
|
||||
|
||||
Variação clara: mesmo desenho em vinho sobre fundo creme.
|
||||
|
||||
## 2. NutriScan (`nutriscan`)
|
||||
|
||||
Consulta de produtos por nome ou código de barras, com cache JSON e Open Food Facts. Identidade observada: fundo creme, verde-floresta, serifa com itálico.
|
||||
|
||||
```
|
||||
[bloco de estilo comum]
|
||||
Logo mark for a food-data lookup app. A single leaf whose central vein is replaced by a short row of vertical barcode bars, framed by four small corner brackets like a scanner viewfinder. Palette: forest green #2E7D4F on warm cream #F7F3EA, dark ink #1F1B16 for the bars only. Friendly, trustworthy, calm, editorial.
|
||||
```
|
||||
|
||||
## 3. Twins Lab (`twins-lab`)
|
||||
|
||||
Protótipo de gêmeo digital industrial: simulador de reator e transmissão de eventos pelo servidor (SSE) a cada dois segundos. Paleta **proposta**.
|
||||
|
||||
```
|
||||
[bloco de estilo comum]
|
||||
Logo mark for an industrial digital-twin prototype. Two identical cylindrical reactor vessels side by side, the right one drawn as a clean outline (the digital twin) and the left one solid, joined by a short pulse line (a small heartbeat zigzag) between them. Palette: graphite #0F1720 background, solid vessel in off-white #E8EEF2, twin outline and pulse in electric cyan #22D3EE. Technical, precise, calm.
|
||||
```
|
||||
|
||||
## 4. Reddit Posts Explorer (`reddit-explorer`)
|
||||
|
||||
Coleta posts do r/artificial, grava no MongoDB e filtra por período, upvotes ou comentários. Paleta **proposta**; evite o mascote e o laranja exato do Reddit (marca registrada).
|
||||
|
||||
```
|
||||
[bloco de estilo comum]
|
||||
Logo mark for a tool that explores and filters social-media posts about artificial intelligence. Three overlapping rounded speech bubbles arranged like a stack, with a small filter funnel notch cut into the front bubble. Palette: deep indigo #2B2A6E and off-white #F4F2ED, one warm accent #FF7A45 on the funnel only. Do not use any alien or mascot character and do not imitate the Reddit logo. Smart, clear, neutral.
|
||||
```
|
||||
|
||||
## 5. SC Plus (`sc-plus`)
|
||||
|
||||
Site institucional de educação, ciência e tecnologia em Garuva-SC (identidade observada: fundo escuro, verde vivo, sans geométrica).
|
||||
|
||||
```
|
||||
[bloco de estilo comum]
|
||||
Logo mark for a community education, science and technology organization focused on sustainability. A plus sign whose upper arm grows into a small sprout with two leaves, and whose center is a single round circuit node. Palette: vivid green #22C55E and white #FFFFFF on a near-black #0A0A0A background. Optimistic, modern, community-oriented.
|
||||
```
|
||||
|
||||
## 6. MinGW GCC Installer (`gcc-installer`)
|
||||
|
||||
Instalador gráfico de MinGW-w64/GCC para Windows: lista builds, baixa, instala em `C:\mingw64` e ajusta o PATH. Paleta **proposta**; sem logotipo do Windows.
|
||||
|
||||
```
|
||||
[bloco de estilo comum]
|
||||
Logo mark for a developer-tool installer. An open package box with a downward arrow entering it, and a terminal prompt ">_" cut out of the front face of the box. Palette: slate #1E293B background, box in sky blue #38BDF8, arrow and prompt in white #FFFFFF. Do not use the Windows logo or any four-pane window shape. Practical, engineered, no-nonsense.
|
||||
```
|
||||
|
||||
## 7. AS Serviços (`as-servicos`)
|
||||
|
||||
Site institucional em Next.js com cena 3D gerada em código (casa wireframe, instalação elétrica e placa-mãe; três disciplinas). Identidade observada: azul forte, camadas de prancha em roxo, azul e cobre.
|
||||
|
||||
```
|
||||
[bloco de estilo comum]
|
||||
Logo mark for a multidisciplinary engineering firm (building, electrical, computing). Three isometric layers stacked like architectural drawing sheets, drawn only with thin even outlines and small round node dots at the corners; the stack reads as a simple geometric peak. Palette: strong blue #0B5FD8 for the top layer, violet #7C4DFF for the middle layer, copper #B26A2B for the bottom layer, on white #FFFFFF. Blueprint feel, clean, technical, no letters.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist depois de gerar
|
||||
|
||||
- [ ] Conferir em 32 px e em fundo claro e escuro.
|
||||
- [ ] Vetorizar e limpar nós (SVG).
|
||||
- [ ] Salvar em `public/assets/projects/<id>.svg` e `.png`.
|
||||
- [ ] Para marcas de clientes, confirmar autorização de uso antes de publicar.
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Este é um runbook declarativo para um operador autorizado. Ele descreve evidências que deverão ser coletadas em contas e painéis externos; não registra execução, acesso, configuração, medição ou resultado externo. Search Console, Bing Webmaster Tools, Cloudflare, CrUX e RUM são próximas ações externas, não evidência do repositório.
|
||||
|
||||
Medições executadas e o auditor repetível estão em `docs/seo-geo-validation.md`.
|
||||
|
||||
## Regras de operação
|
||||
|
||||
- Executar cada ação somente com autorização e na propriedade correta.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# SEO, AEO e GEO: medição antes e depois (2026-10-05)
|
||||
|
||||
Este documento registra o que foi **medido**, com ferramenta e comando, e o que ainda **depende de conta externa**. Ele complementa `docs/seo-geo-operations.md` (runbook de Search Console, Bing, Cloudflare e CrUX, ainda não executado) e respeita as exclusões dele: sem `llms.txt`, sem `FAQPage`/`ProfessionalService`/`LocalBusiness`, sem páginas por cidade, sem `keywords`, sem datas ou métricas inventadas.
|
||||
|
||||
## Como repetir
|
||||
|
||||
```bash
|
||||
# 1) auditoria (HTML sem JavaScript, como um robô vê); funciona em produção ou localhost
|
||||
node scripts/seo-audit.mjs --base https://evandro.dev.br --out audit-depois.json
|
||||
|
||||
# 2) comparar duas execuções (antes.json vs depois.json); sai com código 2 se algo piorou
|
||||
node scripts/seo-compare.mjs audit-antes.json audit-depois.json
|
||||
```
|
||||
|
||||
A auditoria lê o `sitemap.xml` e confere, página a página: status, compressão, TTFB, `lang`, `title` e `description` (tamanho e unicidade), canônica própria, indexabilidade, um `h1` e hierarquia, `alt` das imagens, links internos e externos, Open Graph e Twitter Card (e se a imagem social abre), JSON-LD (válido, referências `@id` resolvem, sem os tipos excluídos pela política), resposta direta cedo no HTML, acesso de 8 robôs de busca e de IA (Googlebot, bingbot, OAI-SearchBot, GPTBot, ChatGPT-User, Claude-SearchBot, ClaudeBot, PerplexityBot), e-mail visível para robôs, links do GitHub e links internos quebrados. Cada verificação é passa/falha com a evidência; não existe "nota" inventada.
|
||||
|
||||
## Resultado: `main` contra a branch (build de produção local, 17 URLs)
|
||||
|
||||
| Grupo | `main` | branch |
|
||||
|---|---:|---:|
|
||||
| técnico | 57/57 | 57/57 |
|
||||
| on-page | 204/221 | **213/221** |
|
||||
| social (Open Graph e Twitter) | 35/51 | **51/51** |
|
||||
| AEO/GEO | 54/60 | **56/60** |
|
||||
| dados estruturados | 84/84 | 84/84 |
|
||||
|
||||
27 verificações corrigidas e 0 pioradas. O que mudou: `og:locale` em todas as rotas (só a home tinha), títulos e descrições mais curtos, e conteúdo textual novo em 2 cases.
|
||||
|
||||
Lighthouse (categorias SEO, acessibilidade e boas práticas, mobile): 100 / 97 / 100 nas duas versões. **O Lighthouse não distingue esse tipo de melhoria**, por isso a auditoria acima é a medida principal.
|
||||
|
||||
Validador independente: `structured-data-testing-tool` (npm), preset de redes sociais. Facebook/Open Graph 100% e Twitter 71–85%; o aviso restante é `twitter:site` ausente (opcional). A ferramenta **não** interpreta JSON-LD dentro de `@graph` (reporta "WebSite não encontrado" por limitação dela, não do site); para o JSON-LD, use os dois validadores manuais abaixo.
|
||||
|
||||
## O que foi alterado no código
|
||||
|
||||
- `openGraph.locale: "pt_BR"` nas 6 rotas que redefinem `openGraph`.
|
||||
- Titles: casos passam de "Case de Engenharia de Software" para "Case de Engenharia"; `/curriculo`, Tauri e Engenharia de Software ficam mais curtos. Descrição da home de 193 para 146 caracteres e a de AS Serviços de 184 para 136.
|
||||
- `Person` (JSON-LD) ganha `jobTitle` e `knowsAbout`, ambos já visíveis no site.
|
||||
- Reddit Posts Explorer e MinGW GCC Installer ganham resposta, desafio, implementação, limites e fontes fixadas em commit (repositórios públicos); os textos anteriores diziam o que o código não sustenta (SSG, "rate limiting" por cache, busca em tempo real, download assíncrono, monitoramento).
|
||||
- SC Plus: removida a alegação "otimizada para Core Web Vitals / LCP < 2,5 s". Medição local (Lighthouse mobile): LCP 3,7 s, desempenho 89.
|
||||
- Hero e páginas: conteúdo visível já no HTML (LCP da home no Lighthouse local: 4,0 s para 3,5 s).
|
||||
|
||||
## Achados que o código não resolve (ações suas)
|
||||
|
||||
Ordenados pelo efeito provável na descoberta:
|
||||
|
||||
1. **Cloudflare ofusca o e-mail para robôs.** Confirmado em produção: o HTML servido ao Googlebot tem `/cdn-cgi/l/email-protection#...` e o texto "[email protected]", sem nenhum `mailto:`. Buscadores e respostas de IA não veem o contato. Segundo a [documentação da Cloudflare](https://developers.cloudflare.com/waf/tools/scrape-shield/email-address-obfuscation/), desative em *Security > Settings > Email Address Obfuscation* (ou envolva um endereço com `<!--email_off-->`). A verificação `email-visivel-para-robos` da auditoria passa a ficar verde depois.
|
||||
2. **Resultados de busca desatualizados.** Uma busca por "evandro.dev.br" ainda mostra o título e a descrição antigos ("Engenharia de Software, Next.js, Tauri, Rust e Consultoria Tecnica"). Peça nova indexação no Search Console (Inspeção de URL > Solicitar indexação) e envie a URL no Bing Webmaster Tools.
|
||||
3. **Dois repositórios com "Ver código" quebrado.** `evandrodevbr/twins-lab` e `evandrodevbr/scplus` respondem 404 para visitantes (são privados), e o Twins Lab traz o selo "Código público". Torne os repositórios públicos ou remova o link e o selo. A auditoria acusa isso (`links-github-acessiveis`).
|
||||
4. **Posicionamento e URLs diferentes entre perfis.** O site se apresenta como "sistemas sob medida para empresas"; o perfil do GitHub diz "Backend Software Engineer | Node.js, NestJS, TypeScript, Python & PostgreSQL", com empresa "Apolo Energia", e o README do perfil se declara aberto a vagas de backend (os "cinco anos" coincidem com o site). Respostas de IA misturam essas descrições. Escolha a frase central e repita-a nos perfis. O LinkedIn também aparece com duas URLs no site (`/in/evandro-fonseca-junior-0969aa1b0/` na hero e no JSON-LD, `/in/evandrodevbr/` no rodapé): use a mesma.
|
||||
5. **Homônimos.** Há outras pessoas com nome parecido (por exemplo "Evandro Junior" no LinkedIn e "Evandro Fonseca Junior" em outros sites). `sameAs` e a repetição do mesmo texto de apresentação nos perfis ajudam a desambiguar.
|
||||
6. **SC Plus.** O site no ar se apresenta como "SCPLUS — Educação, Ciência e Tecnologia em Garuva-SC", e o portfólio diz "SC Plus Aquicultura". Confirme o nome correto.
|
||||
|
||||
## Ferramentas que precisam da sua conta (não executadas)
|
||||
|
||||
| Ferramenta | Para quê | O que observar |
|
||||
|---|---|---|
|
||||
| [Google Search Console](https://search.google.com/search-console) | cobertura, canônica escolhida, consultas, cliques | as 17 URLs do sitemap; a canônica que o Google escolheu |
|
||||
| [Bing Webmaster Tools](https://www.bing.com/webmasters) | indexação e relatório de IA (quando disponível) | AI Performance, se a conta tiver |
|
||||
| [Rich Results Test](https://search.google.com/test/rich-results) | JSON-LD como o Google lê | `Person`, `WebSite`, `BreadcrumbList` sem erros |
|
||||
| [Schema Markup Validator](https://validator.schema.org/) | JSON-LD contra schema.org | sem erros em `@graph` |
|
||||
| [CrUX](https://developer.chrome.com/docs/crux) / PageSpeed Insights | desempenho de campo (LCP, INP, CLS) | só aparece com tráfego suficiente; sem dado de campo, não substitua por nota de laboratório |
|
||||
|
||||
## Fontes conferidas
|
||||
|
||||
- Google, [AI features and your website](https://developers.google.com/search/docs/appearance/ai-features): não há marcação nem arquivo especial para aparecer em AI Overviews/AI Mode; vale o SEO padrão (conteúdo em texto, links internos, robots.txt liberando, dados estruturados coerentes com o texto).
|
||||
- Google, [FAQPage](https://developers.google.com/search/docs/appearance/structured-data/faqpage): resultado enriquecido só para sites governamentais e de saúde conhecidos. Por isso `FAQPage` continua excluído.
|
||||
- OpenAI, [crawlers](https://developers.openai.com/api/docs/bots): `OAI-SearchBot` precisa estar liberado para aparecer nas buscas do ChatGPT; `GPTBot` é de treinamento; `ChatGPT-User` é iniciado pelo usuário. O `robots.txt` do site já libera todos (`User-agent: *`, `Allow: /`) e a auditoria confirma HTTP 200 para os 8 robôs.
|
||||
- Anthropic, [crawlers](https://privacy.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler): `ClaudeBot` (treinamento), `Claude-User` e `Claude-SearchBot` (busca), cada um controlável no `robots.txt`.
|
||||
- `llms.txt`: o Google informou que nenhum sistema de IA o usa hoje e que não planeja suportá-lo (declarações de John Mueller e Gary Illyes reportadas pela imprensa especializada). Continua excluído.
|
||||
|
||||
## Limites desta medição
|
||||
|
||||
- A auditoria vê o HTML entregue sem JavaScript; ela não prevê posição em buscas nem citações em respostas de IA. Esses resultados só aparecem com dados de plataforma (Search Console, Bing, relatórios de IA) ao longo de semanas.
|
||||
- A busca usada para ver como o site aparece hoje é uma única consulta pontual; não é série histórica.
|
||||
- Limiares como 30–65 caracteres no título e 70–165 na descrição são orientações de exibição, não regras de ranqueamento.
|
||||
|
After Width: | Height: | Size: 745 KiB |
|
After Width: | Height: | Size: 668 KiB |
|
After Width: | Height: | Size: 821 KiB |
|
After Width: | Height: | Size: 204 KiB |
@@ -0,0 +1,76 @@
|
||||
export const GA_MEASUREMENT_ID = "G-QTT9QT0T3S";
|
||||
export const CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1";
|
||||
export const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
|
||||
|
||||
export type AnalyticsConsent = "granted" | "denied";
|
||||
export type Gtag = (...args: unknown[]) => void;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: IArguments[];
|
||||
"ga-disable-G-QTT9QT0T3S"?: boolean;
|
||||
gtag?: Gtag;
|
||||
}
|
||||
}
|
||||
|
||||
export function readConsent(): AnalyticsConsent | null {
|
||||
try {
|
||||
const value = localStorage.getItem(CONSENT_STORAGE_KEY);
|
||||
return value === "granted" || value === "denied" ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function analyticsPagePath(): string {
|
||||
const path = window.location.pathname.replace(/\/$/, "") || "/";
|
||||
switch (path) {
|
||||
case "/":
|
||||
case "/servicos":
|
||||
case "/cases":
|
||||
case "/curriculo":
|
||||
case "/privacidade":
|
||||
return path;
|
||||
default:
|
||||
// Route templates keep arbitrary slugs and personal data out of metrics.
|
||||
if (path.startsWith("/servicos/")) return "/servicos/[slug]";
|
||||
if (path.startsWith("/cases/")) return "/cases/[slug]";
|
||||
return "/other";
|
||||
}
|
||||
}
|
||||
|
||||
/** Measures an attempted handoff, not an opened app, delivered message or lead.
|
||||
* Callers must supply static labels, never form values or destination URLs.
|
||||
*/
|
||||
export function trackWhatsAppOpen({
|
||||
service,
|
||||
placement,
|
||||
}: {
|
||||
service: string;
|
||||
placement: string;
|
||||
}): void {
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" ||
|
||||
typeof window === "undefined" ||
|
||||
readConsent() !== "granted" ||
|
||||
window[GA_DISABLE_KEY] === true ||
|
||||
typeof window.gtag !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pagePath = analyticsPagePath();
|
||||
window.gtag("event", "whatsapp_open", {
|
||||
send_to: GA_MEASUREMENT_ID,
|
||||
service,
|
||||
placement,
|
||||
page_path: pagePath,
|
||||
// Override gtag's URL defaults so query/hash/referrer data cannot leak.
|
||||
page_location: `https://evandro.dev.br${pagePath}`,
|
||||
page_referrer: "",
|
||||
});
|
||||
} catch {
|
||||
// Analytics must never interrupt navigation. Dropped events are not replayed.
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const brandMark = `data:image/png;base64,${readFileSync(
|
||||
join(process.cwd(), "public/brand/mark-dark.png"),
|
||||
).toString("base64")}`;
|
||||
|
||||
export const OG_SIZE = { width: 1200, height: 630 } as const;
|
||||
export const OG_CONTENT_TYPE = "image/png";
|
||||
|
||||
@@ -16,6 +23,7 @@ export function OgCard({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
background: "#050505",
|
||||
@@ -24,9 +32,17 @@ export function OgCard({
|
||||
fontFamily: "Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={brandMark}
|
||||
alt="Evandro.dev.br"
|
||||
width={80}
|
||||
height={80}
|
||||
style={{ position: "absolute", top: 48, right: 72 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
paddingRight: 112,
|
||||
color: "#ccff00",
|
||||
fontSize: 24,
|
||||
letterSpacing: "0.16em",
|
||||
|
||||
@@ -86,6 +86,27 @@ describe("factual portfolio records", () => {
|
||||
/produç|clientes?|leads?|ranking|performance|resultado comercial|anime\.js|shadcn/i,
|
||||
);
|
||||
});
|
||||
it("keeps Reddit Posts Explorer and MinGW GCC Installer within what their public repositories support", () => {
|
||||
const reddit = serializeProject("reddit-explorer");
|
||||
const gcc = serializeProject("gcc-installer");
|
||||
|
||||
expect(reddit).toContain("MongoDB");
|
||||
expect(reddit).not.toMatch(
|
||||
/SSG|rate limit|cache|tempo real|real-time|busca em|otimiza|responsiv/i,
|
||||
);
|
||||
expect(gcc).toContain("mingw64");
|
||||
expect(gcc).not.toMatch(
|
||||
/assíncron|monitoramento|drastic|Watchdog|dependências|intuitiv/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not claim measured performance for SC Plus without a stored measurement", () => {
|
||||
// Lighthouse mobile local: LCP 3,7 s em 2026-10-05; a alegação anterior (LCP < 2,5 s) não se sustentava
|
||||
expect(serializeProject("sc-plus")).not.toMatch(
|
||||
/LCP|Core Web Vitals|otimizad|velocidade|rápid|Performance/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not publish the unavailable Twins Lab demo", () => {
|
||||
expect(
|
||||
portfolio.projects.find((item) => item.id === "twins-lab")?.demoUrl,
|
||||
|
||||
@@ -37,7 +37,17 @@ it("keeps the root graph limited to verified website and person fields", () => {
|
||||
["@id", "@type", "inLanguage", "name", "publisher", "url"].sort(),
|
||||
);
|
||||
expect(Object.keys(person).sort()).toEqual(
|
||||
["@id", "@type", "homeLocation", "image", "name", "sameAs", "url"].sort(),
|
||||
[
|
||||
"@id",
|
||||
"@type",
|
||||
"homeLocation",
|
||||
"image",
|
||||
"jobTitle",
|
||||
"knowsAbout",
|
||||
"name",
|
||||
"sameAs",
|
||||
"url",
|
||||
].sort(),
|
||||
);
|
||||
expect(website).toMatchObject({
|
||||
"@type": "WebSite",
|
||||
@@ -49,6 +59,7 @@ it("keeps the root graph limited to verified website and person fields", () => {
|
||||
"@type": "Person",
|
||||
"@id": SEO_IDS.person,
|
||||
image: `${siteUrl}/edev.jpg`,
|
||||
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
|
||||
homeLocation: { "@type": "Place", name: "Garuva, Santa Catarina, Brasil" },
|
||||
sameAs: [
|
||||
"https://github.com/evandrodevbr",
|
||||
|
||||
@@ -24,6 +24,18 @@ export function buildPersonJsonLd() {
|
||||
name: "Evandro Fonseca Junior",
|
||||
url: siteUrl,
|
||||
image: `${siteUrl}/edev.jpg`,
|
||||
// cargo e áreas vêm de public/portfolio-data.json e estão visíveis em /curriculo e na seção de skills
|
||||
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
|
||||
knowsAbout: [
|
||||
"Next.js",
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"IoT",
|
||||
"Computação em nuvem",
|
||||
"Inteligência artificial",
|
||||
],
|
||||
homeLocation: {
|
||||
"@type": "Place",
|
||||
name: "Garuva, Santa Catarina, Brasil",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { caseStudies, getRelatedServices, servicePages } from "./site-content";
|
||||
|
||||
const detailedIds = ["as-servicos", "nutriscan", "twins-lab"] as const;
|
||||
const detailedIds = [
|
||||
"as-servicos",
|
||||
"gcc-installer",
|
||||
"nutriscan",
|
||||
"reddit-explorer",
|
||||
"twins-lab",
|
||||
] as const;
|
||||
const immutableGitHubBlobUrl =
|
||||
/^https:\/\/github\.com\/[^/]+\/[^/]+\/blob\/[0-9a-f]{40}\/.+$/;
|
||||
|
||||
@@ -11,7 +17,7 @@ function getDetail(id: (typeof detailedIds)[number]) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
it("limits detailed content to the three evidenced cases", () => {
|
||||
it("limits detailed content to the evidenced cases (public repositories pinned to a commit)", () => {
|
||||
expect(
|
||||
caseStudies
|
||||
.filter(({ detail }) => detail !== undefined)
|
||||
@@ -64,6 +70,15 @@ it("requires sources for factual statements and resolves every source ID", () =>
|
||||
expect(getDetail("nutriscan").boundaries[0]?.sourceIds).toContain(
|
||||
"nutri-readme",
|
||||
);
|
||||
|
||||
// reddit-explorer e gcc-installer: o 1º limite vem da documentação do repositório; o 2º é o escopo editorial (sem fonte)
|
||||
for (const id of ["reddit-explorer", "gcc-installer"] as const) {
|
||||
const { boundaries } = getDetail(id);
|
||||
expect(boundaries[0]?.sourceIds.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("derives the exact related service set from the existing relationship", () => {
|
||||
@@ -96,3 +111,14 @@ it("does not publish the unavailable Twins Lab demo", () => {
|
||||
caseStudies.find((item) => item.id === "twins-lab")?.demoUrl,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps AS Serviços as a project (code only) and states the author's participation", () => {
|
||||
const asServicos = caseStudies.find(({ id }) => id === "as-servicos");
|
||||
expect(asServicos?.demoUrl).toBeNull();
|
||||
expect(asServicos?.githubUrl).toBe(
|
||||
"https://github.com/evandrodevbr/as-servicos",
|
||||
);
|
||||
expect(asServicos?.role).toMatch(/primeira landing page/i);
|
||||
expect(asServicos?.role).toMatch(/animações 3D/i);
|
||||
expect(asServicos?.role).toMatch(/painel para controle de projetos/i);
|
||||
});
|
||||
@@ -2,13 +2,13 @@ export const siteUrl = "https://evandro.dev.br";
|
||||
export const siteName = "Evandro.dev.br";
|
||||
|
||||
export const intentCtas = {
|
||||
hiring: {
|
||||
label: "Avaliar perfil para contratação",
|
||||
href: "/curriculo",
|
||||
project: {
|
||||
label: "Conversar sobre meu projeto",
|
||||
href: "/#contact",
|
||||
},
|
||||
consulting: {
|
||||
label: "Solicitar consultoria técnica",
|
||||
href: "/#contact",
|
||||
label: "Conhecer as soluções",
|
||||
href: "/servicos",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -75,6 +75,8 @@ export interface CaseStudyContent {
|
||||
stack: string[];
|
||||
demoUrl?: string | null;
|
||||
githubUrl?: string | null;
|
||||
/** Papel do autor quando o projeto não é só dele (texto editorial, sem fonte pública). */
|
||||
role?: string;
|
||||
detail?: CaseStudyDetail;
|
||||
}
|
||||
|
||||
@@ -144,7 +146,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "engenharia-de-software",
|
||||
navLabel: "Sistemas web e aplicativos mobile",
|
||||
eyebrow: "Engenharia de software",
|
||||
seoTitle: "Desenvolvimento de Sistemas Web e Aplicativos Mobile",
|
||||
seoTitle: "Sistemas Web e Aplicativos Mobile sob Medida",
|
||||
metaDescription:
|
||||
"Sistemas web e aplicativos mobile sob medida, integração entre ferramentas e evolução de software existente. Escopo, plataformas e entregas definidos por projeto.",
|
||||
headline: "Sistemas e apps para o seu trabalho.",
|
||||
@@ -266,7 +268,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "tauri-rust",
|
||||
navLabel: "Aplicativos desktop para Windows e Linux",
|
||||
eyebrow: "Aplicativos desktop",
|
||||
seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust",
|
||||
seoTitle: "Aplicativos Desktop com Tauri e Rust",
|
||||
metaDescription:
|
||||
"Desenvolvimento de aplicativos desktop para Windows e Linux com Tauri e Rust. Ferramentas locais, arquivos e integrações com compatibilidade definida por projeto.",
|
||||
headline: "Aplicativos para Windows e Linux.",
|
||||
@@ -618,22 +620,83 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "reddit-explorer",
|
||||
title: "Reddit Posts Explorer",
|
||||
subtitle: "Aplicação orientada a dados com API, cache e busca",
|
||||
subtitle: "Posts do r/artificial em MongoDB, filtrados por período",
|
||||
summary:
|
||||
"Projeto em Astro e MongoDB para explorar conteúdo com filtros, ordenação e cache, mantendo boa experiência em consultas frequentes.",
|
||||
"Aplicação Astro SSR que coleta os posts em alta do r/artificial, grava no MongoDB sem duplicar e filtra por período, upvotes ou comentários.",
|
||||
impact:
|
||||
"Mostra capacidade de desenhar consumo de API, camada de persistência e experiência de busca sem sobrecarregar a aplicação.",
|
||||
stack: ["Astro.js", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
|
||||
"Atualizar a coleta sem duplicar posts e consultar o histórico por intervalo de datas e ordenação.",
|
||||
stack: ["Astro", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
|
||||
githubUrl: "https://github.com/evandrodevbr/reddit-search",
|
||||
detail: {
|
||||
answer: {
|
||||
text: "Aplicação Astro em modo SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.",
|
||||
sourceIds: ["rd-readme", "rd-service", "rd-astro"],
|
||||
},
|
||||
challenge: {
|
||||
text: "Atualizar a coleta sem duplicar posts e consultar o histórico por intervalo de datas e ordenação.",
|
||||
sourceIds: ["rd-service", "rd-filter"],
|
||||
},
|
||||
implementation: [
|
||||
{
|
||||
text: "O serviço consulta r/artificial/hot.json com User-Agent identificado e limite de 15 segundos, e grava os posts em lote com upsert pelo id; rodar a coleta de novo atualiza os existentes.",
|
||||
sourceIds: ["rd-service"],
|
||||
},
|
||||
{
|
||||
text: "O endpoint de filtro valida as datas e o campo de ordenação (ups ou num_comments) e responde 400 quando faltam parâmetros ou eles são inválidos.",
|
||||
sourceIds: ["rd-filter"],
|
||||
},
|
||||
{
|
||||
text: "O Astro roda com output server e o adapter Node em modo standalone.",
|
||||
sourceIds: ["rd-astro", "rd-package"],
|
||||
},
|
||||
],
|
||||
boundaries: [
|
||||
{
|
||||
text: "O subreddit é fixo, e o projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.",
|
||||
sourceIds: ["rd-readme"],
|
||||
},
|
||||
{
|
||||
text: "O case demonstra implementação e decisões técnicas; não atribui ganho de performance, volume de dados ou resultado comercial.",
|
||||
sourceIds: [],
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "rd-readme",
|
||||
label: "README do projeto",
|
||||
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/README.md",
|
||||
},
|
||||
{
|
||||
id: "rd-service",
|
||||
label: "Serviço de coleta e consulta",
|
||||
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/src/lib/services/redditService.ts",
|
||||
},
|
||||
{
|
||||
id: "rd-filter",
|
||||
label: "Endpoint de filtro",
|
||||
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/src/pages/api/posts/filter.ts",
|
||||
},
|
||||
{
|
||||
id: "rd-astro",
|
||||
label: "Configuração do Astro",
|
||||
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/astro.config.mjs",
|
||||
},
|
||||
{
|
||||
id: "rd-package",
|
||||
label: "Manifesto do projeto",
|
||||
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/package.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sc-plus",
|
||||
title: "SC Plus Aquicultura",
|
||||
subtitle: "Site institucional em Next.js com foco em performance e SEO",
|
||||
subtitle: "Landing page institucional em Next.js 15",
|
||||
summary:
|
||||
"Landing page em Next.js 15 otimizada para Core Web Vitals, metadata e estrutura técnica de indexação.",
|
||||
"Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
|
||||
impact:
|
||||
"Exemplo de projeto em que clareza de mensagem, velocidade e SEO técnico trabalham juntos.",
|
||||
"Dar à instituição uma página própria, com base técnica para ser encontrada em buscadores.",
|
||||
stack: ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
|
||||
demoUrl: "https://scplus.evandro.dev.br",
|
||||
githubUrl: "https://github.com/evandrodevbr/scplus",
|
||||
@@ -641,29 +704,80 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "gcc-installer",
|
||||
title: "MinGW GCC Installer",
|
||||
subtitle:
|
||||
"Ferramenta desktop para automação de ambiente de desenvolvimento",
|
||||
subtitle: "Instalador gráfico de MinGW-w64/GCC para Windows",
|
||||
summary:
|
||||
"Aplicação em Python para simplificar setup local no Windows, com download assíncrono, checagem de compatibilidade e integração com PATH.",
|
||||
"Aplicativo Python com interface tkinter que lista as builds do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e adiciona o GCC ao PATH do usuário.",
|
||||
impact:
|
||||
"Mostra familiaridade com software desktop, integração com sistema operacional e automação de setup técnico.",
|
||||
stack: ["Python", "Tkinter", "Win32 API", "Threading", "7-Zip"],
|
||||
"Reduzir a configuração manual do GCC no Windows (escolher a build, baixar, extrair e ajustar o PATH) a uma única janela.",
|
||||
stack: ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"],
|
||||
githubUrl: "https://github.com/evandrodevbr/gcc-installer",
|
||||
detail: {
|
||||
answer: {
|
||||
text: "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o toolchain ao PATH do usuário no registro do Windows.",
|
||||
sourceIds: ["gcc-readme", "gcc-main"],
|
||||
},
|
||||
challenge: {
|
||||
text: "Reduzir a configuração manual do GCC no Windows (escolher a build, baixar, extrair e ajustar o PATH) a uma única janela.",
|
||||
sourceIds: ["gcc-readme"],
|
||||
},
|
||||
implementation: [
|
||||
{
|
||||
text: "A lista de versões vem da API de releases do repositório niXman/mingw-builds-binaries, e as builds com ucrt e thread model posix são destacadas na tabela.",
|
||||
sourceIds: ["gcc-main"],
|
||||
},
|
||||
{
|
||||
text: "A instalação extrai o pacote com o 7-Zip (caminho padrão fixo) em uma pasta temporária, move o resultado para C:\\mingw64 e renomeia mingw32-make.exe para make.exe.",
|
||||
sourceIds: ["gcc-main"],
|
||||
},
|
||||
{
|
||||
text: "Adicionar ao PATH grava em HKCU\\Environment e avisa o Windows com WM_SETTINGCHANGE; fora do Windows o programa encerra com um aviso.",
|
||||
sourceIds: ["gcc-main"],
|
||||
},
|
||||
],
|
||||
boundaries: [
|
||||
{
|
||||
text: "Funciona só no Windows, o caminho do 7-Zip é fixo e o fluxo da interface não foi exercitado de ponta a ponta no Windows nesta revisão; os 23 testes cobrem a lógica independente de plataforma, e não há CI.",
|
||||
sourceIds: ["gcc-readme", "gcc-tests"],
|
||||
},
|
||||
{
|
||||
text: "O case demonstra implementação e decisões técnicas; não atribui ganho de performance, número de usuários ou resultado comercial.",
|
||||
sourceIds: [],
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "gcc-readme",
|
||||
label: "README do projeto",
|
||||
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/README.md",
|
||||
},
|
||||
{
|
||||
id: "gcc-main",
|
||||
label: "Aplicativo (main.py)",
|
||||
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/main.py",
|
||||
},
|
||||
{
|
||||
id: "gcc-tests",
|
||||
label: "Testes do núcleo",
|
||||
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/tests/test_core.py",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "as-servicos",
|
||||
title: "AS Serviços",
|
||||
subtitle: "Site institucional com cena 3D procedural",
|
||||
summary:
|
||||
"Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
"Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
impact:
|
||||
"Representar engenharia física e software na mesma narrativa visual sem depender de um modelo 3D pronto.",
|
||||
stack: ["Next.js 16", "Three.js", "TypeScript"],
|
||||
demoUrl: "https://asservicos.com",
|
||||
demoUrl: null,
|
||||
githubUrl: "https://github.com/evandrodevbr/as-servicos",
|
||||
role: "Desenvolvimento da primeira landing page do site, com animações 3D, e de um painel para controle de projetos.",
|
||||
detail: {
|
||||
answer: {
|
||||
text: "Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
text: "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
sourceIds: ["as-readme", "as-package", "as-engine"],
|
||||
},
|
||||
challenge: {
|
||||
|
||||
@@ -2,10 +2,9 @@ export const CV_URL = "/Profile.pdf";
|
||||
export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf";
|
||||
|
||||
export const NAVIGATION_ITEMS = [
|
||||
{ href: "/#home", label: "Home" },
|
||||
{ href: "/cases", label: "Casos" },
|
||||
{ href: "/servicos", label: "Serviços" },
|
||||
{ href: "/curriculo", label: "Currículo" },
|
||||
{ href: "/#modalities", label: "Soluções" },
|
||||
{ href: "/cases", label: "Projetos" },
|
||||
{ href: "/#process", label: "Como trabalho" },
|
||||
{ href: "/#about", label: "Sobre" },
|
||||
{ href: "/#contact", label: "Contato" },
|
||||
{ href: "/#experience", label: "Experiência" },
|
||||
] as const;
|
||||
@@ -13,6 +13,16 @@ const nextConfig: NextConfig = {
|
||||
root: process.cwd(),
|
||||
},
|
||||
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/servicos/sob-medida",
|
||||
destination: "/servicos/engenharia-de-software",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
async headers(): Promise<
|
||||
Array<{ source: string; headers: Array<{ key: string; value: string }> }>
|
||||
> {
|
||||
|
||||
@@ -11,14 +11,18 @@
|
||||
"@tailwindcss/oxide",
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"brace-expansion@<1.1.21": "1.1.21",
|
||||
"brace-expansion@>=2.0.0 <2.1.7": "2.1.7"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "biome check",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"format": "biome format --write",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -28,11 +32,11 @@
|
||||
"@react-three/drei": "10.7.7",
|
||||
"@react-three/fiber": "9.5.0",
|
||||
"clsx": "2.1.1",
|
||||
"cobe": "0.6.4",
|
||||
"gsap": "3.13.0",
|
||||
"lenis": "^1.3.26",
|
||||
"lucide-react": "0.542.0",
|
||||
"motion": "12.23.12",
|
||||
"next": "16.3.0",
|
||||
"next": "16.3.8",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "3.3.1",
|
||||
|
||||
@@ -4,6 +4,10 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
brace-expansion@<1.1.21: 1.1.21
|
||||
brace-expansion@>=2.0.0 <2.1.7: 2.1.7
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -17,12 +21,12 @@ importers:
|
||||
clsx:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
cobe:
|
||||
specifier: 0.6.4
|
||||
version: 0.6.4
|
||||
gsap:
|
||||
specifier: 3.13.0
|
||||
version: 3.13.0
|
||||
lenis:
|
||||
specifier: ^1.3.26
|
||||
version: 1.3.26([email protected])
|
||||
lucide-react:
|
||||
specifier: 0.542.0
|
||||
version: 0.542.0([email protected])
|
||||
@@ -30,8 +34,8 @@ importers:
|
||||
specifier: 12.23.12
|
||||
version: 12.23.12([email protected]([email protected]))([email protected])
|
||||
next:
|
||||
specifier: 16.3.0
|
||||
version: 16.3.0(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: 16.3.8
|
||||
version: 16.3.8(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -706,53 +710,53 @@ packages:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -810,8 +814,8 @@ packages:
|
||||
'@sinonjs/[email protected]':
|
||||
resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==}
|
||||
|
||||
'@swc/[email protected].15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
'@swc/[email protected].23':
|
||||
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==}
|
||||
@@ -1254,11 +1258,11 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
[email protected]8:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
[email protected].21:
|
||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
[email protected].7:
|
||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
|
||||
@@ -1333,9 +1337,6 @@ packages:
|
||||
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
||||
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-huuGFnDoXLy/tsCZYYa/H35BBRs9cxsS0XKJ3BXjRp699cQKuoEVrvKlAQMx0DKXG7+VUv4jsHVrS7yPbkLSkQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==}
|
||||
|
||||
@@ -1892,6 +1893,20 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==}
|
||||
peerDependencies:
|
||||
'@nuxt/kit': '>=3.0.0'
|
||||
react: '>=17.0.0'
|
||||
vue: '>=3.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@nuxt/kit':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2083,8 +2098,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
|
||||
[email protected].8:
|
||||
resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2171,9 +2186,6 @@ packages:
|
||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
engines: {node: '>=16 || 14 >=14.18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -3425,30 +3437,30 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0': {}
|
||||
'@next/[email protected].8': {}
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@pkgjs/[email protected]':
|
||||
@@ -3519,7 +3531,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@sinonjs/commons': 3.0.1
|
||||
|
||||
'@swc/[email protected].15':
|
||||
'@swc/[email protected].23':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -3942,12 +3954,12 @@ snapshots:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
[email protected]8:
|
||||
[email protected].21:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
[email protected].4:
|
||||
[email protected].7:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -4011,10 +4023,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
phenomenon: 1.6.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4775,6 +4783,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
optionalDependencies:
|
||||
react: 19.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4879,11 +4891,11 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.18
|
||||
brace-expansion: 1.1.21
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.4
|
||||
brace-expansion: 2.1.7
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -4917,10 +4929,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].0(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
[email protected].8(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@next/env': 16.3.0
|
||||
'@swc/helpers': 0.5.15
|
||||
'@next/env': 16.3.8
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.11.21
|
||||
caniuse-lite: 1.0.30001810
|
||||
postcss: 8.5.23
|
||||
@@ -4928,14 +4940,14 @@ snapshots:
|
||||
react-dom: 19.1.0([email protected])
|
||||
styled-jsx: 5.1.6(@babel/[email protected])([email protected])
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.3.0
|
||||
'@next/swc-darwin-x64': 16.3.0
|
||||
'@next/swc-linux-arm64-gnu': 16.3.0
|
||||
'@next/swc-linux-arm64-musl': 16.3.0
|
||||
'@next/swc-linux-x64-gnu': 16.3.0
|
||||
'@next/swc-linux-x64-musl': 16.3.0
|
||||
'@next/swc-win32-arm64-msvc': 16.3.0
|
||||
'@next/swc-win32-x64-msvc': 16.3.0
|
||||
'@next/swc-darwin-arm64': 16.3.8
|
||||
'@next/swc-darwin-x64': 16.3.8
|
||||
'@next/swc-linux-arm64-gnu': 16.3.8
|
||||
'@next/swc-linux-arm64-musl': 16.3.8
|
||||
'@next/swc-linux-x64-gnu': 16.3.8
|
||||
'@next/swc-linux-x64-musl': 16.3.8
|
||||
'@next/swc-win32-arm64-msvc': 16.3.8
|
||||
'@next/swc-win32-x64-msvc': 16.3.8
|
||||
sharp: 0.35.4(@types/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -5000,8 +5012,6 @@ snapshots:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="210 220 830 800"><path fill="#f4f3ef" d="M245 757H321V681H399V606H476V757H399V878H523V757H654V913H594V982H245Z"/><g fill="#d5fa00"><path d="M511 512H667V688H511Z"/><path d="M667 358H821V512H667Z"/><path d="M844 255H947V358H844Z"/><path d="M906 432H1007V533H906Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 323 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="210 220 830 800"><path fill="#151d1c" d="M245 757H321V681H399V606H476V757H399V878H523V757H654V913H594V982H245Z"/><g fill="#d5fa00"><path d="M511 512H667V688H511Z"/><path d="M667 358H821V512H667Z"/><path d="M844 255H947V358H844Z"/><path d="M906 432H1007V533H906Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -152,31 +152,29 @@
|
||||
{
|
||||
"id": "reddit-explorer",
|
||||
"title": "Reddit Posts Explorer",
|
||||
"subtitle": "Análise de Conteúdo IA",
|
||||
"description": "Aplicação Astro.js com arquitetura SSG otimizada para consumo de Reddit API. Resolve problema de rate limiting através de cache estratégico em MongoDB com Mongoose. Sistema de filtragem por data e ordenação, API REST própria e busca em tempo real com otimização de queries.",
|
||||
"longDescription": "Aplicação web que consome a API do Reddit para buscar, filtrar e analisar posts relacionados à Inteligência Artificial, oferecendo interface intuitiva para descoberta de conteúdo relevante.",
|
||||
"subtitle": "Posts do r/artificial em MongoDB, filtrados por período",
|
||||
"description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.",
|
||||
"longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação. O projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.",
|
||||
"technologies": [
|
||||
"Astro.js",
|
||||
"Astro",
|
||||
"MongoDB",
|
||||
"Mongoose",
|
||||
"TypeScript",
|
||||
"TailwindCSS",
|
||||
"Reddit API"
|
||||
],
|
||||
"features": [
|
||||
"Integração com Reddit API",
|
||||
"Sistema de cache com MongoDB",
|
||||
"Filtragem por data e ordenação",
|
||||
"Interface responsiva",
|
||||
"API REST própria",
|
||||
"Busca em tempo real"
|
||||
"Coleta dos posts em alta do r/artificial",
|
||||
"Upsert por id no MongoDB",
|
||||
"Filtro por período",
|
||||
"Ordenação por upvotes ou comentários",
|
||||
"API própria com validação de parâmetros"
|
||||
],
|
||||
"category": "api",
|
||||
"level": "pleno",
|
||||
"status": "Concluído",
|
||||
"demoUrl": null,
|
||||
"githubUrl": "https://github.com/evandrodevbr/reddit-search",
|
||||
"badges": ["API Integration", "MongoDB", "Real-time"],
|
||||
"badges": ["API Integration", "MongoDB", "Código público"],
|
||||
"featured": true,
|
||||
"metrics": {
|
||||
"complexity": 3,
|
||||
@@ -186,9 +184,9 @@
|
||||
{
|
||||
"id": "sc-plus",
|
||||
"title": "SC Plus Aquicultura",
|
||||
"subtitle": "Landing Page Institucional",
|
||||
"description": "Landing page Next.js 15 com React 19 e Tailwind CSS 4. Arquitetura otimizada para Core Web Vitals com LCP < 2.5s através de code splitting e server-side rendering. SEO técnico implementado com meta tags dinâmicas e structured data.",
|
||||
"longDescription": "Site institucional responsivo desenvolvido com as mais recentes tecnologias web, otimizado para performance e SEO, apresentando os serviços e valores da empresa de aquicultura sustentável.",
|
||||
"subtitle": "Landing page institucional em Next.js 15",
|
||||
"description": "Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
|
||||
"longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.",
|
||||
"technologies": [
|
||||
"Next.js 15",
|
||||
"React 19",
|
||||
@@ -196,18 +194,16 @@
|
||||
"TypeScript"
|
||||
],
|
||||
"features": [
|
||||
"Design moderno e responsivo",
|
||||
"Performance otimizada",
|
||||
"SEO otimizado",
|
||||
"Interface intuitiva",
|
||||
"Carregamento rápido"
|
||||
"Landing page institucional",
|
||||
"Metadados e URL canônica",
|
||||
"Dados estruturados"
|
||||
],
|
||||
"category": "web",
|
||||
"level": "pleno",
|
||||
"status": "Concluído",
|
||||
"demoUrl": "https://scplus.evandro.dev.br",
|
||||
"githubUrl": "https://github.com/evandrodevbr/scplus",
|
||||
"badges": ["Responsive", "SEO", "Modern Stack"],
|
||||
"badges": ["Landing page", "Next.js 15"],
|
||||
"featured": true,
|
||||
"metrics": {
|
||||
"complexity": 2,
|
||||
@@ -217,32 +213,23 @@
|
||||
{
|
||||
"id": "gcc-installer",
|
||||
"title": "MinGW GCC Installer",
|
||||
"subtitle": "Ferramenta de Desenvolvimento",
|
||||
"description": "Aplicação desktop Python com Tkinter para automação de setup de ambiente GCC no Windows. Resolve problema de configuração manual complexa através de download assíncrono com threading, verificação de compatibilidade via Win32 API e integração automática com PATH do sistema. Utiliza 7-Zip para extração e Watchdog para monitoramento.",
|
||||
"longDescription": "Ferramenta automatizada que simplifica drasticamente o processo de configuração de ambiente de desenvolvimento GCC no Windows, incluindo verificação de compatibilidade, download automático e integração com o sistema.",
|
||||
"technologies": [
|
||||
"Python",
|
||||
"Tkinter",
|
||||
"Requests",
|
||||
"Watchdog",
|
||||
"Win32 API",
|
||||
"7-Zip",
|
||||
"Threading"
|
||||
],
|
||||
"subtitle": "Instalador gráfico de MinGW-w64/GCC para Windows",
|
||||
"description": "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o toolchain ao PATH do usuário no registro do Windows.",
|
||||
"longDescription": "A lista de versões vem da API de releases do niXman/mingw-builds-binaries, com as builds ucrt e posix destacadas. A instalação extrai o pacote com o 7-Zip, move para C:\\mingw64 e renomeia mingw32-make.exe para make.exe. Funciona só no Windows e o caminho do 7-Zip é fixo.",
|
||||
"technologies": ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"],
|
||||
"features": [
|
||||
"Interface gráfica intuitiva",
|
||||
"Download assíncrono com progresso",
|
||||
"Verificação de compatibilidade",
|
||||
"Integração automática com PATH",
|
||||
"Monitoramento em tempo real",
|
||||
"Instalação automática de dependências"
|
||||
"Lista as builds publicadas do MinGW-w64",
|
||||
"Destaca builds ucrt e posix",
|
||||
"Baixa e instala em C:\\mingw64",
|
||||
"Adiciona o toolchain ao PATH do usuário",
|
||||
"Testes do núcleo (23)"
|
||||
],
|
||||
"category": "desktop",
|
||||
"level": "pleno",
|
||||
"status": "Concluído",
|
||||
"demoUrl": null,
|
||||
"githubUrl": "https://github.com/evandrodevbr/gcc-installer",
|
||||
"badges": ["Desktop", "Automation", "System Integration"],
|
||||
"badges": ["Desktop", "Windows", "Código público"],
|
||||
"featured": true,
|
||||
"metrics": {
|
||||
"complexity": 4,
|
||||
@@ -253,7 +240,7 @@
|
||||
"id": "as-servicos",
|
||||
"title": "AS Serviços",
|
||||
"subtitle": "Site Institucional com Cena 3D Procedural",
|
||||
"description": "Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
"description": "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
"longDescription": "O motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js. O canvas converte a rolagem em progresso de 0 a 1, e a qualidade se adapta conforme WebGL, reduced motion, economia de dados, conexão e capacidade do dispositivo.",
|
||||
"technologies": ["Next.js 16", "TypeScript", "Three.js"],
|
||||
"features": [
|
||||
@@ -264,13 +251,13 @@
|
||||
"category": "web",
|
||||
"level": "senior",
|
||||
"status": "Código público",
|
||||
"demoUrl": "https://asservicos.com",
|
||||
"demoUrl": null,
|
||||
"githubUrl": "https://github.com/evandrodevbr/as-servicos",
|
||||
"badges": ["Three.js", "WebGL", "Código público"],
|
||||
"featured": true,
|
||||
"metrics": {
|
||||
"complexity": 5,
|
||||
"teamSize": "Individual"
|
||||
"teamSize": "Em equipe"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||