feat: rebuild landing experience, content and analytics
This commit is contained in:
1 parent
d24ee12299
commit
9aff4d1035
51 files changed
+3023
-2595
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
|
||||
|
||||
@@ -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,51 @@ 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.
|
||||
|
||||
`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.
|
||||
|
||||
### 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 +141,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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -236,9 +236,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"
|
||||
|
||||
+2
-2
@@ -136,11 +136,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"
|
||||
|
||||
+16
-16
@@ -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");
|
||||
});
|
||||
+217
-196
@@ -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,12 @@ 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 project contact and a direct mailto link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
expect(screen.getByText("Contato")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Engenharia com menos ruído e mais clareza de execução",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
screen.getByRole("link", { name: "Conversar sobre meu projeto" }),
|
||||
).toHaveAttribute("href", "/#contact");
|
||||
|
||||
const emailLink = screen.getByRole("link", {
|
||||
name: "[email protected]",
|
||||
@@ -33,32 +30,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 +72,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();
|
||||
@@ -51,13 +53,21 @@ export default function Footer() {
|
||||
<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
|
||||
Vamos conversar sobre o software que seu negócio precisa?
|
||||
</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.
|
||||
Conte o que você quer construir, integrar ou melhorar. Podemos
|
||||
discutir o contexto, as prioridades e o escopo antes de definir
|
||||
os próximos passos.
|
||||
</p>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/#contact"
|
||||
className="ds-link ds-link-accent mt-sp-6 inline-flex min-h-11 items-center rounded-sm px-sp-4 font-semibold"
|
||||
strength={0.2}
|
||||
>
|
||||
Conversar sobre meu projeto
|
||||
</MagneticButton>
|
||||
|
||||
<div className="mt-sp-6">
|
||||
<p className="mb-2 text-[12px] tracking-[0.02em] text-[var(--meta)]">
|
||||
@@ -66,7 +76,7 @@ export default function Footer() {
|
||||
<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-block 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 +85,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 +160,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
|
||||
@@ -276,7 +268,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,32 @@ 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",
|
||||
"/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",
|
||||
"/#about",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Contato" })).toHaveAttribute(
|
||||
"href",
|
||||
"/#contact",
|
||||
);
|
||||
const navigation = screen.getByRole("navigation", {
|
||||
name: "Navegação principal",
|
||||
});
|
||||
expect(
|
||||
within(navigation)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual(["/#modalities", "/cases", "/#process", "/#about"]);
|
||||
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 +64,16 @@ 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", "/#contact"]);
|
||||
expect(
|
||||
within(mobileNavigation).queryByRole("link", {
|
||||
name: /currículo|download cv/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const mobileLink = within(mobileNavigation).getByRole("link", {
|
||||
name: "Sobre",
|
||||
@@ -109,27 +85,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();
|
||||
},
|
||||
);
|
||||
});
|
||||
+55
-43
@@ -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={`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();
|
||||
|
||||
+21
-100
@@ -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,27 @@ 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.getByRole("link", { name: /nutriscan/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/cases/nutriscan",
|
||||
);
|
||||
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps social destinations, operational proof and WhatsApp action", () => {
|
||||
it("keeps social destinations with isolated external tabs", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute(
|
||||
@@ -132,28 +71,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");
|
||||
}
|
||||
});
|
||||
});
|
||||
+73
-473
@@ -1,506 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
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 HeroSystem from "./cinematic/HeroSystem";
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
const heroRef = useRef<HTMLElement>(null);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
|
||||
const [isHeroVisible, setIsHeroVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
|
||||
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 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={heroRef}
|
||||
id="home"
|
||||
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||
className="w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
|
||||
>
|
||||
{shouldRenderHeroScene && <HeroScene isVisible={isHeroVisible} />}
|
||||
|
||||
<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],
|
||||
}
|
||||
}
|
||||
>
|
||||
<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>
|
||||
|
||||
<motion.div
|
||||
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
|
||||
variants={heroItem}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<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">
|
||||
{SOCIAL_LINKS.map((link) => (
|
||||
<MagneticButton
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</motion.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],
|
||||
}
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2"
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: -10 }}
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={
|
||||
shouldReduceMotion ? { duration: 0 } : { delay: 2, duration: 1 }
|
||||
}
|
||||
transition={{
|
||||
duration: shouldReduceMotion ? 0 : 0.45,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="mx-auto grid w-full max-w-[1400px] grid-cols-1 items-center gap-sp-8 px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-10 lg:grid-cols-[minmax(0,53fr)_minmax(0,47fr)] lg:gap-sp-8 lg:px-sp-12 lg:pb-sp-16"
|
||||
>
|
||||
{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 className="min-w-0">
|
||||
<p className="mb-sp-5 text-[14px] leading-[1.5] text-[rgba(244,242,237,.72)]">
|
||||
Evandro Fonseca Junior · Engenharia de software
|
||||
</p>
|
||||
<h1 className="max-w-[700px] text-balance text-[34px] font-semibold leading-[1.05] tracking-[-0.045em] sm:text-[48px] xl:text-[60px]">
|
||||
Sistemas sob medida para operações que não cabem em soluções
|
||||
prontas.
|
||||
</h1>
|
||||
<p className="mt-sp-5 max-w-[600px] text-[16px] leading-[1.55] text-[rgba(244,242,237,.8)] lg:text-[18px]">
|
||||
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
|
||||
entendimento do negócio à arquitetura e à entrega em produção.
|
||||
</p>
|
||||
|
||||
<div className="mt-sp-6 flex flex-col gap-sp-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Link
|
||||
href="/#contact"
|
||||
className="inline-flex min-h-12 items-center justify-center bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
||||
>
|
||||
Conversar sobre meu projeto
|
||||
</Link>
|
||||
<Link
|
||||
href="/cases"
|
||||
className="inline-flex min-h-12 items-center justify-center border border-white/12 px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
|
||||
>
|
||||
Ver projetos entregues
|
||||
</Link>
|
||||
</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>
|
||||
)}
|
||||
<p className="mt-sp-3 text-[13px] leading-[1.5] text-[rgba(244,242,237,.64)]">
|
||||
Santa Catarina · Atendimento remoto
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0 lg:col-start-2 lg:row-start-1 lg:row-span-2">
|
||||
<HeroSystem />
|
||||
</div>
|
||||
<div className="min-w-0 lg:col-start-1 lg:row-start-2">
|
||||
<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="mt-sp-2 flex flex-wrap gap-x-sp-5">
|
||||
{SOCIAL_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
link.href.startsWith("http")
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
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"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
+105
-81
@@ -3,6 +3,7 @@
|
||||
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 { Section } from "./Section";
|
||||
@@ -41,9 +42,9 @@ 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
|
||||
>
|
||||
@@ -54,94 +55,117 @@ export default function Projects({
|
||||
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 && (
|
||||
{projects.map((project) => {
|
||||
const caseStudy = caseStudies.find(
|
||||
(entry) => entry.id === project.id,
|
||||
);
|
||||
return (
|
||||
<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="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") && (
|
||||
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
|
||||
|
||||
@@ -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} />
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
if (originalScrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
@@ -37,6 +38,37 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("home fragment navigation", () => {
|
||||
it("starts an unfragmented visit at the top after the notebook 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 notebook layout" }),
|
||||
);
|
||||
expect(restoredPosition).toBe(0);
|
||||
});
|
||||
|
||||
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 notebook layout" }),
|
||||
);
|
||||
expect(position).toBe(600);
|
||||
});
|
||||
|
||||
it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
render(<HomeNavigation />);
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -35,40 +36,36 @@ export default function CinematicHome({
|
||||
<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" />
|
||||
<ScrollVelocityText text="SOFTWARE SOB MEDIDA" direction="left" />
|
||||
</div>
|
||||
|
||||
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
|
||||
|
||||
<About />
|
||||
<Projects projects={projects} />
|
||||
|
||||
<SectionDivider variant="waveform" />
|
||||
|
||||
<Skills categories={skillCategories} />
|
||||
<WorkModalities />
|
||||
|
||||
<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" />
|
||||
|
||||
<Projects projects={projects} />
|
||||
<EngineeringWithAI />
|
||||
|
||||
<LighthouseMetrics />
|
||||
|
||||
<SectionDivider variant="pulse" />
|
||||
|
||||
<WorkModalities />
|
||||
<About />
|
||||
|
||||
<details 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>
|
||||
<Skills categories={skillCategories} />
|
||||
<Experience experiences={experiences} />
|
||||
</details>
|
||||
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
|
||||
<ScrollVelocityText text="VAMOS CONVERSAR" direction="left" />
|
||||
<ScrollVelocityText text="VAMOS CONSTRUIR" direction="left" />
|
||||
</div>
|
||||
|
||||
<Contact />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { PointerEvent } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import type { HeroSystemLayer, HeroSystemSceneProps } from "./HeroSystemScene";
|
||||
|
||||
const DynamicScene = dynamic<HeroSystemSceneProps>(
|
||||
() => import("./HeroSystemScene"),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const LAYERS: { id: HeroSystemLayer; label: string; description: string }[] = [
|
||||
{
|
||||
id: "overview",
|
||||
label: "Visão geral",
|
||||
description:
|
||||
"Da tela ao que acontece por trás: interface, integrações e dados trabalhando juntos.",
|
||||
},
|
||||
{
|
||||
id: "interface",
|
||||
label: "Interface",
|
||||
description:
|
||||
"A parte que as pessoas usam. Aqui, uma tela real do NutriScan, projeto do portfólio.",
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: "Integrações",
|
||||
description:
|
||||
"Conexões entre sistemas para levar informações de uma etapa da operação à próxima.",
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
label: "Dados",
|
||||
description:
|
||||
"A base que organiza e guarda as informações de que a operação precisa.",
|
||||
},
|
||||
];
|
||||
|
||||
const REST_POSE = { x: 0, y: 0 };
|
||||
type Gesture = {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
startPose: number;
|
||||
width: number;
|
||||
dragging: boolean;
|
||||
};
|
||||
|
||||
export default function HeroSystem() {
|
||||
const descriptionId = useId();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const nextPoseRef = useRef(REST_POSE);
|
||||
const gestureRef = useRef<Gesture | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const [activeLayer, setActiveLayer] = useState<HeroSystemLayer>("overview");
|
||||
const [pose, setPose] = useState(REST_POSE);
|
||||
const [canRender, setCanRender] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const renderScene = canRender && !failed;
|
||||
const sceneActive = renderScene && ready;
|
||||
|
||||
useEffect(() => {
|
||||
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let supported: boolean | undefined;
|
||||
const updateCapability = () => {
|
||||
if (preference.matches) {
|
||||
setCanRender(false);
|
||||
setReady(false);
|
||||
return;
|
||||
}
|
||||
if (supported === undefined) {
|
||||
if (typeof window.WebGL2RenderingContext === "undefined") {
|
||||
supported = false;
|
||||
} else {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("webgl2");
|
||||
supported = context !== null;
|
||||
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
} catch {
|
||||
supported = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
setCanRender(supported);
|
||||
};
|
||||
updateCapability();
|
||||
preference.addEventListener("change", updateCapability);
|
||||
return () => preference.removeEventListener("change", updateCapability);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
let intersecting = false;
|
||||
const updateVisibility = () => {
|
||||
setVisible(intersecting && document.visibilityState === "visible");
|
||||
};
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
intersecting = entry.isIntersecting;
|
||||
updateVisibility();
|
||||
});
|
||||
observer.observe(viewport);
|
||||
document.addEventListener("visibilitychange", updateVisibility);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", updateVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const queuePose = useCallback((x: number, y: number) => {
|
||||
nextPoseRef.current = {
|
||||
x: Math.max(-1, Math.min(1, x)),
|
||||
y: Math.max(-1, Math.min(1, y)),
|
||||
};
|
||||
if (frameRef.current !== null) return;
|
||||
frameRef.current = window.requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
setPose(nextPoseRef.current);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const releaseGesture = useCallback(() => {
|
||||
const gesture = gestureRef.current;
|
||||
gestureRef.current = null;
|
||||
const viewport = viewportRef.current;
|
||||
if (gesture && viewport?.hasPointerCapture(gesture.id)) {
|
||||
viewport.releasePointerCapture(gesture.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sceneActive || !visible) {
|
||||
releaseGesture();
|
||||
if (frameRef.current !== null)
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
nextPoseRef.current = REST_POSE;
|
||||
setPose(REST_POSE);
|
||||
}
|
||||
return () => {
|
||||
releaseGesture();
|
||||
if (frameRef.current !== null)
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
};
|
||||
}, [sceneActive, visible, releaseGesture]);
|
||||
|
||||
const handleReady = useCallback(() => setReady(true), []);
|
||||
const handleError = useCallback(() => {
|
||||
setFailed(true);
|
||||
setReady(false);
|
||||
}, []);
|
||||
const selectSceneLayer = useCallback((layer: HeroSystemLayer) => {
|
||||
if (!suppressClickRef.current) setActiveLayer(layer);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!sceneActive || !visible || !event.isPrimary || event.button !== 0)
|
||||
return;
|
||||
suppressClickRef.current = false;
|
||||
gestureRef.current = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
startPose: nextPoseRef.current.x,
|
||||
width: event.currentTarget.clientWidth,
|
||||
dragging: false,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!sceneActive || !visible) return;
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture && gesture.id === event.pointerId) {
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
if (!gesture.dragging) {
|
||||
if (Math.abs(dy) > 7 && Math.abs(dy) > Math.abs(dx)) {
|
||||
releaseGesture();
|
||||
return;
|
||||
}
|
||||
if (Math.abs(dx) < 7) return;
|
||||
gesture.dragging = true;
|
||||
suppressClickRef.current = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
queuePose(gesture.startPose + (dx / Math.max(gesture.width, 1)) * 3, 0);
|
||||
return;
|
||||
}
|
||||
if (event.pointerType !== "mouse" || event.buttons !== 0) return;
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
queuePose(
|
||||
((event.clientX - bounds.left) / Math.max(bounds.width, 1)) * 2 - 1,
|
||||
((event.clientY - bounds.top) / Math.max(bounds.height, 1)) * 2 - 1,
|
||||
);
|
||||
};
|
||||
|
||||
const cancelGesture = () => {
|
||||
releaseGesture();
|
||||
if (sceneActive && visible) queuePose(0, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hero-system min-w-0 w-full">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="hero-system-viewport relative isolate h-[350px] w-full overflow-hidden sm:h-[400px] lg:h-[440px]"
|
||||
style={{
|
||||
touchAction: "pan-y",
|
||||
cursor: sceneActive ? "grab" : undefined,
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={releaseGesture}
|
||||
onPointerCancel={cancelGesture}
|
||||
onLostPointerCapture={releaseGesture}
|
||||
onPointerLeave={() => {
|
||||
if (!gestureRef.current?.dragging) cancelGesture();
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
if (suppressClickRef.current) event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center px-sp-3 ${sceneActive ? "pointer-events-none opacity-0" : "opacity-100"}`}
|
||||
aria-hidden={sceneActive}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="mb-sp-3 flex items-center justify-between gap-sp-3 text-[12px] text-white/65">
|
||||
<span className="font-medium text-[#f4f2ed]">
|
||||
Interface real: NutriScan
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative block aspect-[2/1] overflow-hidden rounded-lg border border-white/15 bg-[#151a16] shadow-[0_24px_70px_rgba(0,0,0,0.45)]">
|
||||
<Image
|
||||
src="/assets/notebook/nutriscan.webp"
|
||||
alt="Tela real do projeto NutriScan."
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 44vw, 92vw"
|
||||
className="object-cover object-top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderScene && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 ${sceneActive ? "opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
>
|
||||
<DynamicScene
|
||||
activeLayer={activeLayer}
|
||||
pose={pose}
|
||||
visible={visible}
|
||||
onReady={handleReady}
|
||||
onError={handleError}
|
||||
onSelectLayer={selectSceneLayer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{sceneActive && (
|
||||
<p className="pointer-events-none absolute inset-x-0 bottom-sp-3 text-center text-[12px] text-white/60">
|
||||
<span className="hidden sm:inline">
|
||||
Mova ou arraste para explorar
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
Arraste para os lados para explorar
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative px-sp-1 sm:px-sp-3">
|
||||
<fieldset
|
||||
aria-label="Explorar camadas do sistema"
|
||||
className="grid grid-cols-2 gap-1 min-[400px]:grid-cols-4"
|
||||
>
|
||||
{LAYERS.map((layer) => (
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
aria-pressed={activeLayer === layer.id}
|
||||
aria-controls={descriptionId}
|
||||
onClick={() => {
|
||||
suppressClickRef.current = false;
|
||||
setActiveLayer(layer.id);
|
||||
if (layer.id === "overview" && sceneActive && visible)
|
||||
queuePose(0, 0);
|
||||
}}
|
||||
className={`min-h-11 rounded-sm px-sp-2 py-sp-2 text-[12px] font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none sm:text-[13px] ${activeLayer === layer.id ? "bg-[var(--slash)] text-[var(--ink)]" : "bg-white/5 text-white/75 hover:bg-white/10 hover:text-white"}`}
|
||||
>
|
||||
{layer.label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<p
|
||||
id={descriptionId}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="mt-sp-3 min-h-[4.5em] text-[13px] leading-[1.5] text-white/75 sm:min-h-[3em]"
|
||||
>
|
||||
{LAYERS.find((layer) => layer.id === activeLayer)?.description}
|
||||
</p>
|
||||
<div className="mt-sp-1 flex flex-wrap items-center justify-between gap-x-sp-3">
|
||||
<p className="max-w-[230px] text-[11px] leading-[1.5] text-white/55">
|
||||
Camadas ilustrativas de um sistema
|
||||
</p>
|
||||
<Link
|
||||
href="/cases/nutriscan"
|
||||
className="inline-flex min-h-11 items-center text-[12px] font-medium text-[#f4f2ed] underline decoration-white/35 underline-offset-4 hover:decoration-[var(--slash)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
|
||||
>
|
||||
Interface real: NutriScan
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
"use client";
|
||||
|
||||
import { RoundedBox } from "@react-three/drei";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
Component,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
export type HeroSystemLayer =
|
||||
| "overview"
|
||||
| "interface"
|
||||
| "integrations"
|
||||
| "data";
|
||||
|
||||
export type HeroSystemSceneProps = {
|
||||
activeLayer: HeroSystemLayer;
|
||||
pose: { x: number; y: number };
|
||||
visible: boolean;
|
||||
onReady: () => void;
|
||||
onError: () => void;
|
||||
onSelectLayer: (layer: HeroSystemLayer) => void;
|
||||
};
|
||||
|
||||
type LayerPose = readonly [number, number, number, number];
|
||||
type SceneTextures = {
|
||||
screen: THREE.CanvasTexture;
|
||||
integrations: THREE.CanvasTexture;
|
||||
data: THREE.CanvasTexture;
|
||||
};
|
||||
|
||||
const LIME = "#ccff00";
|
||||
const LAYERS = ["interface", "integrations", "data"] as const;
|
||||
const LAYER_POSES: Record<HeroSystemLayer, readonly LayerPose[]> = {
|
||||
overview: [
|
||||
[-0.38, -0.42, 0.9, 1],
|
||||
[0.12, 0.25, 0, 1],
|
||||
[0.65, 0.92, -0.9, 1],
|
||||
],
|
||||
interface: [
|
||||
[-0.1, -0.15, 1.3, 1.04],
|
||||
[0.36, 0.6, -0.45, 0.91],
|
||||
[0.8, 1.08, -1.25, 0.88],
|
||||
],
|
||||
integrations: [
|
||||
[-0.82, -1.05, 1.05, 0.77],
|
||||
[0.12, 0.55, 0.55, 1.02],
|
||||
[0.76, 1.16, -1, 0.88],
|
||||
],
|
||||
data: [
|
||||
[-0.9, -1.25, 1.02, 0.68],
|
||||
[-0.44, -0.48, 0.18, 0.81],
|
||||
[0.38, 1.08, 0.55, 1.02],
|
||||
],
|
||||
};
|
||||
const NODE_X = [-1.28, 0, 1.28];
|
||||
const SERVER_ROWS = [-0.47, -0.17, 0.13];
|
||||
|
||||
function canvasContext(width: number, height: number) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas 2D is unavailable");
|
||||
return { canvas, context };
|
||||
}
|
||||
|
||||
function asTexture(canvas: HTMLCanvasElement) {
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.anisotropy = 4;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createBoardTexture(layer: "integrations" | "data") {
|
||||
const { canvas, context: ctx } = canvasContext(1024, 640);
|
||||
ctx.fillStyle = "#121a1b";
|
||||
ctx.fillRect(0, 0, 1024, 640);
|
||||
ctx.strokeStyle = "#293737";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 32; x < 1024; x += 40) {
|
||||
for (let y = 32; y < 640; y += 40) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 2, y);
|
||||
ctx.lineTo(x + 2, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = "#edf0e6";
|
||||
ctx.font = "500 32px sans-serif";
|
||||
ctx.fillText(layer === "integrations" ? "Integrações" : "Dados", 46, 61);
|
||||
ctx.fillStyle = LIME;
|
||||
ctx.fillRect(932, 43, 42, 5);
|
||||
ctx.strokeStyle = "#455c50";
|
||||
ctx.lineWidth = 3;
|
||||
const endpoints = [175, 512, 849];
|
||||
for (const x of endpoints) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 132);
|
||||
ctx.lineTo(x, 231);
|
||||
ctx.lineTo(512, 300);
|
||||
ctx.lineTo(512, 490);
|
||||
ctx.lineTo(x, 490);
|
||||
ctx.lineTo(x, 556);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = LIME;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, 556, 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.strokeStyle = "#a6c748";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(52, 342);
|
||||
ctx.lineTo(125, 342);
|
||||
ctx.lineTo(206, 422);
|
||||
ctx.lineTo(820, 422);
|
||||
ctx.lineTo(899, 342);
|
||||
ctx.lineTo(972, 342);
|
||||
ctx.stroke();
|
||||
return asTexture(canvas);
|
||||
}
|
||||
|
||||
function useSceneTextures(onError: () => void) {
|
||||
const [textures, setTextures] = useState<SceneTextures | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const owned: THREE.Texture[] = [];
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.onload = () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const { canvas, context } = canvasContext(1600, 800);
|
||||
// Keep the actual product interface; exclude the old metrics footer.
|
||||
context.drawImage(image, 0, 0, 1600, 800, 0, 0, 1600, 800);
|
||||
const screen = asTexture(canvas);
|
||||
owned.push(screen);
|
||||
const integrations = createBoardTexture("integrations");
|
||||
owned.push(integrations);
|
||||
const data = createBoardTexture("data");
|
||||
owned.push(data);
|
||||
setTextures({ screen, integrations, data });
|
||||
} catch {
|
||||
onError();
|
||||
}
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (!cancelled) onError();
|
||||
};
|
||||
image.src = "/assets/notebook/nutriscan.webp";
|
||||
return () => {
|
||||
cancelled = true;
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.removeAttribute("src");
|
||||
for (const texture of owned) texture.dispose();
|
||||
};
|
||||
}, [onError]);
|
||||
return textures;
|
||||
}
|
||||
|
||||
function Plate({ selected }: { selected: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<RoundedBox args={[4.28, 2.72, 0.14]} radius={0.06} smoothness={4}>
|
||||
<meshStandardMaterial
|
||||
color={selected ? "#93ad76" : "#687579"}
|
||||
metalness={0.62}
|
||||
roughness={0.29}
|
||||
/>
|
||||
</RoundedBox>
|
||||
<RoundedBox
|
||||
args={[4.2, 2.64, 0.08]}
|
||||
radius={0.03}
|
||||
smoothness={4}
|
||||
position={[0, 0, 0.075]}
|
||||
>
|
||||
<meshStandardMaterial color="#111919" metalness={0.3} roughness={0.4} />
|
||||
</RoundedBox>
|
||||
<mesh position={[0, -1.3, 0.124]}>
|
||||
<boxGeometry args={[3.8, 0.018, 0.014]} />
|
||||
<meshBasicMaterial color={selected ? LIME : "#667451"} />
|
||||
</mesh>
|
||||
{[-1.96, 1.96].map((x) => (
|
||||
<mesh key={x} position={[x, 1.18, 0.13]}>
|
||||
<circleGeometry args={[0.025, 12]} />
|
||||
<meshStandardMaterial
|
||||
color="#b6c0b7"
|
||||
metalness={0.8}
|
||||
roughness={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InterfacePlate({ texture }: { texture: THREE.Texture }) {
|
||||
return (
|
||||
<>
|
||||
<RoundedBox
|
||||
args={[4.02, 2.09, 0.035]}
|
||||
position={[0, -0.075, 0.139]}
|
||||
radius={0.012}
|
||||
smoothness={3}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color="#dfe5d7"
|
||||
metalness={0.2}
|
||||
roughness={0.38}
|
||||
/>
|
||||
</RoundedBox>
|
||||
<mesh position={[0, -0.075, 0.16]}>
|
||||
<planeGeometry args={[3.94, 1.97]} />
|
||||
<meshBasicMaterial map={texture} toneMapped={false} />
|
||||
</mesh>
|
||||
<mesh position={[-1.84, 1.16, 0.13]}>
|
||||
<circleGeometry args={[0.025, 16]} />
|
||||
<meshBasicMaterial color={LIME} />
|
||||
</mesh>
|
||||
<mesh position={[-1.6, 1.16, 0.13]}>
|
||||
<boxGeometry args={[0.28, 0.018, 0.012]} />
|
||||
<meshBasicMaterial color="#b3bcb3" />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.16, 0.13]}>
|
||||
<boxGeometry args={[1.35, 0.12, 0.015]} />
|
||||
<meshStandardMaterial color="#2b3633" roughness={0.7} />
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationNodes() {
|
||||
return (
|
||||
<>
|
||||
{NODE_X.map((x) => (
|
||||
<group key={x} position={[x, 0.45, 0.21]}>
|
||||
<RoundedBox args={[0.7, 0.52, 0.14]} radius={0.06} smoothness={3}>
|
||||
<meshStandardMaterial
|
||||
color="#d6ddcf"
|
||||
metalness={0.5}
|
||||
roughness={0.28}
|
||||
/>
|
||||
</RoundedBox>
|
||||
<mesh position={[0, 0, 0.077]}>
|
||||
<planeGeometry args={[0.53, 0.35]} />
|
||||
<meshStandardMaterial
|
||||
color="#25382d"
|
||||
roughness={0.36}
|
||||
metalness={0.35}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, 0, 0.083]}>
|
||||
<boxGeometry args={[0.2, 0.035, 0.01]} />
|
||||
<meshBasicMaterial color={LIME} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
<RoundedBox
|
||||
args={[1.03, 0.68, 0.2]}
|
||||
radius={0.055}
|
||||
smoothness={3}
|
||||
position={[0, -0.38, 0.24]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color="#89a962"
|
||||
metalness={0.55}
|
||||
roughness={0.32}
|
||||
/>
|
||||
</RoundedBox>
|
||||
<mesh position={[0, -0.38, 0.345]}>
|
||||
<planeGeometry args={[0.77, 0.44]} />
|
||||
<meshStandardMaterial color="#15251c" metalness={0.2} roughness={0.5} />
|
||||
</mesh>
|
||||
{[-0.15, 0, 0.15].map((y) => (
|
||||
<mesh key={y} position={[0, -0.38 + y, 0.352]}>
|
||||
<boxGeometry args={[0.43, 0.022, 0.01]} />
|
||||
<meshBasicMaterial color={LIME} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DataVolumes() {
|
||||
return (
|
||||
<>
|
||||
{[-1.25, 0].map((x) => (
|
||||
<group key={x} position={[x, 0.03, 0.3]}>
|
||||
<RoundedBox args={[0.86, 1.24, 0.3]} radius={0.065} smoothness={3}>
|
||||
<meshStandardMaterial
|
||||
color="#51645f"
|
||||
metalness={0.6}
|
||||
roughness={0.3}
|
||||
/>
|
||||
</RoundedBox>
|
||||
{SERVER_ROWS.map((y) => (
|
||||
<group key={y} position={[0, y + 0.17, 0.159]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[0.7, 0.21, 0.035]} />
|
||||
<meshStandardMaterial
|
||||
color="#142020"
|
||||
metalness={0.3}
|
||||
roughness={0.45}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[-0.25, 0, 0.022]}>
|
||||
<circleGeometry args={[0.021, 12]} />
|
||||
<meshBasicMaterial color={LIME} />
|
||||
</mesh>
|
||||
<mesh position={[0.06, 0, 0.022]}>
|
||||
<boxGeometry args={[0.34, 0.025, 0.013]} />
|
||||
<meshStandardMaterial
|
||||
color="#a6b3a5"
|
||||
metalness={0.6}
|
||||
roughness={0.35}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
))}
|
||||
<group position={[1.28, 0.02, 0.59]}>
|
||||
{[-0.2, 0, 0.2].map((y) => (
|
||||
<group key={y} position={[0, y, 0]}>
|
||||
<mesh>
|
||||
<cylinderGeometry args={[0.43, 0.43, 0.17, 40]} />
|
||||
<meshStandardMaterial
|
||||
color="#a8b6a4"
|
||||
metalness={0.62}
|
||||
roughness={0.29}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, 0.078, 0]}>
|
||||
<cylinderGeometry args={[0.435, 0.435, 0.015, 40]} />
|
||||
<meshBasicMaterial color={LIME} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemAssembly(
|
||||
props: HeroSystemSceneProps & {
|
||||
textures: SceneTextures;
|
||||
reducedMotion: boolean;
|
||||
},
|
||||
) {
|
||||
const { textures, reducedMotion } = props;
|
||||
const root = useRef<THREE.Group>(null);
|
||||
const plates = useRef<(THREE.Group | null)[]>([]);
|
||||
const entrance = useRef(0);
|
||||
const ready = useRef(false);
|
||||
const failed = useRef(false);
|
||||
const { camera, gl, invalidate, size } = useThree();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!(camera instanceof THREE.PerspectiveCamera)) return;
|
||||
camera.zoom = Math.min(1.1, size.width / Math.max(size.height, 1) / 1.12);
|
||||
camera.updateProjectionMatrix();
|
||||
if (props.visible) invalidate();
|
||||
}, [camera, invalidate, props.visible, size.height, size.width]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visible) invalidate();
|
||||
}, [invalidate, props]);
|
||||
|
||||
// Render on demand and stop scheduling as soon as every transform settles.
|
||||
useFrame((state, delta) => {
|
||||
if (
|
||||
!props.visible ||
|
||||
document.hidden ||
|
||||
failed.current ||
|
||||
!root.current ||
|
||||
gl.getContext().isContextLost()
|
||||
)
|
||||
return;
|
||||
const dt = Math.min(delta, 1 / 30);
|
||||
entrance.current = reducedMotion
|
||||
? 1
|
||||
: Math.min(1, entrance.current + dt / 1.05);
|
||||
const assembly = (1 - entrance.current) ** 3;
|
||||
const alpha = reducedMotion ? 1 : 1 - Math.exp(-11 * dt);
|
||||
const targetX = -0.12 + Math.max(-1, Math.min(1, props.pose.y)) * 0.11;
|
||||
const targetY = -0.36 + Math.max(-1, Math.min(1, props.pose.x)) * 0.16;
|
||||
let moving = entrance.current < 1;
|
||||
const rotation = root.current.rotation;
|
||||
rotation.x += (targetX - rotation.x) * alpha;
|
||||
rotation.y += (targetY - rotation.y) * alpha;
|
||||
if (
|
||||
Math.abs(targetX - rotation.x) + Math.abs(targetY - rotation.y) >
|
||||
0.0004
|
||||
)
|
||||
moving = true;
|
||||
else {
|
||||
rotation.x = targetX;
|
||||
rotation.y = targetY;
|
||||
}
|
||||
const targets = LAYER_POSES[props.activeLayer];
|
||||
for (let index = 0; index < LAYERS.length; index++) {
|
||||
const plate = plates.current[index];
|
||||
if (!plate) continue;
|
||||
const target = targets[index];
|
||||
const x = target[0] + (index - 1) * assembly * 0.65;
|
||||
const y = target[1] + (index - 1) * assembly * 0.5;
|
||||
const z = target[2] - (3 - index) * assembly * 0.8;
|
||||
plate.position.x += (x - plate.position.x) * alpha;
|
||||
plate.position.y += (y - plate.position.y) * alpha;
|
||||
plate.position.z += (z - plate.position.z) * alpha;
|
||||
const scale = plate.scale.x + (target[3] - plate.scale.x) * alpha;
|
||||
plate.scale.setScalar(scale);
|
||||
const remaining =
|
||||
Math.abs(x - plate.position.x) +
|
||||
Math.abs(y - plate.position.y) +
|
||||
Math.abs(z - plate.position.z) +
|
||||
Math.abs(target[3] - scale);
|
||||
if (remaining > 0.0008) moving = true;
|
||||
else {
|
||||
plate.position.set(x, y, z);
|
||||
plate.scale.setScalar(target[3]);
|
||||
}
|
||||
}
|
||||
try {
|
||||
gl.render(state.scene, camera);
|
||||
if (!ready.current && !gl.getContext().isContextLost()) {
|
||||
ready.current = true;
|
||||
props.onReady();
|
||||
}
|
||||
} catch {
|
||||
failed.current = true;
|
||||
props.onError();
|
||||
return;
|
||||
}
|
||||
if (moving) invalidate();
|
||||
}, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={1.15} />
|
||||
<hemisphereLight args={["#f6faed", "#283b30", 1.8]} />
|
||||
<directionalLight position={[1, 5, 6]} color="#f6f9ef" intensity={3.7} />
|
||||
<directionalLight position={[-5, 1, 2]} color={LIME} intensity={2.1} />
|
||||
<directionalLight position={[4, -1, -3]} color="#e0e8df" intensity={3} />
|
||||
<group
|
||||
ref={root}
|
||||
rotation={[-0.12, -0.36, -0.055]}
|
||||
position={[0, -0.07, 0]}
|
||||
>
|
||||
{LAYERS.map((layer, index) => (
|
||||
<group
|
||||
key={layer}
|
||||
ref={(group) => {
|
||||
plates.current[index] = group;
|
||||
}}
|
||||
position={[
|
||||
LAYER_POSES.overview[index][0],
|
||||
LAYER_POSES.overview[index][1] + (index - 1) * 0.5,
|
||||
LAYER_POSES.overview[index][2] - (3 - index) * 0.8,
|
||||
]}
|
||||
onPointerUp={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onSelectLayer(layer);
|
||||
}}
|
||||
>
|
||||
<Plate
|
||||
selected={
|
||||
props.activeLayer === layer || props.activeLayer === "overview"
|
||||
}
|
||||
/>
|
||||
{layer === "interface" ? (
|
||||
<InterfacePlate texture={textures.screen} />
|
||||
) : (
|
||||
<>
|
||||
<mesh position={[0, 0, 0.12]}>
|
||||
<planeGeometry args={[4.02, 2.5]} />
|
||||
<meshBasicMaterial map={textures[layer]} toneMapped={false} />
|
||||
</mesh>
|
||||
{layer === "integrations" ? (
|
||||
<IntegrationNodes />
|
||||
) : (
|
||||
<DataVolumes />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneContent(
|
||||
props: HeroSystemSceneProps & { reducedMotion: boolean },
|
||||
) {
|
||||
const textures = useSceneTextures(props.onError);
|
||||
const gl = useThree((state) => state.gl);
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement;
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
props.onError();
|
||||
};
|
||||
canvas.addEventListener("webglcontextlost", handleContextLost);
|
||||
return () =>
|
||||
canvas.removeEventListener("webglcontextlost", handleContextLost);
|
||||
}, [gl, props.onError]);
|
||||
return textures ? <SystemAssembly {...props} textures={textures} /> : null;
|
||||
}
|
||||
|
||||
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 HeroSystemScene(props: HeroSystemSceneProps) {
|
||||
const [tabVisible, setTabVisible] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const updateVisibility = () => setTabVisible(!document.hidden);
|
||||
const updateMotion = () => setReducedMotion(media.matches);
|
||||
updateVisibility();
|
||||
updateMotion();
|
||||
document.addEventListener("visibilitychange", updateVisibility);
|
||||
media.addEventListener("change", updateMotion);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", updateVisibility);
|
||||
media.removeEventListener("change", updateMotion);
|
||||
};
|
||||
}, []);
|
||||
const visible = props.visible && tabVisible;
|
||||
useEffect(() => {
|
||||
if (visible) setStarted(true);
|
||||
}, [visible]);
|
||||
if (!started) return null;
|
||||
return (
|
||||
<SceneBoundary onError={props.onError}>
|
||||
<Canvas
|
||||
aria-hidden="true"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
camera={{ fov: 32, position: [0, 0, 10], near: 0.1, far: 40 }}
|
||||
dpr={[1, 1.5]}
|
||||
frameloop={visible ? "demand" : "never"}
|
||||
gl={{ alpha: true, antialias: true, powerPreference: "low-power" }}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SceneContent
|
||||
{...props}
|
||||
visible={visible}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</SceneBoundary>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -28,20 +28,47 @@ export function useHomeFragmentNavigation() {
|
||||
// A history restoration owns its saved position, not the URL's old fragment.
|
||||
let pending =
|
||||
navigation?.type === "back_forward" ? "" : window.location.hash;
|
||||
let pendingTop =
|
||||
navigation?.type !== "back_forward" && !window.location.hash;
|
||||
if (navigation?.type !== "back_forward")
|
||||
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;
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -88,11 +115,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 +138,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);
|
||||
|
||||
@@ -342,11 +342,34 @@ function NotebookScene({
|
||||
projects,
|
||||
requestRender,
|
||||
isVisible,
|
||||
onStageError,
|
||||
}: 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);
|
||||
const { camera, gl, size, invalidate } = useThree();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!(camera instanceof THREE.PerspectiveCamera)) return;
|
||||
// Preserve desktop framing; widen portrait views without stretching the LCD.
|
||||
camera.zoom =
|
||||
window.innerWidth <= 1024
|
||||
? Math.min(1, size.width / Math.max(size.height, 1) / 1.5)
|
||||
: 1;
|
||||
camera.updateProjectionMatrix();
|
||||
invalidate();
|
||||
}, [camera, invalidate, size.width, size.height]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement;
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onStageError?.();
|
||||
};
|
||||
canvas.addEventListener("webglcontextlost", handleContextLost);
|
||||
return () =>
|
||||
canvas.removeEventListener("webglcontextlost", handleContextLost);
|
||||
}, [gl, onStageError]);
|
||||
|
||||
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStory, { canUseNotebook3D } from "./NotebookStory";
|
||||
|
||||
@@ -29,6 +36,7 @@ jest.mock("gsap/ScrollTrigger", () => ({
|
||||
const projects = getFeaturedProjects(null);
|
||||
|
||||
let intersectionCallback: IntersectionObserverCallback | undefined;
|
||||
let reducedMotionListener: (() => void) | undefined;
|
||||
const mockObserve = jest.fn();
|
||||
const mockDisconnect = jest.fn();
|
||||
|
||||
@@ -63,7 +71,11 @@ function mockMatchMedia({
|
||||
(query === "(hover: hover)" && hover),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
addEventListener: jest.fn((event: string, callback: () => void) => {
|
||||
if (query === "(prefers-reduced-motion: reduce)" && event === "change") {
|
||||
reducedMotionListener = callback;
|
||||
}
|
||||
}),
|
||||
removeEventListener: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
@@ -80,6 +92,7 @@ describe("NotebookStory", () => {
|
||||
});
|
||||
setDocumentHidden(false);
|
||||
intersectionCallback = undefined;
|
||||
reducedMotionListener = undefined;
|
||||
mockObserve.mockReset();
|
||||
mockDisconnect.mockReset();
|
||||
class MockIntersectionObserver {
|
||||
@@ -115,17 +128,53 @@ describe("NotebookStory", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not initialize the 3D stage on coarse pointers", async () => {
|
||||
mockMatchMedia({ coarse: true });
|
||||
it("initializes the 3D stage on a narrow touch viewport without hover", async () => {
|
||||
mockMatchMedia({ coarse: true, hover: false });
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 390,
|
||||
});
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
expect(await screen.findByTestId("notebook-stage")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("notebook-fallback")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets touch users select and open a project outside the scaled screen", async () => {
|
||||
mockMatchMedia({ coarse: true, hover: false });
|
||||
render(<NotebookStory projects={projects} />);
|
||||
await screen.findByTestId("notebook-stage");
|
||||
const controls = screen.getByRole("navigation", {
|
||||
name: "Navegar projetos do notebook",
|
||||
});
|
||||
const button = within(controls).getByRole("button", {
|
||||
name: /Mostrar SC/i,
|
||||
});
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(button).toHaveAttribute("aria-pressed", "true");
|
||||
expect(
|
||||
within(controls).getByRole("link", { name: /Abrir projeto/i }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
projects.find((project) => project.id === "sc-plus")?.demoUrl,
|
||||
);
|
||||
});
|
||||
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
|
||||
|
||||
it("returns to static content if reduced motion is enabled during the story", async () => {
|
||||
mockMatchMedia({ coarse: true, hover: false });
|
||||
render(<NotebookStory projects={projects} />);
|
||||
await screen.findByTestId("notebook-stage");
|
||||
|
||||
mockMatchMedia({ reduced: true, coarse: true, hover: false });
|
||||
act(() => reducedMotionListener?.());
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument();
|
||||
});
|
||||
it("initializes the 3D stage on a desktop viewport", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -155,22 +204,19 @@ describe("NotebookStory", () => {
|
||||
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 () => {
|
||||
it("keeps the mounted 3D stage when resizing from desktop to touch mobile", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
||||
);
|
||||
const stage = await screen.findByTestId("notebook-stage");
|
||||
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
value: 360,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
mockMatchMedia({ coarse: true, hover: false });
|
||||
act(() => window.dispatchEvent(new Event("resize")));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("notebook-stage")).toBe(stage);
|
||||
expect(screen.queryByTestId("notebook-fallback")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the static fallback when no mapped projects are available", async () => {
|
||||
@@ -187,17 +233,15 @@ describe("NotebookStory", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("requires desktop hover input without reduced motion", () => {
|
||||
it("respects reduced motion independently of width and pointer capabilities", () => {
|
||||
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({ matchMedia })).toBe(true);
|
||||
expect(
|
||||
canUseNotebook3D({
|
||||
innerWidth: 1280,
|
||||
matchMedia: (query) =>
|
||||
({
|
||||
matches:
|
||||
|
||||
@@ -12,7 +12,7 @@ const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
|
||||
type CapabilityWindow = Pick<Window, "matchMedia">;
|
||||
|
||||
export type NotebookStoryProps = {
|
||||
projects: PortfolioProjectView[];
|
||||
@@ -21,12 +21,7 @@ export type NotebookStoryProps = {
|
||||
|
||||
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
|
||||
);
|
||||
return !view.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
export function getNotebookRunwayVh(tabCount: number): number {
|
||||
@@ -84,21 +79,11 @@ export default function NotebookStory({
|
||||
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);
|
||||
});
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
reducedMotion.addEventListener("change", updateEngine);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateEngine);
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
mediaQuery.removeEventListener("change", updateEngine);
|
||||
});
|
||||
reducedMotion.removeEventListener("change", updateEngine);
|
||||
};
|
||||
}, [notebookProjects.length]);
|
||||
|
||||
@@ -224,6 +209,8 @@ export default function NotebookStory({
|
||||
[engine, notebookProjects.length],
|
||||
);
|
||||
|
||||
const activeProject = notebookProjects[activeIndex];
|
||||
|
||||
if (!engine) {
|
||||
return (
|
||||
<section
|
||||
@@ -235,6 +222,7 @@ export default function NotebookStory({
|
||||
style={
|
||||
{
|
||||
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
|
||||
"--notebook-mobile-runway": `${getNotebookRunwayVh(notebookProjects.length) * 0.8}svh`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -269,6 +257,7 @@ export default function NotebookStory({
|
||||
style={
|
||||
{
|
||||
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
|
||||
"--notebook-mobile-runway": `${getNotebookRunwayVh(notebookProjects.length) * 0.8}svh`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -295,6 +284,45 @@ export default function NotebookStory({
|
||||
requestRender={requestRenderRef}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
<nav
|
||||
className="notebook-story-controls"
|
||||
aria-label="Navegar projetos do notebook"
|
||||
>
|
||||
<div className="notebook-story-project-buttons">
|
||||
{notebookProjects.map((project, index) => (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
aria-label={`Mostrar ${project.title}`}
|
||||
aria-pressed={index === activeIndex}
|
||||
onClick={() => handleSelectTab(index)}
|
||||
>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p aria-live="polite">{activeProject?.title}</p>
|
||||
{activeProject?.demoAvailability === "unavailable" ? (
|
||||
<span aria-disabled="true">
|
||||
Demo indisponível na última verificação
|
||||
</span>
|
||||
) : activeProject?.demoAvailability !== "none" &&
|
||||
activeProject?.demoUrl ? (
|
||||
<a
|
||||
href={activeProject.demoUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Abrir projeto
|
||||
{activeProject.demoAvailability === "unknown" && (
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
— Disponibilidade não verificada
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
+123
-145
@@ -47,6 +47,7 @@
|
||||
--r-md: 8px;
|
||||
--r-lg: 16px;
|
||||
--r-full: 999px;
|
||||
--site-header-height: 60px;
|
||||
--font-display: var(--font-space-grotesk), sans-serif;
|
||||
--font-hero: var(--font-monument-extended), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), monospace;
|
||||
@@ -79,7 +80,13 @@ body {
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 68px;
|
||||
scroll-padding-top: calc(var(--site-header-height) + var(--sp-4));
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
:root {
|
||||
--site-header-height: 68px;
|
||||
}
|
||||
}
|
||||
|
||||
.site-header-nav,
|
||||
@@ -764,138 +771,6 @@ textarea {
|
||||
CINEMATIC STYLES - Awwwards Level
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* Cinematic text gradient - hero heading */
|
||||
.cinematic-text-gradient {
|
||||
color: #f4f2ed;
|
||||
}
|
||||
|
||||
/* Solid surfaces avoid expensive backdrop blur during first paint. */
|
||||
.cinematic-glass {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(10, 10, 10, 0.78);
|
||||
transition:
|
||||
border-color 0.25s ease,
|
||||
background 0.25s ease;
|
||||
}
|
||||
.cinematic-glass:hover {
|
||||
border-color: rgba(204, 255, 0, 0.35);
|
||||
background: rgba(10, 10, 10, 0.9);
|
||||
}
|
||||
|
||||
/* Primary CTA - cinematic glow button */
|
||||
.cinematic-cta-primary {
|
||||
position: relative;
|
||||
background: var(--slash);
|
||||
overflow: hidden;
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.cinematic-cta-primary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.4) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.cinematic-cta-primary:hover {
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
0 0 30px rgba(204, 255, 0, 0.4),
|
||||
0 0 60px rgba(204, 255, 0, 0.15);
|
||||
}
|
||||
|
||||
.cinematic-cta-primary:hover::before {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* Secondary CTA */
|
||||
.cinematic-cta-secondary {
|
||||
position: relative;
|
||||
border: 1px solid rgba(244, 242, 237, 0.2);
|
||||
background: rgba(10, 10, 10, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
overflow: hidden;
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.cinematic-cta-secondary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 30%,
|
||||
rgba(204, 255, 0, 0.3) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
background-size: 200% 200%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.cinematic-cta-secondary:hover {
|
||||
border-color: var(--slash);
|
||||
color: var(--slash);
|
||||
box-shadow: 0 0 20px rgba(204, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
.cinematic-cta-secondary:hover::before {
|
||||
opacity: 1;
|
||||
animation: borderGlow 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes borderGlow {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 200%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Social links */
|
||||
.cinematic-social-link {
|
||||
border: 1px solid rgba(244, 242, 237, 0.12);
|
||||
background: rgba(10, 10, 10, 0.3);
|
||||
color: rgba(244, 242, 237, 0.86);
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.cinematic-social-link:hover {
|
||||
border-color: var(--slash);
|
||||
background: rgba(204, 255, 0, 0.08);
|
||||
color: var(--slash);
|
||||
box-shadow: 0 0 20px rgba(204, 255, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Aside card */
|
||||
.cinematic-aside-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 10, 10, 0.6) 0%,
|
||||
rgba(10, 10, 10, 0.3) 100%
|
||||
);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.cinematic-aside-card:hover {
|
||||
border-color: rgba(204, 255, 0, 0.12);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.4),
|
||||
0 0 1px rgba(204, 255, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Section divider animations */
|
||||
@keyframes shimmerDivider {
|
||||
0% {
|
||||
@@ -1053,9 +928,7 @@ textarea {
|
||||
min-height: var(--notebook-runway, 560vh);
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) and (hover: hover) and (pointer: fine) and (
|
||||
prefers-reduced-motion: no-preference
|
||||
) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.notebook-story-pending {
|
||||
min-height: var(--notebook-runway, 560vh);
|
||||
}
|
||||
@@ -1133,6 +1006,10 @@ textarea {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notebook-story-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notebook-story-static-content {
|
||||
width: min(1080px, 100%);
|
||||
margin: 0 auto;
|
||||
@@ -1529,16 +1406,18 @@ textarea {
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.notebook-story-runway {
|
||||
min-height: auto;
|
||||
min-height: var(--notebook-mobile-runway, 440svh);
|
||||
}
|
||||
|
||||
.notebook-story-sticky {
|
||||
position: relative;
|
||||
min-height: auto;
|
||||
height: 100svh;
|
||||
min-height: 100svh;
|
||||
padding: 64px 20px 16px;
|
||||
}
|
||||
|
||||
.notebook-story-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notebook-story-copy {
|
||||
@@ -1547,10 +1426,77 @@ textarea {
|
||||
|
||||
.notebook-story-copy h2 {
|
||||
max-width: 12ch;
|
||||
margin-top: 8px;
|
||||
font-size: clamp(32px, 6vw, 44px);
|
||||
}
|
||||
|
||||
.notebook-story-copy p {
|
||||
max-width: 44ch;
|
||||
margin-top: 8px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.notebook-story-stage {
|
||||
min-height: min(78vw, 620px);
|
||||
height: min(68vw, 38svh);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.notebook-story-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notebook-story-project-buttons {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notebook-story-project-buttons button,
|
||||
.notebook-story-controls > a {
|
||||
display: inline-flex;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(244, 242, 237, 0.24);
|
||||
color: var(--white);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.notebook-story-project-buttons button[aria-pressed="true"] {
|
||||
border-color: var(--slash);
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
.notebook-story-project-buttons button:focus-visible,
|
||||
.notebook-story-controls > a:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.notebook-story-controls > p {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notebook-story-controls > span {
|
||||
max-width: 18ch;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) and (prefers-reduced-motion: no-preference) {
|
||||
.notebook-story-pending {
|
||||
min-height: var(--notebook-mobile-runway, 440svh);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1560,11 +1506,7 @@ textarea {
|
||||
}
|
||||
|
||||
.notebook-story-copy h2 {
|
||||
font-size: clamp(38px, 14vw, 64px);
|
||||
}
|
||||
|
||||
.notebook-story-stage {
|
||||
min-height: 84vw;
|
||||
font-size: clamp(32px, 8vw, 44px);
|
||||
}
|
||||
|
||||
.notebook-fallback {
|
||||
@@ -1588,6 +1530,15 @@ textarea {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.notebook-screen-surface .notebook-tablist {
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: none;
|
||||
}
|
||||
|
||||
.notebook-screen-surface .notebook-tab-title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notebook-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1597,6 +1548,33 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) and (max-height: 520px) and (orientation: landscape) {
|
||||
.notebook-story-grid {
|
||||
grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.notebook-story-copy h2 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.notebook-story-copy p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notebook-story-stage {
|
||||
height: 48svh;
|
||||
}
|
||||
|
||||
.notebook-story-controls {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.notebook-story-project-buttons {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.notebook-tab,
|
||||
.notebook-cursor,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -107,10 +107,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 +157,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 {
|
||||
@@ -122,14 +123,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 +139,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 +310,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"
|
||||
|
||||
+263
-79
@@ -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,
|
||||
@@ -56,89 +95,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,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.
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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;
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@ 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" },
|
||||
] 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 }> }>
|
||||
> {
|
||||
|
||||
+1
-2
@@ -28,11 +28,10 @@
|
||||
"@react-three/drei": "10.7.7",
|
||||
"@react-three/fiber": "9.5.0",
|
||||
"clsx": "2.1.1",
|
||||
"cobe": "0.6.4",
|
||||
"gsap": "3.13.0",
|
||||
"lucide-react": "0.542.0",
|
||||
"motion": "12.23.12",
|
||||
"next": "16.3.0",
|
||||
"next": "16.3.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "3.3.1",
|
||||
|
||||
Generated
+45
-60
@@ -17,9 +17,6 @@ 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
|
||||
@@ -30,8 +27,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.3
|
||||
version: 16.3.3(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -706,53 +703,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].3':
|
||||
resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==}
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==}
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==}
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==}
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==}
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==}
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==}
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==}
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==}
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -810,8 +807,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==}
|
||||
@@ -1333,9 +1330,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==}
|
||||
|
||||
@@ -2083,8 +2077,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2171,9 +2165,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 +3416,30 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0': {}
|
||||
'@next/[email protected].3': {}
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].3':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].3':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].3':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].3':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].3':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].3':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].3':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].0':
|
||||
'@next/[email protected].3':
|
||||
optional: true
|
||||
|
||||
'@pkgjs/[email protected]':
|
||||
@@ -3519,7 +3510,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@sinonjs/commons': 3.0.1
|
||||
|
||||
'@swc/[email protected].15':
|
||||
'@swc/[email protected].23':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -4011,10 +4002,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
phenomenon: 1.6.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4917,10 +4904,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].0(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
[email protected].3(@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.3
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.11.21
|
||||
caniuse-lite: 1.0.30001810
|
||||
postcss: 8.5.23
|
||||
@@ -4928,14 +4915,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.3
|
||||
'@next/swc-darwin-x64': 16.3.3
|
||||
'@next/swc-linux-arm64-gnu': 16.3.3
|
||||
'@next/swc-linux-arm64-musl': 16.3.3
|
||||
'@next/swc-linux-x64-gnu': 16.3.3
|
||||
'@next/swc-linux-x64-musl': 16.3.3
|
||||
'@next/swc-win32-arm64-msvc': 16.3.3
|
||||
'@next/swc-win32-x64-msvc': 16.3.3
|
||||
sharp: 0.35.4(@types/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -5000,8 +4987,6 @@ snapshots:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
Reference in new issue
Block a user